# -*- 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 re import tkinter as tk from datetime import datetime 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" # ── Sortier-Helfer fuer das Ordner-Fenster ───────────────────────────────────── # Eintragsnamen sehen typischerweise so aus: "44 Diktat 21.05.2026 23:14". # Die Anzeige soll *neueste oben* sein. Bisher sortierte list_ablage_files() # in aza_persistence.py nach fuehrender Nummer absteigend — das fuehrt dazu, # dass ein neu hinzugefuegter Eintrag mit kleiner Nummer faelschlich ganz unten # steht. Wir lassen die Persistenz-Funktion bewusst unveraendert und # re-sortieren die Liste hier robust nach Datum + Uhrzeit. _ABLAGE_DT_FULL_RE = re.compile(r"(\d{1,2})\.(\d{1,2})\.(\d{4})\s+(\d{1,2}):(\d{2})") _ABLAGE_DT_ISO_RE = re.compile(r"(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})") _ABLAGE_DATE_ONLY_RE = re.compile(r"(\d{1,2})\.(\d{1,2})\.(\d{4})") _ABLAGE_DATE_ISO_ONLY_RE = re.compile(r"(\d{4})-(\d{1,2})-(\d{1,2})") _ABLAGE_LEADING_NUM_RE = re.compile(r"^\s*(\d+)") def _parse_ablage_datetime(name: str): """Extrahiert datetime aus dem Anzeigenamen. Reihenfolge: dd.mm.yyyy hh:mm > yyyy-mm-dd hh:mm > dd.mm.yyyy > yyyy-mm-dd. Liefert ``None``, wenn nichts erkannt werden konnte. """ if not name: return None m = _ABLAGE_DT_FULL_RE.search(name) if m: try: d, mo, y, hh, mm = (int(x) for x in m.groups()) return datetime(y, mo, d, hh, mm) except (ValueError, IndexError): pass m = _ABLAGE_DT_ISO_RE.search(name) if m: try: y, mo, d, hh, mm = (int(x) for x in m.groups()) return datetime(y, mo, d, hh, mm) except (ValueError, IndexError): pass m = _ABLAGE_DATE_ONLY_RE.search(name) if m: try: d, mo, y = (int(x) for x in m.groups()) return datetime(y, mo, d) except (ValueError, IndexError): pass m = _ABLAGE_DATE_ISO_ONLY_RE.search(name) if m: try: y, mo, d = (int(x) for x in m.groups()) return datetime(y, mo, d) except (ValueError, IndexError): pass return None def _parse_ablage_leading_number(name: str) -> int: """Liest die fuehrende Nummer (z.B. ``44`` aus ``44 Diktat ...``).""" if not name: return 0 m = _ABLAGE_LEADING_NUM_RE.match(str(name)) if not m: return 0 try: return int(m.group(1)) except (ValueError, IndexError): return 0 def _ablage_store_mtime() -> float: """mtime der zentralen ablage.json — Fallback-Sortierschluessel. Da alle Eintraege in einer einzigen JSON liegen, ist diese mtime fuer Eintraege ohne lesbares Datum identisch. Sie liefert in dem Fall aber immerhin einen stabilen, deterministischen Wert (und schlaegt spaeter zusaetzlich, falls einzelne Eintraege jemals auf eigene Dateien umgestellt werden sollten). """ try: path = os.path.join(_ablage_base_path(), "ablage.json") if os.path.isfile(path): return float(os.path.getmtime(path)) except Exception: pass return 0.0 def _sort_ablage_entries(names) -> list: """Sortiert Eintragsnamen fuer die Listbox-Anzeige (neueste oben). Sortierschluessel pro Eintrag (alle absteigend, dank ``reverse=True``): 1. erkanntes Datum + Uhrzeit (dd.mm.yyyy hh:mm bevorzugt) 2. Bei identischem Datum: fuehrende Nummer absteigend 3. Eintraege ohne erkennbares Datum landen stabil am Ende (Pythons sort ist stabil, daher bleibt deren urspruengliche Reihenfolge erhalten). Die Methode beruehrt aza_persistence.list_ablage_files nicht und funktioniert mit jeder beliebigen Liste von Eintragsnamen. """ if not names: return [] fallback_mtime = _ablage_store_mtime() def _key(n): dt = _parse_ablage_datetime(n) num = _parse_ablage_leading_number(n) if dt is not None: return (1, dt.timestamp(), num) return (0, fallback_mtime, num) return sorted(list(names), key=_key, reverse=True) 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("", _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("", lambda e, c=cat: _switch_tab(c)) _tab_btns[cat].bind("", lambda e, b=_tab_btns[cat], c=cat: ( b.configure(bg=_TAB_ACT) if _active[0] != c else None )) _tab_btns[cat].bind("", 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 = [] # Cache der zuletzt angezeigten, sortierten Dateinamen pro Kategorie. # Wird beim Doppelklick gelesen, damit Listbox-Index und tatsaechlich # geoeffnete Datei garantiert konsistent sind. sorted_files_cache: dict = {} def _refresh(lb: tk.Listbox, category: str): lb.delete(0, "end") files = _sort_ablage_entries(list_ablage_files(category)) sorted_files_cache[category] = files for f in files: 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 # Aus dem Cache lesen, damit Index 1:1 zur angezeigten, # sortierten Reihenfolge passt. Cache leer? Sortierung # rekonstruieren, damit Doppelklick auch ohne vorheriges # _refresh sicher die richtige Datei oeffnet. files = sorted_files_cache.get(c) if not files: files = _sort_ablage_entries(list_ablage_files(c)) sorted_files_cache[c] = files if 0 <= sel[0] < len(files): _load_file(c, files[sel[0]]) lb.bind("", _on_dblclick) def _on_mousewheel(evt, lbx=lb): lbx.yview_scroll(int(-1 * (evt.delta / 120)), "units") lb.bind("", _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)