This commit is contained in:
2026-05-28 18:58:38 +02:00
parent 641bb10479
commit 28f429885a
4950 changed files with 933414 additions and 666 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

File diff suppressed because it is too large Load Diff