update
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
TEIL A: Ordner Canvas-Scrollframe / Tab-Farbe
|
||||
TEIL B: Console-Flash - alle specs console=False (OK), basis14.py subprocess ohne CREATE_NO_WINDOW
|
||||
TEIL C: Update-Dialog 3 Optionen + Beim Beenden + Auto-Prompt + Settings-Update-Sektion
|
||||
|
||||
Rollback:
|
||||
Copy-Item 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\backup_local_ui_update_on_exit_20260525_112611\aza_ordner_mixin.py' 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\aza_ordner_mixin.py' -Force
|
||||
Copy-Item 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\backup_local_ui_update_on_exit_20260525_112611\aza_updater.py' 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\aza_updater.py' -Force
|
||||
Copy-Item 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\backup_local_ui_update_on_exit_20260525_112611\basis14.py' 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\basis14.py' -Force
|
||||
Copy-Item 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\backup_local_ui_update_on_exit_20260525_112611\aza_update_core.py' 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\aza_update_core.py' -Force
|
||||
Copy-Item 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\backup_local_ui_update_on_exit_20260525_112611\aza_settings_mixin.py' 'c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\aza_settings_mixin.py' -Force
|
||||
@@ -0,0 +1,370 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AzaOrdnerMixin – AzA-Ordner-Fenster (modernes AzA-Design).
|
||||
Zeigt gespeicherte KG, Briefe, Rezepte, Kostengutsprachen, Diktate, Transkripte.
|
||||
Doppelklick oeffnet Datei. Fenster bleibt immer offen.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
from aza_persistence import (
|
||||
ensure_ablage_dirs,
|
||||
_ablage_base_path,
|
||||
load_ordner_geometry,
|
||||
save_ordner_geometry,
|
||||
list_ablage_files,
|
||||
get_ablage_content,
|
||||
save_to_ablage,
|
||||
count_entries_older_than,
|
||||
delete_entries_older_than,
|
||||
save_autotext,
|
||||
_clamp_geometry_str,
|
||||
)
|
||||
from aza_ui_helpers import center_window, add_resize_grip, add_font_scale_control
|
||||
from aza_config import ABLAGE_SUBFOLDERS
|
||||
|
||||
# ── Design ────────────────────────────────────────────────────────────────────
|
||||
_WIN_BG = "#EEF4F8"
|
||||
_HDR_BG = "#1A4D6D"
|
||||
_HDR_FG = "#FFFFFF"
|
||||
_HDR_SUB = "#A0C0DC"
|
||||
_CARD_BG = "#FFFFFF"
|
||||
_CARD_BD = "#C8D8E8"
|
||||
_TEXT = "#1A3D55"
|
||||
_TEXT_SUB = "#607890"
|
||||
_TAB_ACT = "#1A4D6D"
|
||||
_TAB_INACT = "#D4E7F5"
|
||||
_TAB_FG_A = "#FFFFFF"
|
||||
_TAB_FG_I = "#1A4D6D"
|
||||
_LB_SEL = "#1A8ACC"
|
||||
_FF = "Segoe UI"
|
||||
|
||||
|
||||
class AzaOrdnerMixin:
|
||||
"""Mixin fuer den AzA-Ordner (modernes Design, Doppelklick zum Laden)."""
|
||||
|
||||
def open_ordner_window(self):
|
||||
"""Oeffnet den AzA-Ordner in modernem Design. Bleibt offen bis Benutzer schliesst."""
|
||||
ensure_ablage_dirs()
|
||||
base_path = _ablage_base_path()
|
||||
ORDNER_MIN_W, ORDNER_MIN_H = 660, 560
|
||||
|
||||
win = tk.Toplevel(self)
|
||||
win.title("AzA-Ordner")
|
||||
win.transient(self)
|
||||
win.minsize(ORDNER_MIN_W, ORDNER_MIN_H)
|
||||
win.configure(bg=_WIN_BG)
|
||||
try:
|
||||
win.attributes("-alpha", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(self, "_aza_windows"):
|
||||
self._aza_windows.add(win)
|
||||
self._register_window(win)
|
||||
|
||||
# Groesse setzen — Position wird nach kurzer Verzoegerung gesetzt,
|
||||
# damit Windows/transient die Positionierung nicht ueberschreibt
|
||||
win.geometry(f"{ORDNER_MIN_W}x{ORDNER_MIN_H}")
|
||||
|
||||
def _place_next_to_main():
|
||||
try:
|
||||
win.update_idletasks()
|
||||
sw = win.winfo_screenwidth()
|
||||
sh = win.winfo_screenheight()
|
||||
px = self.winfo_rootx()
|
||||
py = self.winfo_rooty()
|
||||
pw = self.winfo_width()
|
||||
ww, wh = ORDNER_MIN_W, ORDNER_MIN_H
|
||||
x = px + pw + 8
|
||||
if x + ww > sw:
|
||||
x = max(0, px - ww - 8)
|
||||
y = max(0, min(py, sh - wh))
|
||||
win.geometry(f"{ww}x{wh}+{x}+{y}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
win.after(50, _place_next_to_main)
|
||||
|
||||
# Geometrie persistieren
|
||||
_after_id = [None]
|
||||
def _save_geom():
|
||||
try:
|
||||
save_ordner_geometry(win.geometry())
|
||||
except Exception:
|
||||
pass
|
||||
def _on_configure(e):
|
||||
if e.widget is not win:
|
||||
return
|
||||
if _after_id[0]:
|
||||
try:
|
||||
self.after_cancel(_after_id[0])
|
||||
except Exception:
|
||||
pass
|
||||
_after_id[0] = self.after(400, _save_geom)
|
||||
win.bind("<Configure>", _on_configure)
|
||||
|
||||
def _on_close():
|
||||
_save_geom()
|
||||
if hasattr(self, "_aza_windows"):
|
||||
self._aza_windows.discard(win)
|
||||
win.destroy()
|
||||
win.protocol("WM_DELETE_WINDOW", _on_close)
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────
|
||||
hdr = tk.Frame(win, bg=_HDR_BG)
|
||||
hdr.pack(fill="x")
|
||||
hdr_inner = tk.Frame(hdr, bg=_HDR_BG, padx=20, pady=14)
|
||||
hdr_inner.pack(fill="x")
|
||||
tk.Label(hdr_inner, text="AzA-Ordner", bg=_HDR_BG, fg=_HDR_FG,
|
||||
font=(_FF, 13, "bold")).pack(anchor="w")
|
||||
tk.Label(hdr_inner,
|
||||
text="Gespeicherte Krankengeschichten, Briefe, Rezepte, Kostengutsprachen, Diktate und Transkripte",
|
||||
bg=_HDR_BG, fg=_HDR_SUB, font=(_FF, 8),
|
||||
wraplength=580, justify="left").pack(anchor="w", pady=(2, 0))
|
||||
tk.Label(hdr_inner, text=base_path, bg=_HDR_BG, fg="#6090B8",
|
||||
font=(_FF, 7)).pack(anchor="w", pady=(2, 0))
|
||||
|
||||
tk.Frame(win, bg=_CARD_BD, height=1).pack(fill="x")
|
||||
|
||||
# ── Auto-delete card ──────────────────────────────────────────────────
|
||||
cb_card = tk.Frame(win, bg=_CARD_BG,
|
||||
highlightbackground=_CARD_BD, highlightthickness=1,
|
||||
padx=14, pady=8)
|
||||
cb_card.pack(fill="x", padx=14, pady=(10, 4))
|
||||
|
||||
auto_delete_var = tk.BooleanVar(
|
||||
value=bool(getattr(self, "_autotext_data", {}).get("ablage_auto_delete_old", True))
|
||||
)
|
||||
|
||||
def _persist_auto_delete():
|
||||
try:
|
||||
if hasattr(self, "_autotext_data"):
|
||||
self._autotext_data["ablage_auto_delete_old"] = bool(auto_delete_var.get())
|
||||
save_autotext(self._autotext_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tk.Checkbutton(
|
||||
cb_card,
|
||||
text="Lokale Eintraege nach 2 Wochen automatisch loeschen",
|
||||
variable=auto_delete_var,
|
||||
command=_persist_auto_delete,
|
||||
bg=_CARD_BG, fg=_TEXT, activebackground=_CARD_BG,
|
||||
activeforeground=_TEXT, selectcolor=_CARD_BG,
|
||||
font=(_FF, 9), anchor="w",
|
||||
).pack(anchor="w")
|
||||
tk.Label(cb_card,
|
||||
text="(Beim Oeffnen des Ordners wird eine Bestaetigung abgefragt)",
|
||||
bg=_CARD_BG, fg=_TEXT_SUB, font=(_FF, 8)).pack(anchor="w")
|
||||
|
||||
# ── Tab-Leiste ────────────────────────────────────────────────────────
|
||||
tab_outer = tk.Frame(win, bg=_WIN_BG, padx=14)
|
||||
tab_outer.pack(fill="x", pady=(6, 0))
|
||||
tab_bar = tk.Frame(tab_outer, bg=_WIN_BG)
|
||||
tab_bar.pack(fill="x")
|
||||
|
||||
_pages: dict = {}
|
||||
_tab_btns: dict = {}
|
||||
_active: list = [None]
|
||||
|
||||
# Inhaltsbereich (Karte fuer Listbox)
|
||||
content_outer = tk.Frame(win, bg=_WIN_BG, padx=14)
|
||||
content_outer.pack(fill="both", expand=True, pady=(0, 14))
|
||||
content_card = tk.Frame(content_outer, bg=_CARD_BG,
|
||||
highlightbackground=_CARD_BD, highlightthickness=1)
|
||||
content_card.pack(fill="both", expand=True)
|
||||
|
||||
for cat in ABLAGE_SUBFOLDERS:
|
||||
page = tk.Frame(content_card, bg=_CARD_BG, padx=10, pady=8)
|
||||
_pages[cat] = page
|
||||
is_first = _active[0] is None
|
||||
if is_first:
|
||||
_active[0] = cat
|
||||
btn = tk.Label(
|
||||
tab_bar, text=cat, cursor="hand2",
|
||||
bg=_TAB_ACT if is_first else _TAB_INACT,
|
||||
fg=_TAB_FG_A if is_first else _TAB_FG_I,
|
||||
font=(_FF, 9, "bold") if is_first else (_FF, 9),
|
||||
padx=14, pady=5,
|
||||
)
|
||||
btn.pack(side="left", padx=(0, 2))
|
||||
_tab_btns[cat] = btn
|
||||
|
||||
def _switch_tab(name: str):
|
||||
_active[0] = name
|
||||
for k, b in _tab_btns.items():
|
||||
active = k == name
|
||||
b.configure(
|
||||
bg=_TAB_ACT if active else _TAB_INACT,
|
||||
fg=_TAB_FG_A if active else _TAB_FG_I,
|
||||
font=(_FF, 9, "bold") if active else (_FF, 9),
|
||||
)
|
||||
for k, p in _pages.items():
|
||||
if k == name:
|
||||
p.pack(fill="both", expand=True)
|
||||
else:
|
||||
p.pack_forget()
|
||||
|
||||
for cat in ABLAGE_SUBFOLDERS:
|
||||
_tab_btns[cat].bind("<Button-1>", lambda e, c=cat: _switch_tab(c))
|
||||
_tab_btns[cat].bind("<Enter>", lambda e, b=_tab_btns[cat], c=cat: (
|
||||
b.configure(bg=_TAB_ACT) if _active[0] != c else None
|
||||
))
|
||||
_tab_btns[cat].bind("<Leave>", lambda e, b=_tab_btns[cat], c=cat: (
|
||||
b.configure(bg=_TAB_INACT) if _active[0] != c else None
|
||||
))
|
||||
|
||||
# Erste Tab-Seite sichtbar
|
||||
_pages[_active[0]].pack(fill="both", expand=True)
|
||||
|
||||
# ── Pro-Tab: Listbox + Hilfstext ──────────────────────────────────────
|
||||
listboxes: list = []
|
||||
|
||||
def _refresh(lb: tk.Listbox, category: str):
|
||||
lb.delete(0, "end")
|
||||
for f in list_ablage_files(category):
|
||||
disp = f[:-4] if f.endswith(".txt") else f
|
||||
lb.insert("end", disp)
|
||||
|
||||
def _load_file(category: str, filename: str):
|
||||
"""Laedt Datei in neuem Fenster. Ordner-Fenster bleibt offen."""
|
||||
content = get_ablage_content(category, filename)
|
||||
if not content:
|
||||
messagebox.showinfo("Hinweis", "Datei ist leer oder nicht gefunden.",
|
||||
parent=win)
|
||||
return
|
||||
if category == "KG":
|
||||
self._show_text_window("KG (geladen)", content, buttons="kg")
|
||||
self.set_status("KG in neuem Fenster geoeffnet.")
|
||||
elif category == "Briefe":
|
||||
self._last_brief_text = content
|
||||
self._show_text_window("Brief (geladen)", content, buttons="brief")
|
||||
self.set_status("Brief in neuem Fenster geoeffnet.")
|
||||
elif category == "Rezepte":
|
||||
self._last_rezept_text = content
|
||||
self._show_text_window("Rezept (geladen)", content, buttons="rezept")
|
||||
self.set_status("Rezept in neuem Fenster geoeffnet.")
|
||||
elif category == "Kostengutsprachen":
|
||||
self._last_kogu_text = content
|
||||
self._show_text_window("KOGU (geladen)", content, buttons="kogu")
|
||||
self.set_status("KOGU in neuem Fenster geoeffnet.")
|
||||
elif category in ("Diktat", "Transkript"):
|
||||
self._show_text_window(f"{category} (geladen)", content, buttons=None)
|
||||
self.set_status(f"{category} in neuem Fenster geoeffnet.")
|
||||
|
||||
for cat in ABLAGE_SUBFOLDERS:
|
||||
page = _pages[cat]
|
||||
hint = tk.Label(page,
|
||||
text="Doppelklick: Datei in neuem Fenster oeffnen",
|
||||
bg=_CARD_BG, fg=_TEXT_SUB, font=(_FF, 8))
|
||||
hint.pack(anchor="w", pady=(0, 4))
|
||||
|
||||
lb_frame = tk.Frame(page, bg=_CARD_BG)
|
||||
lb_frame.pack(fill="both", expand=True)
|
||||
|
||||
scrollbar = tk.Scrollbar(lb_frame, orient="vertical",
|
||||
bg=_WIN_BG, troughcolor=_WIN_BG)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
lb = tk.Listbox(
|
||||
lb_frame, font=(_FF, 10),
|
||||
bg=_CARD_BG, fg=_TEXT,
|
||||
selectbackground=_LB_SEL, selectforeground=_HDR_FG,
|
||||
activestyle="none",
|
||||
highlightbackground=_CARD_BD, highlightthickness=1,
|
||||
relief="flat", bd=0,
|
||||
yscrollcommand=scrollbar.set,
|
||||
)
|
||||
lb.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.configure(command=lb.yview)
|
||||
_refresh(lb, cat)
|
||||
listboxes.append({"listbox": lb, "category": cat})
|
||||
|
||||
def _on_dblclick(evt, c=cat, lbx=lb):
|
||||
sel = lbx.curselection()
|
||||
if not sel:
|
||||
return
|
||||
files = list_ablage_files(c)
|
||||
if 0 <= sel[0] < len(files):
|
||||
_load_file(c, files[sel[0]])
|
||||
lb.bind("<Double-Button-1>", _on_dblclick)
|
||||
|
||||
def _on_mousewheel(evt, lbx=lb):
|
||||
lbx.yview_scroll(int(-1 * (evt.delta / 120)), "units")
|
||||
lb.bind("<MouseWheel>", _on_mousewheel)
|
||||
|
||||
# ── Internes Speichern (kein Button, aber Funktion erhalten) ──────────
|
||||
def _save_current_internal(category: str):
|
||||
"""Intern erreichbar, kein sichtbarer Button."""
|
||||
if category == "KG":
|
||||
content = self.txt_output.get("1.0", "end").strip()
|
||||
elif category == "Briefe":
|
||||
content = getattr(self, "_last_brief_text", "")
|
||||
elif category == "Rezepte":
|
||||
content = getattr(self, "_last_rezept_text", "")
|
||||
elif category == "Kostengutsprachen":
|
||||
content = getattr(self, "_last_kogu_text", "")
|
||||
elif category in ("Diktat", "Transkript"):
|
||||
content = self.txt_transcript.get("1.0", "end").strip()
|
||||
else:
|
||||
return
|
||||
if not content:
|
||||
return
|
||||
try:
|
||||
save_to_ablage(category, content)
|
||||
for lb_info in listboxes:
|
||||
_refresh(lb_info["listbox"], lb_info["category"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Auto-Cleanup beim Oeffnen ─────────────────────────────────────────
|
||||
def _maybe_cleanup():
|
||||
if not bool(auto_delete_var.get()):
|
||||
return
|
||||
total_old = 0
|
||||
for cat in ABLAGE_SUBFOLDERS:
|
||||
try:
|
||||
total_old += int(count_entries_older_than(cat, days=14))
|
||||
except Exception:
|
||||
pass
|
||||
if total_old <= 0:
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Automatisch loeschen",
|
||||
f"{total_old} Eintraege aelter als 2 Wochen gefunden.\n"
|
||||
"Jetzt loeschen?",
|
||||
parent=win,
|
||||
):
|
||||
return
|
||||
deleted = 0
|
||||
for cat in ABLAGE_SUBFOLDERS:
|
||||
try:
|
||||
deleted += int(delete_entries_older_than(cat, days=14))
|
||||
except Exception:
|
||||
pass
|
||||
for lb_info in listboxes:
|
||||
_refresh(lb_info["listbox"], lb_info["category"])
|
||||
if deleted > 0:
|
||||
messagebox.showinfo("Geloescht",
|
||||
f"{deleted} alte Eintraege wurden geloescht.",
|
||||
parent=win)
|
||||
|
||||
win.after(300, _maybe_cleanup)
|
||||
|
||||
# ── Fenster sichtbar machen ───────────────────────────────────────────
|
||||
try:
|
||||
win.attributes("-alpha", 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
win.lift()
|
||||
win.focus_force()
|
||||
win.after(600, lambda: win.attributes("-topmost", False))
|
||||
win.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
add_font_scale_control(win)
|
||||
@@ -0,0 +1,658 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AzaSettingsMixin – Einstellungsfenster (KG-Modell, Templates, Autotext, Add-ons, etc.).
|
||||
Modernes AzA-Blau-Design, scrollbar, KG-Modell-Auswahl ausgeblendet.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from tkinter.scrolledtext import ScrolledText
|
||||
|
||||
from aza_audit_log import log_event as _audit_log
|
||||
from aza_persistence import (
|
||||
load_settings_geometry,
|
||||
save_settings_geometry,
|
||||
load_templates_text,
|
||||
save_templates_text,
|
||||
save_autotext,
|
||||
save_model,
|
||||
_clamp_geometry_str,
|
||||
load_signature_name,
|
||||
save_signature_name,
|
||||
load_user_profile,
|
||||
)
|
||||
from aza_ui_helpers import add_resize_grip, add_font_scale_control
|
||||
from aza_config import MODEL_LABELS, ALLOWED_SUMMARY_MODELS
|
||||
|
||||
# ── Design-Konstanten ─────────────────────────────────────────────────────────
|
||||
_WIN_BG = "#EEF4F8"
|
||||
_HDR_BG = "#1A4D6D"
|
||||
_HDR_FG = "#FFFFFF"
|
||||
_HDR_SUB = "#A0C0DC"
|
||||
_CARD_BG = "#FFFFFF"
|
||||
_CARD_BD = "#C8D8E8"
|
||||
_TEXT = "#1A3D55"
|
||||
_TEXT_SUB = "#607890"
|
||||
_BTN_PRI = "#1A8ACC"
|
||||
_BTN_PRI_F = "#FFFFFF"
|
||||
_BTN_SEC = "#D4E7F5"
|
||||
_BTN_SEC_F = "#1A4D6D"
|
||||
_FF = "Segoe UI"
|
||||
|
||||
|
||||
def _darken(hex_color: str, amount: int = 18) -> str:
|
||||
try:
|
||||
r = max(0, int(hex_color[1:3], 16) - amount)
|
||||
g = max(0, int(hex_color[3:5], 16) - amount)
|
||||
b = max(0, int(hex_color[5:7], 16) - amount)
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
except Exception:
|
||||
return hex_color
|
||||
|
||||
|
||||
def _make_btn(parent, text: str, command, primary: bool = False, **kw):
|
||||
bg = _BTN_PRI if primary else _BTN_SEC
|
||||
fg = _BTN_PRI_F if primary else _BTN_SEC_F
|
||||
b = tk.Button(
|
||||
parent, text=text, command=command,
|
||||
bg=bg, fg=fg, activebackground=_darken(bg),
|
||||
activeforeground=fg, font=(_FF, 9),
|
||||
bd=0, padx=14, pady=6, cursor="hand2", relief="flat", **kw,
|
||||
)
|
||||
b.bind("<Enter>", lambda e, _b=b, _bg=bg: _b.configure(bg=_darken(_bg)))
|
||||
b.bind("<Leave>", lambda e, _b=b, _bg=bg: _b.configure(bg=_bg))
|
||||
return b
|
||||
|
||||
|
||||
def _make_cb(parent, text: str, variable, command=None, card_bg: str = _CARD_BG):
|
||||
kw: dict = {}
|
||||
if command:
|
||||
kw["command"] = command
|
||||
return tk.Checkbutton(
|
||||
parent, text=text, variable=variable,
|
||||
bg=card_bg, fg=_TEXT, activebackground=card_bg,
|
||||
activeforeground=_TEXT, selectcolor=card_bg,
|
||||
font=(_FF, 9), anchor="w", **kw,
|
||||
)
|
||||
|
||||
|
||||
class AzaSettingsMixin:
|
||||
"""Mixin für das Einstellungsfenster."""
|
||||
|
||||
def _open_settings(self):
|
||||
SETTINGS_MIN_W, SETTINGS_MIN_H = 720, 720
|
||||
|
||||
win = tk.Toplevel(self)
|
||||
win.title("Einstellungen")
|
||||
win.transient(self)
|
||||
win.configure(bg=_WIN_BG)
|
||||
win.minsize(SETTINGS_MIN_W, SETTINGS_MIN_H)
|
||||
try:
|
||||
win.attributes("-alpha", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self, "_aza_windows"):
|
||||
self._aza_windows.add(win)
|
||||
self._register_window(win)
|
||||
|
||||
saved_geom = load_settings_geometry()
|
||||
if saved_geom:
|
||||
try:
|
||||
win.geometry(_clamp_geometry_str(saved_geom, SETTINGS_MIN_W, SETTINGS_MIN_H))
|
||||
except Exception:
|
||||
win.geometry(f"{SETTINGS_MIN_W}x{SETTINGS_MIN_H}")
|
||||
else:
|
||||
win.update_idletasks()
|
||||
sw, sh = win.winfo_screenwidth(), win.winfo_screenheight()
|
||||
x = max(0, (sw - SETTINGS_MIN_W) // 2)
|
||||
y = max(0, (sh - SETTINGS_MIN_H) // 2)
|
||||
win.geometry(f"{SETTINGS_MIN_W}x{SETTINGS_MIN_H}+{x}+{y}")
|
||||
|
||||
add_font_scale_control(win)
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────
|
||||
hdr = tk.Frame(win, bg=_HDR_BG)
|
||||
hdr.pack(fill="x")
|
||||
tk.Label(hdr, text="Einstellungen", bg=_HDR_BG, fg=_HDR_FG,
|
||||
font=(_FF, 13, "bold"), pady=14, padx=20).pack(anchor="w")
|
||||
|
||||
sep = tk.Frame(win, bg="#C8D8E8", height=1)
|
||||
sep.pack(fill="x")
|
||||
|
||||
# ── Scrollable body ───────────────────────────────────────────────────
|
||||
body = tk.Frame(win, bg=_WIN_BG)
|
||||
body.pack(fill="both", expand=True)
|
||||
|
||||
vbar = tk.Scrollbar(body, orient="vertical")
|
||||
vbar.pack(side="right", fill="y")
|
||||
|
||||
canvas = tk.Canvas(body, bg=_WIN_BG, highlightthickness=0, bd=0,
|
||||
yscrollcommand=vbar.set)
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
vbar.configure(command=canvas.yview)
|
||||
|
||||
inner = tk.Frame(canvas, bg=_WIN_BG)
|
||||
cwin_id = canvas.create_window((0, 0), window=inner, anchor="nw")
|
||||
|
||||
def _on_inner_resize(event):
|
||||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
|
||||
def _on_canvas_resize(event):
|
||||
canvas.itemconfigure(cwin_id, width=event.width)
|
||||
|
||||
inner.bind("<Configure>", _on_inner_resize)
|
||||
canvas.bind("<Configure>", _on_canvas_resize)
|
||||
|
||||
def _scroll(event):
|
||||
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||||
|
||||
win.bind_all("<MouseWheel>", _scroll)
|
||||
|
||||
# Content area inside scrollable frame
|
||||
pad = tk.Frame(inner, bg=_WIN_BG, padx=20, pady=16)
|
||||
pad.pack(fill="both", expand=True)
|
||||
|
||||
# ── Card builder ──────────────────────────────────────────────────────
|
||||
def _card(title: str = "") -> tk.Frame:
|
||||
outer = tk.Frame(pad, bg=_WIN_BG)
|
||||
outer.pack(fill="x", pady=(0, 10))
|
||||
if title:
|
||||
tk.Label(outer, text=title.upper(), bg=_WIN_BG, fg=_TEXT_SUB,
|
||||
font=(_FF, 8, "bold")).pack(anchor="w", padx=2, pady=(0, 4))
|
||||
c = tk.Frame(outer, bg=_CARD_BG,
|
||||
highlightbackground=_CARD_BD, highlightthickness=1,
|
||||
padx=14, pady=10)
|
||||
c.pack(fill="x")
|
||||
return c
|
||||
|
||||
# ── KG-Modell (hidden – variable kept for on_ok compatibility) ────────
|
||||
display_values = [MODEL_LABELS[m] for m in ALLOWED_SUMMARY_MODELS]
|
||||
current_model_label = MODEL_LABELS.get(self.model_var.get(), display_values[0])
|
||||
model_var_dialog = tk.StringVar(value=current_model_label)
|
||||
|
||||
# ── Templates + Reset ─────────────────────────────────────────────────
|
||||
c_tmpl = _card("Vorlagen")
|
||||
row_tmpl = tk.Frame(c_tmpl, bg=_CARD_BG)
|
||||
row_tmpl.pack(anchor="w")
|
||||
|
||||
def open_templates():
|
||||
tw = tk.Toplevel(win)
|
||||
tw.title("Templates")
|
||||
tw.transient(win)
|
||||
tw.geometry("620x370")
|
||||
tw.configure(bg="#B9ECFA")
|
||||
tw.minsize(450, 280)
|
||||
tw.attributes("-topmost", True)
|
||||
self._register_window(tw)
|
||||
add_resize_grip(tw, 450, 280)
|
||||
add_font_scale_control(tw)
|
||||
tf = ttk.Frame(tw, padding=12)
|
||||
tf.pack(fill="both", expand=True)
|
||||
ttk.Label(tf, text=(
|
||||
"Kontext fuer die KI (z. B. Ich bin ein Dermatologe). "
|
||||
"Wird bei der KG-Erstellung beruecksichtigt:"
|
||||
)).pack(anchor="w")
|
||||
ttxt = ScrolledText(tf, wrap="word", font=self._text_font, bg="#F5FCFF", height=8)
|
||||
ttxt.pack(fill="both", expand=True, pady=(4, 8))
|
||||
ttxt.insert("1.0", load_templates_text())
|
||||
self._bind_autotext(ttxt)
|
||||
btn_f = ttk.Frame(tf)
|
||||
btn_f.pack(fill="x")
|
||||
def _save_close():
|
||||
save_templates_text(ttxt.get("1.0", "end").strip())
|
||||
tw.destroy()
|
||||
ttk.Button(btn_f, text="OK", command=_save_close).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(btn_f, text="Abbrechen", command=tw.destroy).pack(side="left")
|
||||
|
||||
def do_reset():
|
||||
save_templates_text("")
|
||||
messagebox.showinfo("Reset", "Template-Text wurde zurückgesetzt und ist jetzt leer.",
|
||||
parent=win)
|
||||
|
||||
_make_btn(row_tmpl, "Templates", open_templates).pack(side="left", padx=(0, 8))
|
||||
_make_btn(row_tmpl, "Reset", do_reset).pack(side="left")
|
||||
|
||||
# ── Startverhalten / Fenster ──────────────────────────────────────────
|
||||
c_start = _card("Startverhalten / Fenster")
|
||||
|
||||
diktat_auto_var = tk.BooleanVar(value=self._autotext_data.get("diktat_auto_start", True))
|
||||
_make_cb(c_start, "Diktat startet sofort (wenn aus: Aufnahme manuell starten)",
|
||||
diktat_auto_var).pack(anchor="w", pady=2)
|
||||
|
||||
notizen_open_var = tk.BooleanVar(value=self._autotext_data.get("notizen_open_on_start", True))
|
||||
_make_cb(c_start, "Audionotiz beim Programmstart automatisch öffnen",
|
||||
notizen_open_var).pack(anchor="w", pady=2)
|
||||
|
||||
kommentare_auto_var = tk.BooleanVar(value=self._autotext_data.get("kommentare_auto_open", False))
|
||||
_make_cb(c_start, "Kommentare-Fenster beim Programmstart automatisch öffnen",
|
||||
kommentare_auto_var).pack(anchor="w", pady=2)
|
||||
|
||||
empfang_auto_var = tk.BooleanVar(value=self._autotext_data.get("empfang_auto_open", False))
|
||||
_make_cb(c_start, "Empfang-Fenster beim Programmstart automatisch öffnen",
|
||||
empfang_auto_var).pack(anchor="w", pady=2)
|
||||
|
||||
# ── Darstellung ───────────────────────────────────────────────────────
|
||||
c_display = _card("Darstellung")
|
||||
|
||||
def _live_textbloecke_visible(*_):
|
||||
vis = bool(textbloecke_visible_var.get())
|
||||
self._autotext_data["textbloecke_visible"] = vis
|
||||
try:
|
||||
if vis:
|
||||
self._textbloecke_container.pack(fill="x", before=self._textbloecke_anchor)
|
||||
else:
|
||||
self._textbloecke_container.pack_forget()
|
||||
self.update_idletasks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
textbloecke_visible_var = tk.BooleanVar(
|
||||
value=self._autotext_data.get("textbloecke_visible", True))
|
||||
_make_cb(c_display,
|
||||
"Textblöcke anzeigen (Inhalt bleibt gespeichert, wenn ausgeblendet)",
|
||||
textbloecke_visible_var, command=_live_textbloecke_visible).pack(anchor="w", pady=2)
|
||||
|
||||
def _live_addon_visible(*_):
|
||||
vis = bool(addon_visible_var.get())
|
||||
self._autotext_data["addon_visible"] = vis
|
||||
try:
|
||||
if vis:
|
||||
self._addon_container.pack(fill="x", before=self._addon_anchor)
|
||||
self._update_addon_buttons_visibility()
|
||||
else:
|
||||
self._addon_container.pack_forget()
|
||||
self.update_idletasks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
addon_visible_var = tk.BooleanVar(value=self._autotext_data.get("addon_visible", True))
|
||||
_make_cb(c_display, "Add-ons anzeigen",
|
||||
addon_visible_var, command=_live_addon_visible).pack(anchor="w", pady=2)
|
||||
|
||||
def _live_logo_visible(*_):
|
||||
vis = bool(logo_visible_var.get())
|
||||
self._autotext_data["logo_visible"] = vis
|
||||
try:
|
||||
if vis:
|
||||
self._logo_frame.place(relx=0.01, rely=0.97, anchor="sw")
|
||||
else:
|
||||
self._logo_frame.place_forget()
|
||||
self.update_idletasks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logo_visible_var = tk.BooleanVar(value=self._autotext_data.get("logo_visible", True))
|
||||
_make_cb(c_display, "Logo anzeigen (Klick auf Logo startet/stoppt Aufnahme)",
|
||||
logo_visible_var, command=_live_logo_visible).pack(anchor="w", pady=2)
|
||||
|
||||
# ── Add-on-Buttons ────────────────────────────────────────────────────
|
||||
c_addon = _card("Welche Add-on-Buttons anzeigen?")
|
||||
|
||||
addon_buttons = self._autotext_data.get("addon_buttons", {})
|
||||
addon_button_vars: dict = {}
|
||||
addon_button_options = [
|
||||
("uebersetzer", "Übersetzer (provisorisch)"),
|
||||
("email", "E-Mail"),
|
||||
("autotext", "Autotext"),
|
||||
("whatsapp", "WhatsApp"),
|
||||
("docapp", "MedWork"),
|
||||
("todo", "To-do"),
|
||||
("macro", "Makro starten"),
|
||||
("kongresse", "Kongresse"),
|
||||
("news", "News"),
|
||||
("empfang", "An Empfang senden"),
|
||||
]
|
||||
todo_auto_open_var = tk.BooleanVar(
|
||||
value=self._autotext_data.get("todo_auto_open", True))
|
||||
|
||||
def _live_addon_toggle(*_):
|
||||
self._autotext_data["addon_buttons"] = {
|
||||
bid: bool(v.get()) for bid, v in addon_button_vars.items()
|
||||
}
|
||||
try:
|
||||
self._update_addon_buttons_visibility()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for button_id, label in addon_button_options:
|
||||
var = tk.BooleanVar(value=addon_buttons.get(button_id, True))
|
||||
addon_button_vars[button_id] = var
|
||||
_make_cb(c_addon, label, var, command=_live_addon_toggle).pack(
|
||||
anchor="w", padx=(0, 0), pady=2)
|
||||
if button_id == "todo":
|
||||
_make_cb(c_addon, " ↳ To-do beim Start automatisch öffnen",
|
||||
todo_auto_open_var).pack(anchor="w", pady=(0, 2))
|
||||
|
||||
# ── Diverses ──────────────────────────────────────────────────────────
|
||||
c_misc = _card("Diverses")
|
||||
|
||||
kg_auto_delete_var = tk.BooleanVar(
|
||||
value=self._autotext_data.get("kg_auto_delete_old", False))
|
||||
_make_cb(c_misc, "KG-Einträge älter als 2 Wochen automatisch löschen (Speicher schonen)",
|
||||
kg_auto_delete_var).pack(anchor="w", pady=2)
|
||||
|
||||
# ── Statusanzeige ─────────────────────────────────────────────────────
|
||||
c_status = _card("Statusanzeige")
|
||||
_status_color_options = {
|
||||
"Standard (Orange)": "#BD4500",
|
||||
"Blau": "#1a4d6d",
|
||||
"Ausblenden": "hidden",
|
||||
}
|
||||
_current_sc = self._autotext_data.get("status_color", "#BD4500")
|
||||
_sc_label = "Standard (Orange)"
|
||||
for _lbl, _val in _status_color_options.items():
|
||||
if _val == _current_sc:
|
||||
_sc_label = _lbl
|
||||
break
|
||||
status_color_var = tk.StringVar(value=_sc_label)
|
||||
|
||||
def _live_status_color(*_):
|
||||
sc_v = _status_color_options.get(status_color_var.get(), "#BD4500")
|
||||
self._autotext_data["status_color"] = sc_v
|
||||
try:
|
||||
self._apply_status_color()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
row_sc = tk.Frame(c_status, bg=_CARD_BG)
|
||||
row_sc.pack(anchor="w")
|
||||
for sc_label in _status_color_options:
|
||||
tk.Radiobutton(
|
||||
row_sc, text=sc_label, variable=status_color_var,
|
||||
value=sc_label, command=_live_status_color,
|
||||
bg=_CARD_BG, fg=_TEXT, activebackground=_CARD_BG,
|
||||
selectcolor=_CARD_BG, font=(_FF, 9),
|
||||
).pack(side="left", padx=(0, 12))
|
||||
|
||||
# ── Autotext ──────────────────────────────────────────────────────────
|
||||
c_at = _card("Autotext")
|
||||
|
||||
autotext_var = tk.BooleanVar(value=self._autotext_data.get("enabled", True))
|
||||
_make_cb(c_at,
|
||||
"Autotext aktiv (Abkuerzungen z. B. mfg -> mit freundlichen Gruessen)",
|
||||
autotext_var).pack(anchor="w", pady=(0, 6))
|
||||
|
||||
def open_autotext_manage():
|
||||
self._open_autotext_dialog(win)
|
||||
|
||||
_make_btn(c_at, "Autotext verwalten", open_autotext_manage).pack(anchor="w")
|
||||
|
||||
# ── Tastatur / Eingabe ────────────────────────────────────────────────
|
||||
c_input = _card("Tastatur / Eingabe")
|
||||
|
||||
autocopy_var = tk.BooleanVar(
|
||||
value=self._autotext_data.get("autocopy_after_diktat", True))
|
||||
_make_cb(c_input,
|
||||
"Autocopy: Nach Diktat/Transkription automatisch in Zwischenablage kopieren",
|
||||
autocopy_var).pack(anchor="w", pady=2)
|
||||
|
||||
if not hasattr(self, "_rclick_paste_var"):
|
||||
self._rclick_paste_var = tk.BooleanVar(
|
||||
value=bool(self._autotext_data.get("global_right_click_paste", True)))
|
||||
_make_cb(c_input,
|
||||
"Global: Rechtsklick fügt direkt ein (ohne Kontextmenü, nur externe Apps)",
|
||||
self._rclick_paste_var, command=self._toggle_rclick_paste).pack(
|
||||
anchor="w", pady=2)
|
||||
|
||||
# ── Unterschrift / Signatur ───────────────────────────────────────────
|
||||
c_sig = _card("Unterschrift / Signatur")
|
||||
|
||||
profile_name = self._user_profile.get("name", "")
|
||||
current_sig = load_signature_name(fallback_to_profile=False)
|
||||
use_profile = not bool(current_sig)
|
||||
|
||||
sig_auto_var = tk.BooleanVar(value=use_profile)
|
||||
sig_name_var = tk.StringVar(value=current_sig if current_sig else profile_name)
|
||||
|
||||
_make_cb(c_sig,
|
||||
f"Profilname verwenden: {profile_name}" if profile_name else "Profilname verwenden",
|
||||
sig_auto_var).pack(anchor="w", pady=(0, 4))
|
||||
|
||||
row_sig = tk.Frame(c_sig, bg=_CARD_BG)
|
||||
row_sig.pack(anchor="w", fill="x")
|
||||
tk.Label(row_sig, text="Abweichender Name:", bg=_CARD_BG, fg=_TEXT,
|
||||
font=(_FF, 9)).pack(side="left", padx=(0, 8))
|
||||
ent_sig = tk.Entry(row_sig, textvariable=sig_name_var, width=34,
|
||||
font=(_FF, 9), bg="#F5FAFF", relief="flat",
|
||||
highlightbackground=_CARD_BD, highlightthickness=1)
|
||||
ent_sig.pack(side="left")
|
||||
|
||||
def _update_sig_entry(*_):
|
||||
if sig_auto_var.get():
|
||||
ent_sig.configure(state="disabled")
|
||||
sig_name_var.set(profile_name)
|
||||
else:
|
||||
ent_sig.configure(state="normal")
|
||||
|
||||
sig_auto_var.trace_add("write", _update_sig_entry)
|
||||
_update_sig_entry()
|
||||
|
||||
self._sig_auto_var = sig_auto_var
|
||||
self._sig_name_var = sig_name_var
|
||||
|
||||
# ── Audio / Mikrofon ──────────────────────────────────────────────────
|
||||
c_audio = _card("Audio / Mikrofon")
|
||||
audio_status_var = tk.StringVar(value="")
|
||||
|
||||
def _run_audio_test():
|
||||
audio_status_var.set("Test läuft …")
|
||||
win.update_idletasks()
|
||||
try:
|
||||
from aza_audio import test_audio_device
|
||||
result = test_audio_device(duration_sec=1.5)
|
||||
audio_status_var.set(
|
||||
("✓ " if result["ok"] else "✗ ") + result["message"]
|
||||
)
|
||||
except Exception as exc:
|
||||
audio_status_var.set(f"✗ Fehler: {exc}")
|
||||
|
||||
row_audio = tk.Frame(c_audio, bg=_CARD_BG)
|
||||
row_audio.pack(fill="x")
|
||||
_make_btn(row_audio, "Audio-Test starten", _run_audio_test).pack(
|
||||
side="left", padx=(0, 12))
|
||||
tk.Label(row_audio, textvariable=audio_status_var, bg=_CARD_BG, fg=_TEXT,
|
||||
font=(_FF, 9), wraplength=400, justify="left").pack(side="left")
|
||||
|
||||
# ── Datenschutz & Recht ───────────────────────────────────────────────
|
||||
c_legal = _card("Datenschutz & Recht")
|
||||
|
||||
row_legal1 = tk.Frame(c_legal, bg=_CARD_BG)
|
||||
row_legal1.pack(anchor="w", pady=(0, 6))
|
||||
_make_btn(row_legal1, "Datenschutzerklärung anzeigen",
|
||||
lambda: self._show_legal_text(win, "Datenschutzerklärung",
|
||||
"privacy_policy.md")
|
||||
).pack(side="left", padx=(0, 8))
|
||||
_make_btn(row_legal1, "KI-Einwilligung anzeigen",
|
||||
lambda: self._show_legal_text(win, "KI-Einwilligung",
|
||||
"ai_consent.md")
|
||||
).pack(side="left")
|
||||
|
||||
from aza_consent import get_consent_status, record_revoke, has_valid_consent, record_consent, export_consent_log
|
||||
uid = self._user_profile.get("name", "default")
|
||||
consent_ok = has_valid_consent(uid)
|
||||
consent_status_var = tk.StringVar(
|
||||
value=f"KI-Einwilligung: {'Erteilt' if consent_ok else 'Nicht erteilt / widerrufen'}")
|
||||
tk.Label(c_legal, textvariable=consent_status_var, bg=_CARD_BG, fg=_TEXT,
|
||||
font=(_FF, 9)).pack(anchor="w", pady=(0, 4))
|
||||
|
||||
def toggle_consent():
|
||||
nonlocal consent_ok
|
||||
_uid = self._user_profile.get("name", "default")
|
||||
if has_valid_consent(_uid):
|
||||
if messagebox.askyesno(
|
||||
"Einwilligung widerrufen",
|
||||
"Möchten Sie Ihre KI-Einwilligung widerrufen?\n\n"
|
||||
"KI-Funktionen (Transkription, KG-Erstellung,\n"
|
||||
"Interaktionsprüfung) werden danach gesperrt.",
|
||||
parent=win):
|
||||
record_revoke(_uid, source="ui")
|
||||
_audit_log("CONSENT_REVOKE", _uid)
|
||||
consent_ok = False
|
||||
consent_status_var.set("KI-Einwilligung: Widerrufen")
|
||||
btn_consent.configure(text="KI-Einwilligung erteilen")
|
||||
messagebox.showinfo("Widerruf",
|
||||
"Ihre KI-Einwilligung wurde widerrufen und protokolliert.",
|
||||
parent=win)
|
||||
else:
|
||||
if self._check_ai_consent():
|
||||
consent_ok = True
|
||||
consent_status_var.set("KI-Einwilligung: Erteilt")
|
||||
btn_consent.configure(text="KI-Einwilligung widerrufen")
|
||||
|
||||
row_legal2 = tk.Frame(c_legal, bg=_CARD_BG)
|
||||
row_legal2.pack(anchor="w")
|
||||
btn_consent = _make_btn(
|
||||
row_legal2,
|
||||
"KI-Einwilligung widerrufen" if consent_ok else "KI-Einwilligung erteilen",
|
||||
toggle_consent,
|
||||
)
|
||||
btn_consent.pack(side="left", padx=(0, 8))
|
||||
|
||||
def do_export():
|
||||
from aza_audit_log import export_audit_log
|
||||
try:
|
||||
path_consent = export_consent_log()
|
||||
path_audit = export_audit_log()
|
||||
_audit_log("EXPORT", uid, detail="consent+audit log")
|
||||
messagebox.showinfo(
|
||||
"Export",
|
||||
f"Consent-Log exportiert:\n{path_consent}\n\n"
|
||||
f"Audit-Log exportiert:\n{path_audit}",
|
||||
parent=win,
|
||||
)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Export-Fehler", str(e), parent=win)
|
||||
|
||||
_make_btn(row_legal2, "Logs exportieren (Audit)", do_export).pack(side="left")
|
||||
|
||||
# ── Spacing below last card ───────────────────────────────────────────
|
||||
tk.Frame(pad, bg=_WIN_BG, height=8).pack()
|
||||
|
||||
# ── Footer with OK button (outside scroll) ────────────────────────────
|
||||
footer_sep = tk.Frame(win, bg="#C8D8E8", height=1)
|
||||
footer_sep.pack(fill="x", side="bottom")
|
||||
footer = tk.Frame(win, bg=_WIN_BG, pady=10, padx=20)
|
||||
footer.pack(fill="x", side="bottom")
|
||||
|
||||
# ── Save / close helpers ──────────────────────────────────────────────
|
||||
def save_and_close():
|
||||
try:
|
||||
win.unbind_all("<MouseWheel>")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
save_settings_geometry(win.geometry())
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self, "_aza_windows"):
|
||||
self._aza_windows.discard(win)
|
||||
win.destroy()
|
||||
|
||||
def on_ok():
|
||||
# KG-Modell: keep current (dialog hidden, no change)
|
||||
selected_label = model_var_dialog.get().strip()
|
||||
for model_id, label in MODEL_LABELS.items():
|
||||
if label == selected_label:
|
||||
self.model_var.set(model_id)
|
||||
save_model(model_id)
|
||||
break
|
||||
|
||||
self._autotext_data["enabled"] = bool(autotext_var.get())
|
||||
self._autotext_data["diktat_auto_start"] = bool(diktat_auto_var.get())
|
||||
self._autotext_data["notizen_open_on_start"] = bool(notizen_open_var.get())
|
||||
self._autotext_data["kommentare_auto_open"] = bool(kommentare_auto_var.get())
|
||||
self._autotext_data["empfang_auto_open"] = bool(empfang_auto_var.get())
|
||||
self._autotext_data["textbloecke_visible"] = bool(textbloecke_visible_var.get())
|
||||
self._autotext_data["addon_visible"] = bool(addon_visible_var.get())
|
||||
self._autotext_data["logo_visible"] = bool(logo_visible_var.get())
|
||||
self._autotext_data["addon_buttons"] = {
|
||||
bid: bool(v.get()) for bid, v in addon_button_vars.items()
|
||||
}
|
||||
self._autotext_data["kg_auto_delete_old"] = bool(kg_auto_delete_var.get())
|
||||
self._autotext_data["todo_auto_open"] = bool(todo_auto_open_var.get())
|
||||
self._autotext_data["autocopy_after_diktat"] = bool(autocopy_var.get())
|
||||
self._autotext_data["global_right_click_paste"] = bool(self._rclick_paste_var.get())
|
||||
self._autotext_data["status_color"] = _status_color_options.get(
|
||||
status_color_var.get(), "#BD4500"
|
||||
)
|
||||
|
||||
if self._sig_auto_var.get():
|
||||
save_signature_name("")
|
||||
else:
|
||||
save_signature_name(self._sig_name_var.get().strip())
|
||||
|
||||
save_autotext(self._autotext_data)
|
||||
save_and_close()
|
||||
|
||||
def _apply_ui():
|
||||
try:
|
||||
if self._autotext_data["textbloecke_visible"]:
|
||||
self._textbloecke_container.pack(fill="x", before=self._textbloecke_anchor)
|
||||
else:
|
||||
self._textbloecke_container.pack_forget()
|
||||
if self._autotext_data["addon_visible"]:
|
||||
self._addon_container.pack(fill="x", before=self._addon_anchor)
|
||||
self._update_addon_buttons_visibility()
|
||||
self.update_idletasks()
|
||||
h = self.winfo_height()
|
||||
if h < 500:
|
||||
self.geometry(f"{self.winfo_width()}x500")
|
||||
else:
|
||||
self._addon_container.pack_forget()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._apply_status_color()
|
||||
except Exception:
|
||||
pass
|
||||
self.update_idletasks()
|
||||
|
||||
self.after(50, _apply_ui)
|
||||
|
||||
_make_btn(footer, "OK", on_ok, primary=True).pack(side="right", padx=(8, 0))
|
||||
_make_btn(footer, "Abbrechen", save_and_close).pack(side="right")
|
||||
|
||||
win.protocol("WM_DELETE_WINDOW", save_and_close)
|
||||
|
||||
try:
|
||||
win.attributes("-alpha", 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
win.focus_set()
|
||||
|
||||
def _show_legal_text(self, parent, title: str, filename: str):
|
||||
"""Zeigt einen Rechtstext (Markdown) in einem Lesefenster an."""
|
||||
import os
|
||||
legal_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "legal")
|
||||
filepath = os.path.join(legal_dir, filename)
|
||||
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
except FileNotFoundError:
|
||||
messagebox.showerror("Fehler", f"Datei nicht gefunden:\n{filepath}", parent=parent)
|
||||
return
|
||||
except OSError as e:
|
||||
messagebox.showerror("Fehler", f"Datei konnte nicht gelesen werden:\n{e}", parent=parent)
|
||||
return
|
||||
|
||||
tw = tk.Toplevel(parent)
|
||||
tw.title(title)
|
||||
tw.transient(parent)
|
||||
tw.geometry("720x600")
|
||||
tw.minsize(500, 400)
|
||||
tw.attributes("-topmost", True)
|
||||
self._register_window(tw)
|
||||
|
||||
add_resize_grip(tw, 500, 400)
|
||||
add_font_scale_control(tw)
|
||||
|
||||
frame = ttk.Frame(tw, padding=12)
|
||||
frame.pack(fill="both", expand=True)
|
||||
|
||||
txt = ScrolledText(frame, wrap="word", font=("Segoe UI", 10), bg="#FAFAFA")
|
||||
txt.pack(fill="both", expand=True, pady=(0, 8))
|
||||
txt.insert("1.0", content)
|
||||
txt.configure(state="disabled")
|
||||
|
||||
ttk.Button(frame, text="Schliessen", command=tw.destroy).pack(anchor="e")
|
||||
@@ -0,0 +1,834 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AZA Desktop — gemeinsame Versions- und Update-Logik (Controller, Updater, Office).
|
||||
|
||||
Phase 1: Manifest lesen, Version vergleichen, SHA256, Backup/Rollback vorbereiten.
|
||||
Kein stilles Auto-Update, keine Aenderung von Benutzerdaten in %%APPDATA%%\\AzA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError: # pragma: no cover
|
||||
requests = None # type: ignore
|
||||
|
||||
# Neues Update-Manifest (Ziel) + bestehende Endpunkte (Kompatibilitaet).
|
||||
UPDATE_CHANNEL_MANIFEST_URL = "https://api.aza-medwork.ch/downloads/updates/manifest.json"
|
||||
UPDATE_MANIFEST_URLS = (
|
||||
UPDATE_CHANNEL_MANIFEST_URL,
|
||||
"https://api.aza-medwork.ch/download/version.json",
|
||||
"https://api.aza-medwork.ch/release/version.json",
|
||||
)
|
||||
LOCAL_TEST_MANIFEST_NAME = "test_update_manifest.json"
|
||||
|
||||
_BUILD_STAMP_RE = re.compile(r"^\d{8}_\d{6}$")
|
||||
_VERSION_JSON_NAME = "version.json"
|
||||
_test_install_dir_override: Path | None = None
|
||||
|
||||
|
||||
def configure_test_install_dir(path: str | Path | None) -> None:
|
||||
"""Isolierter Test-Installationsordner — nie den Projektroot ueberschreiben."""
|
||||
global _test_install_dir_override
|
||||
if path is None or not str(path).strip():
|
||||
_test_install_dir_override = None
|
||||
return
|
||||
_test_install_dir_override = Path(path).resolve()
|
||||
|
||||
|
||||
def get_test_install_dir() -> Path | None:
|
||||
if _test_install_dir_override is not None:
|
||||
return _test_install_dir_override
|
||||
env = (os.environ.get("AZA_UPDATE_TEST_INSTALL_DIR") or "").strip()
|
||||
if env:
|
||||
return Path(env).resolve()
|
||||
return None
|
||||
|
||||
|
||||
def get_install_dir() -> Path:
|
||||
test_dir = get_test_install_dir()
|
||||
if test_dir is not None:
|
||||
return test_dir
|
||||
return get_project_root()
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Installations- bzw. Projektroot (EXE-Verzeichnis oder Skriptordner)."""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def get_user_data_root() -> Path:
|
||||
"""Benutzerdaten — darf bei Updates nie geloescht werden."""
|
||||
appdata = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or ""
|
||||
if appdata:
|
||||
return Path(appdata) / "AzA"
|
||||
return Path.home() / "AppData" / "Roaming" / "AzA"
|
||||
|
||||
|
||||
def get_update_backup_root() -> Path:
|
||||
"""Rollback-Speicher: im Testmodus unter dem Test-Installationsordner."""
|
||||
test_dir = get_test_install_dir()
|
||||
if test_dir is not None:
|
||||
base = test_dir / "_update_backups"
|
||||
try:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return base
|
||||
program_data = os.environ.get("PROGRAMDATA") or ""
|
||||
if program_data:
|
||||
base = Path(program_data) / "AzA" / "update_backups"
|
||||
else:
|
||||
base = get_user_data_root() / "update_backups"
|
||||
try:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return base
|
||||
|
||||
|
||||
def find_local_version_file() -> Path | None:
|
||||
root = get_install_dir()
|
||||
direct = root / _VERSION_JSON_NAME
|
||||
if direct.is_file():
|
||||
return direct
|
||||
if get_test_install_dir() is not None:
|
||||
return None
|
||||
release = get_project_root() / "release" / _VERSION_JSON_NAME
|
||||
if release.is_file():
|
||||
return release
|
||||
return None
|
||||
|
||||
|
||||
def _parse_semver_tuple(v: str) -> tuple[int, ...]:
|
||||
v = str(v or "").strip()
|
||||
if not v:
|
||||
return tuple()
|
||||
parts: list[int] = []
|
||||
for seg in v.split("."):
|
||||
seg = seg.strip()
|
||||
m = re.match(r"^(\d+)", seg)
|
||||
parts.append(int(m.group(1)) if m else 0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _semver_pad(a: tuple[int, ...], b: tuple[int, ...]) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
||||
n = max(len(a), len(b))
|
||||
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
|
||||
|
||||
|
||||
def semver_gt(ver_a: str, ver_b: str) -> bool:
|
||||
ta, tb = _parse_semver_tuple(ver_a), _parse_semver_tuple(ver_b)
|
||||
if not ta or not tb:
|
||||
return False
|
||||
aa, bb = _semver_pad(ta, tb)
|
||||
return aa > bb
|
||||
|
||||
|
||||
def semver_eq(ver_a: str, ver_b: str) -> bool:
|
||||
ta, tb = _parse_semver_tuple(ver_a), _parse_semver_tuple(ver_b)
|
||||
if not ta and not tb:
|
||||
return str(ver_a).strip() == str(ver_b).strip()
|
||||
aa, bb = _semver_pad(ta, tb)
|
||||
return aa == bb
|
||||
|
||||
|
||||
def build_gt(remote: str, local: str) -> bool:
|
||||
r, l = (remote or "").strip(), (local or "").strip()
|
||||
if not r or not l:
|
||||
return False
|
||||
if _BUILD_STAMP_RE.match(r) and _BUILD_STAMP_RE.match(l):
|
||||
return r > l
|
||||
return r > l
|
||||
|
||||
|
||||
def _fallback_version_from_code() -> dict[str, Any]:
|
||||
version = "0.0.0"
|
||||
channel = "stable"
|
||||
build = ""
|
||||
try:
|
||||
from aza_version import APP_VERSION, APP_CHANNEL
|
||||
|
||||
version = str(APP_VERSION).strip() or version
|
||||
channel = str(APP_CHANNEL).strip() or channel
|
||||
except Exception:
|
||||
pass
|
||||
if not build:
|
||||
try:
|
||||
from _build_info import BUILD_TIMESTAMP
|
||||
|
||||
build = str(BUILD_TIMESTAMP or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"version": version,
|
||||
"build": build,
|
||||
"channel": channel,
|
||||
"app": "AZA Desktop",
|
||||
}
|
||||
|
||||
|
||||
def load_local_version() -> dict[str, Any]:
|
||||
"""Liest version.json; Fallback aza_version.py + _build_info.py."""
|
||||
vf = find_local_version_file()
|
||||
if vf is not None:
|
||||
try:
|
||||
data = json.loads(vf.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
if get_test_install_dir() is not None:
|
||||
base = {
|
||||
"version": "0.0.0",
|
||||
"build": "",
|
||||
"channel": "stable",
|
||||
"app": "AZA Desktop",
|
||||
}
|
||||
base.update({k: v for k, v in data.items() if v is not None})
|
||||
return base
|
||||
out = _fallback_version_from_code()
|
||||
out.update({k: v for k, v in data.items() if v is not None})
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
if get_test_install_dir() is not None:
|
||||
return {
|
||||
"version": "0.0.0",
|
||||
"build": "",
|
||||
"channel": "stable",
|
||||
"app": "AZA Desktop",
|
||||
}
|
||||
return _fallback_version_from_code()
|
||||
|
||||
|
||||
def save_local_version(data: dict[str, Any]) -> Path:
|
||||
"""Schreibt version.json neben der Installation."""
|
||||
root = get_install_dir()
|
||||
target = root / _VERSION_JSON_NAME
|
||||
payload = dict(load_local_version())
|
||||
payload.update(data)
|
||||
target.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def format_version_label(data: dict[str, Any] | None = None) -> str:
|
||||
info = data or load_local_version()
|
||||
ver = str(info.get("version") or "?").strip()
|
||||
build = str(info.get("build") or "").strip()
|
||||
if build:
|
||||
return f"v{ver} · {build}"
|
||||
return f"v{ver}"
|
||||
|
||||
|
||||
def _normalize_notes(data: dict[str, Any]) -> list[str]:
|
||||
raw = data.get("notes_de")
|
||||
if raw is None:
|
||||
raw = data.get("notes")
|
||||
if raw is None:
|
||||
raw = data.get("release_notes")
|
||||
if isinstance(raw, str):
|
||||
return [raw.strip()] if raw.strip() else []
|
||||
if isinstance(raw, list):
|
||||
return [str(x).strip() for x in raw if str(x).strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _remote_version_fields(data: dict[str, Any]) -> tuple[str, str, str]:
|
||||
version = str(
|
||||
data.get("latest_version") or data.get("version") or ""
|
||||
).strip()
|
||||
build = str(data.get("latest_build") or data.get("build") or "").strip()
|
||||
channel = str(data.get("channel") or "stable").strip()
|
||||
return version, build, channel
|
||||
|
||||
|
||||
def _min_required(data: dict[str, Any]) -> str:
|
||||
for key in ("min_supported_version", "minimum_supported_version", "min_required_version"):
|
||||
v = data.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
return ""
|
||||
|
||||
|
||||
UPDATE_CHECK_LOG_NAME = "update_check.log"
|
||||
|
||||
|
||||
def get_update_check_log_path() -> Path:
|
||||
log_dir = get_user_data_root() / "logs"
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return log_dir / UPDATE_CHECK_LOG_NAME
|
||||
|
||||
|
||||
def _sanitize_log_text(text: str, *, max_len: int = 400) -> str:
|
||||
s = str(text or "").replace("\r", " ").replace("\n", " ").strip()
|
||||
for needle in ("token", "secret", "password", "authorization", "bearer"):
|
||||
if needle in s.lower():
|
||||
return "[redacted]"
|
||||
if len(s) > max_len:
|
||||
return s[: max_len - 3] + "..."
|
||||
return s
|
||||
|
||||
|
||||
def log_update_check(event: str, **fields: Any) -> None:
|
||||
"""Schreibt strukturierte Zeile nach %APPDATA%\\AzA\\logs\\update_check.log."""
|
||||
try:
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
parts = [f"time={ts}", f"event={event}"]
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
parts.append(f"{key}={_sanitize_log_text(str(value))}")
|
||||
line = " ".join(parts) + "\n"
|
||||
get_update_check_log_path().open("a", encoding="utf-8").write(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log_internal(msg: str) -> None:
|
||||
print(f"[AZA Update] {msg}")
|
||||
log_update_check("internal", message=_sanitize_log_text(msg))
|
||||
|
||||
|
||||
def resolve_manifest_sources(*, cli_manifest: str | None = None) -> list[str]:
|
||||
"""
|
||||
Manifest-Quellen (Startup, CLI, Updater).
|
||||
|
||||
Prioritaet:
|
||||
1) --manifest (CLI)
|
||||
2) AZA_UPDATE_MANIFEST_URL
|
||||
3) test_update_manifest.json bei AZA_UPDATE_TEST_MANIFEST=1
|
||||
4) oeffentlicher Update-Kanal /downloads/updates/manifest.json
|
||||
"""
|
||||
sources: list[str] = []
|
||||
cli = (cli_manifest or "").strip()
|
||||
if cli:
|
||||
sources.append(cli)
|
||||
|
||||
env_url = (os.environ.get("AZA_UPDATE_MANIFEST_URL") or "").strip()
|
||||
if env_url and env_url not in sources:
|
||||
sources.append(env_url)
|
||||
|
||||
use_test = (os.environ.get("AZA_UPDATE_TEST_MANIFEST") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
test_path = get_project_root() / LOCAL_TEST_MANIFEST_NAME
|
||||
if use_test and test_path.is_file():
|
||||
resolved = str(test_path.resolve())
|
||||
if resolved not in sources:
|
||||
sources.append(resolved)
|
||||
|
||||
if UPDATE_CHANNEL_MANIFEST_URL not in sources:
|
||||
sources.append(UPDATE_CHANNEL_MANIFEST_URL)
|
||||
return sources
|
||||
|
||||
|
||||
def resolve_startup_manifest_sources() -> list[str]:
|
||||
"""Alias fuer Startpanel-Startup (ohne CLI-Override)."""
|
||||
return resolve_manifest_sources()
|
||||
|
||||
|
||||
def _load_manifest_json_text(text: str, *, source: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
_log_internal(f"invalid_json source={source}")
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
_log_internal(f"invalid_manifest_shape source={source}")
|
||||
return None
|
||||
data["_manifest_url"] = source
|
||||
return data
|
||||
|
||||
|
||||
def load_manifest_from_source(source: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""Laedt Manifest von HTTP(S)-URL oder lokaler Datei."""
|
||||
src = (source or "").strip()
|
||||
if not src:
|
||||
return None, "empty_source"
|
||||
|
||||
if src.lower().startswith("file://"):
|
||||
src = src[7:]
|
||||
if sys.platform == "win32" and src.startswith("/") and len(src) > 2 and src[2] == ":":
|
||||
src = src[1:]
|
||||
|
||||
path = Path(src)
|
||||
if path.is_file() or (not src.lower().startswith("http") and path.exists()):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
data = _load_manifest_json_text(text, source=str(path.resolve()))
|
||||
return (data, None) if data else (None, "invalid_json")
|
||||
except OSError as exc:
|
||||
return None, f"file_read={type(exc).__name__}"
|
||||
|
||||
if requests is None:
|
||||
return None, "requests_not_installed"
|
||||
|
||||
try:
|
||||
r = requests.get(src, timeout=8.0)
|
||||
if r.status_code == 404:
|
||||
return None, f"http_status=404 url={src}"
|
||||
if r.status_code != 200:
|
||||
return None, f"http_status={r.status_code} url={src}"
|
||||
text = r.content.decode("utf-8-sig")
|
||||
data = _load_manifest_json_text(text, source=src)
|
||||
return (data, None) if data else (None, "invalid_json")
|
||||
except Exception as exc:
|
||||
return None, f"exc={type(exc).__name__} url={src}"
|
||||
|
||||
|
||||
def fetch_startup_manifest(
|
||||
*,
|
||||
sources: list[str] | None = None,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""Nur Update-Kanal — kein Fallback auf Installer-version.json."""
|
||||
last: str | None = None
|
||||
for source in sources or resolve_startup_manifest_sources():
|
||||
data, err = load_manifest_from_source(source)
|
||||
if data:
|
||||
return data, None
|
||||
last = err
|
||||
if err and "404" in err:
|
||||
_log_internal(f"Update manifest not available ({err})")
|
||||
return None, last or "no_manifest"
|
||||
|
||||
|
||||
def _update_files_from_manifest(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
files = data.get("files")
|
||||
if isinstance(files, list) and files:
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in files:
|
||||
if isinstance(item, dict) and item.get("url"):
|
||||
out.append(dict(item))
|
||||
return out
|
||||
download_url = str(data.get("download_url") or "").strip()
|
||||
if download_url:
|
||||
return [
|
||||
{
|
||||
"name": Path(download_url).name or "aza_desktop_setup.exe",
|
||||
"url": download_url,
|
||||
"sha256": str(data.get("sha256") or "").strip(),
|
||||
"size_bytes": data.get("size_bytes"),
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def fetch_remote_manifest(
|
||||
*,
|
||||
timeout: float = 8.0,
|
||||
urls: tuple[str, ...] | None = None,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""Laedt Manifest (inkl. Legacy-Installer-Endpunkte)."""
|
||||
last: str | None = None
|
||||
for url in urls or UPDATE_MANIFEST_URLS:
|
||||
data, err = load_manifest_from_source(url)
|
||||
if data:
|
||||
return data, None
|
||||
last = err
|
||||
return None, last or "no_manifest"
|
||||
|
||||
|
||||
def check_update_from_manifest(manifest_source: str | Path) -> dict[str, Any]:
|
||||
"""Prueft Update anhand eines expliziten Manifests (lokal oder URL)."""
|
||||
local = load_local_version()
|
||||
remote, err = load_manifest_from_source(str(manifest_source))
|
||||
if err or not remote:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Manifest nicht lesbar.",
|
||||
"detail": err,
|
||||
"local": local,
|
||||
}
|
||||
return evaluate_update(local, remote)
|
||||
|
||||
|
||||
def check_update_for_startup(*, manifest: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Start-Updatepruefung fuer das schoene Startpanel.
|
||||
|
||||
status:
|
||||
- manifest_unavailable (404/kein Netz — still fuer Benutzer)
|
||||
- current
|
||||
- update_available
|
||||
- channel_mismatch
|
||||
- error
|
||||
"""
|
||||
local = load_local_version()
|
||||
sources = resolve_manifest_sources(cli_manifest=manifest)
|
||||
log_update_check(
|
||||
"check_start",
|
||||
local_version=local.get("version"),
|
||||
local_build=local.get("build"),
|
||||
manifest_url=sources[0] if sources else "",
|
||||
)
|
||||
remote, err = fetch_startup_manifest(sources=sources)
|
||||
if err or not remote:
|
||||
silent = bool(
|
||||
err
|
||||
and (
|
||||
"404" in err
|
||||
or err in ("no_manifest", "requests_not_installed")
|
||||
or err.startswith("exc=")
|
||||
)
|
||||
)
|
||||
status = "manifest_unavailable" if silent else "error"
|
||||
log_update_check(
|
||||
"check_result",
|
||||
status=status,
|
||||
detail=err,
|
||||
manifest_url=remote.get("_manifest_url") if remote else sources[0],
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"message": "Update manifest not available" if silent else "Manifest nicht erreichbar.",
|
||||
"detail": err,
|
||||
"local": local,
|
||||
}
|
||||
result = evaluate_update(local, remote)
|
||||
result["detail"] = err
|
||||
log_update_check(
|
||||
"check_result",
|
||||
status=result.get("status"),
|
||||
latest_version=result.get("latest_version"),
|
||||
manifest_url=remote.get("_manifest_url") or sources[0],
|
||||
detail=err,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def validate_update_install_ready(result: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""Prueft alle Voraussetzungen vor einer Installation."""
|
||||
if result.get("status") != "update_available":
|
||||
return False, "Kein Update verfuegbar."
|
||||
|
||||
files = result.get("files") or []
|
||||
if not files:
|
||||
return False, "Kein Update-Paket im Manifest."
|
||||
|
||||
file_info = files[0]
|
||||
url = str(file_info.get("url") or "").strip()
|
||||
if not url:
|
||||
return False, "Download-URL fehlt."
|
||||
|
||||
sha = str(file_info.get("sha256") or "").strip()
|
||||
if not sha:
|
||||
return False, "SHA256 fehlt im Manifest."
|
||||
|
||||
latest = str(result.get("latest_version") or "").strip()
|
||||
if not latest:
|
||||
return False, "Versionsangabe fehlt."
|
||||
|
||||
name = str(file_info.get("name") or Path(url).name or "").strip()
|
||||
if name.lower().endswith(".zip"):
|
||||
return True, "ok"
|
||||
if name.lower().endswith(".exe"):
|
||||
return True, "ok"
|
||||
return False, "Unbekanntes Update-Format."
|
||||
|
||||
|
||||
def evaluate_update(
|
||||
local: dict[str, Any] | None = None,
|
||||
remote: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Vergleicht lokal vs. remote.
|
||||
|
||||
status:
|
||||
- current
|
||||
- update_available
|
||||
- below_min_supported
|
||||
- channel_mismatch
|
||||
- error
|
||||
"""
|
||||
loc = local or load_local_version()
|
||||
if remote is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Kein Remote-Manifest",
|
||||
"local": loc,
|
||||
}
|
||||
|
||||
local_ver = str(loc.get("version") or "").strip()
|
||||
local_build = str(loc.get("build") or "").strip()
|
||||
local_channel = str(loc.get("channel") or "stable").strip()
|
||||
|
||||
remote_ver, remote_build, remote_channel = _remote_version_fields(remote)
|
||||
if remote_channel != local_channel:
|
||||
return {
|
||||
"status": "channel_mismatch",
|
||||
"message": f"Kanal lokal={local_channel}, remote={remote_channel}",
|
||||
"local": loc,
|
||||
"remote": remote,
|
||||
}
|
||||
|
||||
min_req = _min_required(remote)
|
||||
below_min = bool(min_req and semver_gt(min_req, local_ver))
|
||||
newer_semver = semver_gt(remote_ver, local_ver)
|
||||
same_semver = semver_eq(remote_ver, local_ver)
|
||||
build_bump = bool(
|
||||
same_semver and remote_build and local_build and build_gt(remote_build, local_build)
|
||||
)
|
||||
client_ahead = semver_gt(local_ver, remote_ver)
|
||||
|
||||
if client_ahead and not below_min:
|
||||
return {
|
||||
"status": "current",
|
||||
"message": "Lokal ist aktuell oder neuer.",
|
||||
"local": loc,
|
||||
"remote": remote,
|
||||
}
|
||||
|
||||
if below_min or newer_semver or build_bump:
|
||||
files = _update_files_from_manifest(remote)
|
||||
return {
|
||||
"status": "update_available",
|
||||
"message": "Update verfuegbar.",
|
||||
"local": loc,
|
||||
"remote": remote,
|
||||
"latest_version": remote_ver,
|
||||
"latest_build": remote_build,
|
||||
"below_min_supported": below_min,
|
||||
"mandatory": bool(remote.get("mandatory")) or below_min,
|
||||
"notes": _normalize_notes(remote),
|
||||
"files": files,
|
||||
"manifest_url": remote.get("_manifest_url"),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "current",
|
||||
"message": "Kein Update noetig.",
|
||||
"local": loc,
|
||||
"remote": remote,
|
||||
}
|
||||
|
||||
|
||||
def compute_sha256(path: Path, *, chunk_size: int = 1024 * 1024) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest().upper()
|
||||
|
||||
|
||||
def verify_sha256(path: Path, expected: str) -> bool:
|
||||
exp = str(expected or "").strip().upper()
|
||||
if not exp or not path.is_file():
|
||||
return False
|
||||
return compute_sha256(path) == exp
|
||||
|
||||
|
||||
def _resolve_package_source(url: str) -> Path | None:
|
||||
src = (url or "").strip()
|
||||
if not src:
|
||||
return None
|
||||
if src.lower().startswith("file://"):
|
||||
src = src[7:]
|
||||
if sys.platform == "win32" and src.startswith("/") and len(src) > 2 and src[2] == ":":
|
||||
src = src[1:]
|
||||
path = Path(src)
|
||||
if path.is_file():
|
||||
return path.resolve()
|
||||
if not src.lower().startswith("http") and path.exists():
|
||||
return path.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def download_file(
|
||||
url: str,
|
||||
dest: Path,
|
||||
*,
|
||||
timeout: float = 120.0,
|
||||
progress: Callable[[int, int | None], None] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Laedt eine Datei herunter. Keine Installation."""
|
||||
if requests is None:
|
||||
return False, "requests_not_installed"
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=timeout) as r:
|
||||
if r.status_code != 200:
|
||||
return False, f"http_status={r.status_code}"
|
||||
total = int(r.headers.get("Content-Length") or 0) or None
|
||||
done = 0
|
||||
with tmp.open("wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=1024 * 256):
|
||||
if not chunk:
|
||||
continue
|
||||
f.write(chunk)
|
||||
done += len(chunk)
|
||||
if progress:
|
||||
progress(done, total)
|
||||
tmp.replace(dest)
|
||||
return True, "ok"
|
||||
except Exception as exc:
|
||||
try:
|
||||
if tmp.is_file():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def create_pre_update_backup(
|
||||
install_dir: Path | None = None,
|
||||
*,
|
||||
extra_names: tuple[str, ...] = (
|
||||
"aza_desktop.exe",
|
||||
"aza_controller.exe",
|
||||
"aza_office.exe",
|
||||
"aza_praxis_chat.exe",
|
||||
"aza_updater.exe",
|
||||
"AZA_EmpfangShell.exe",
|
||||
"version.json",
|
||||
"BUILD_INFO.txt",
|
||||
),
|
||||
) -> Path:
|
||||
"""
|
||||
Sichert EXEs und version.json vor einem Update.
|
||||
Benutzerdaten in APPDATA werden nicht angetastet.
|
||||
"""
|
||||
src = install_dir or get_install_dir()
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_dir = get_update_backup_root() / stamp
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied: list[str] = []
|
||||
names = list(extra_names) + ["app_marker.txt"]
|
||||
for name in names:
|
||||
p = src / name
|
||||
if p.is_file():
|
||||
shutil.copy2(p, backup_dir / name)
|
||||
copied.append(name)
|
||||
|
||||
runtime = src / "runtime"
|
||||
if runtime.is_dir():
|
||||
shutil.copytree(runtime, backup_dir / "runtime", dirs_exist_ok=True)
|
||||
copied.append("runtime/")
|
||||
|
||||
meta = {
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"install_dir": str(src),
|
||||
"copied": copied,
|
||||
}
|
||||
(backup_dir / "backup_meta.json").write_text(
|
||||
json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return backup_dir
|
||||
|
||||
|
||||
def rollback_from_backup(
|
||||
backup_dir: Path,
|
||||
install_dir: Path | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Stellt gesicherte Programmdateien wieder her."""
|
||||
if not backup_dir.is_dir():
|
||||
return False, "Backup-Ordner nicht gefunden."
|
||||
dst = install_dir or get_install_dir()
|
||||
restored: list[str] = []
|
||||
for item in backup_dir.iterdir():
|
||||
if item.name == "backup_meta.json":
|
||||
continue
|
||||
target = dst / item.name
|
||||
try:
|
||||
if item.is_dir():
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
shutil.copytree(item, target)
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, target)
|
||||
restored.append(item.name)
|
||||
except Exception as exc:
|
||||
return False, f"Rollback fehlgeschlagen bei {item.name}: {exc}"
|
||||
return True, f"Wiederhergestellt: {', '.join(restored)}"
|
||||
|
||||
|
||||
def list_available_backups() -> list[Path]:
|
||||
root = get_update_backup_root()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
dirs = [p for p in root.iterdir() if p.is_dir()]
|
||||
return sorted(dirs, reverse=True)
|
||||
|
||||
|
||||
def extract_update_zip(zip_path: Path, target_dir: Path) -> tuple[bool, str]:
|
||||
"""Entpackt ein Update-ZIP in das Installationsverzeichnis (ohne APPDATA)."""
|
||||
if not zip_path.is_file():
|
||||
return False, "ZIP nicht gefunden."
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for member in zf.namelist():
|
||||
if member.startswith("/") or ".." in Path(member).parts:
|
||||
return False, f"Unsicherer ZIP-Eintrag: {member}"
|
||||
zf.extractall(target_dir)
|
||||
return True, "ok"
|
||||
except Exception as exc:
|
||||
return False, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def download_update_package(
|
||||
file_info: dict[str, Any],
|
||||
*,
|
||||
work_dir: Path | None = None,
|
||||
progress: Callable[[int, int | None], None] | None = None,
|
||||
) -> tuple[Path | None, str]:
|
||||
"""Laedt ein Update-Paket herunter und prueft SHA256."""
|
||||
url = str(file_info.get("url") or "").strip()
|
||||
if not url:
|
||||
log_update_check("dl_no_url")
|
||||
return None, "Keine Download-URL."
|
||||
name = str(file_info.get("name") or Path(url).name or "aza_update.zip")
|
||||
base = work_dir or Path(tempfile.gettempdir()) / "aza_updates"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
dest = base / name
|
||||
log_update_check("dl_start", url=url[:200], dest=str(dest))
|
||||
local_src = _resolve_package_source(url)
|
||||
if local_src is not None:
|
||||
try:
|
||||
shutil.copy2(local_src, dest)
|
||||
ok, msg = True, "ok"
|
||||
except Exception as exc:
|
||||
log_update_check("dl_local_copy_failed", error=f"{type(exc).__name__}: {exc}"[:160])
|
||||
return None, f"{type(exc).__name__}: {exc}"
|
||||
elif not url.lower().startswith("http"):
|
||||
log_update_check("dl_no_pkg")
|
||||
return None, "Paket nicht gefunden."
|
||||
else:
|
||||
ok, msg = download_file(url, dest, progress=progress)
|
||||
if not ok:
|
||||
log_update_check("dl_failed", msg=str(msg)[:160])
|
||||
return None, msg
|
||||
expected = str(file_info.get("sha256") or "").strip()
|
||||
if not expected:
|
||||
log_update_check("dl_no_sha")
|
||||
return None, "SHA256 fehlt — Download abgebrochen."
|
||||
if not verify_sha256(dest, expected):
|
||||
try:
|
||||
dest.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
log_update_check("dl_sha_mismatch")
|
||||
return None, "SHA256 stimmt nicht ueberein — Download verworfen."
|
||||
log_update_check("dl_ok", size=dest.stat().st_size)
|
||||
return dest, "ok"
|
||||
File diff suppressed because it is too large
Load Diff
19748
AzA march 2026/backup_local_ui_update_on_exit_20260525_112611/basis14.py
Normal file
19748
AzA march 2026/backup_local_ui_update_on_exit_20260525_112611/basis14.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user