Files
aza/AzA march 2026/aza_ordner_mixin.py
2026-05-28 18:58:38 +02:00

498 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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("<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 = []
# 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("<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)