feat: Implement application preferences with JSON persistence
Build and Push Docker Image / build-and-push (push) Has been cancelled
Build and Push Docker Image / build-and-push (push) Has been cancelled
- Added preferences management in `preferences.py` with functions to load, save, get, set, and reset preferences. - Introduced a configuration directory structure based on the operating system. - Integrated preferences into the settings window, allowing users to reset settings and manage window geometry. - Enhanced `search_filter.py` to support flexible date column names and improved filtering logic. - Updated `settings_window.py` to include options for managing backup and configuration folder paths. - Introduced an `UndoManager` class to handle undo actions for add/update/delete operations. - Improved UIManager to support sorting in tree views and added a toast notification feature.
This commit is contained in:
+550
-95
@@ -1,8 +1,10 @@
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from tkinter import messagebox, ttk
|
||||
from datetime import datetime
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
@@ -20,11 +22,13 @@ from medicine_management_window import MedicineManagementWindow
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_management_window import PathologyManagementWindow
|
||||
from pathology_manager import PathologyManager
|
||||
from preferences import get_config_dir, get_pref, save_preferences, set_pref
|
||||
from search_filter import DataFilter
|
||||
from search_filter_ui import SearchFilterWidget
|
||||
from settings_window import SettingsWindow
|
||||
from theme_manager import ThemeManager
|
||||
from ui_manager import UIManager
|
||||
from undo_manager import UndoAction, UndoManager
|
||||
|
||||
|
||||
class MedTrackerApp:
|
||||
@@ -34,19 +38,23 @@ class MedTrackerApp:
|
||||
self.root.title("Thechart - medication tracker")
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.handle_window_closing)
|
||||
|
||||
# Live geometry persistence state
|
||||
self._geom_save_job: str | None = None
|
||||
self._last_saved_geometry: str = ""
|
||||
|
||||
# Set up data file
|
||||
self.filename: str = "thechart_data.csv"
|
||||
first_argument: str = ""
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
first_argument: str = sys.argv[1]
|
||||
first_argument = sys.argv[1]
|
||||
if os.path.exists(first_argument):
|
||||
self.filename = first_argument
|
||||
logger.info(f"Using data file: {first_argument}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Data file {first_argument} doesn't exist. \
|
||||
Using default file: {self.filename}"
|
||||
"Data file %s doesn't exist. Using default file: %s",
|
||||
first_argument,
|
||||
self.filename,
|
||||
)
|
||||
|
||||
logger.info(f"Log level: {LOG_LEVEL}")
|
||||
@@ -73,6 +81,8 @@ class MedTrackerApp:
|
||||
self.pathology_manager,
|
||||
self.theme_manager,
|
||||
)
|
||||
# Undo manager (history of data mutations)
|
||||
self.undo_manager: UndoManager = UndoManager()
|
||||
|
||||
# Update error handler with UI manager for user feedback
|
||||
self.error_handler.ui_manager = self.ui_manager
|
||||
@@ -90,7 +100,11 @@ class MedTrackerApp:
|
||||
self.auto_save_manager = AutoSaveManager(
|
||||
save_callback=self._auto_save_callback, interval_minutes=5, logger=logger
|
||||
)
|
||||
self.backup_manager = BackupManager(data_file_path=self.filename, logger=logger)
|
||||
self.backup_manager = BackupManager(
|
||||
data_file_path=self.filename,
|
||||
logger=logger,
|
||||
status_callback=self._on_backup_status,
|
||||
)
|
||||
|
||||
# Initialize search/filter system
|
||||
self.data_filter = DataFilter()
|
||||
@@ -106,8 +120,26 @@ class MedTrackerApp:
|
||||
# Setup keyboard shortcuts
|
||||
self._setup_keyboard_shortcuts()
|
||||
|
||||
# Center the window on screen
|
||||
self._center_window()
|
||||
# Apply window preferences (geometry, always-on-top) then center if needed
|
||||
with contextlib.suppress(Exception):
|
||||
self.root.wm_attributes("-topmost", bool(get_pref("always_on_top", False)))
|
||||
|
||||
geom = str(get_pref("last_window_geometry", ""))
|
||||
if get_pref("remember_window_geometry", True) and geom:
|
||||
try:
|
||||
self.root.geometry(geom)
|
||||
except Exception:
|
||||
self._center_window()
|
||||
else:
|
||||
# Center the window on screen
|
||||
self._center_window()
|
||||
|
||||
# Bind configure to persist geometry live (debounced)
|
||||
try:
|
||||
self.root.bind("<Configure>", self._on_configure, add="+")
|
||||
except Exception:
|
||||
# Older Tk variants may not support add; fall back
|
||||
self.root.bind("<Configure>", self._on_configure)
|
||||
|
||||
# Enable auto-save by default
|
||||
self.auto_save_manager.enable_auto_save()
|
||||
@@ -115,6 +147,143 @@ class MedTrackerApp:
|
||||
# Create initial backup
|
||||
self.backup_manager.create_backup("startup")
|
||||
|
||||
def _on_configure(self, _event: object | None = None) -> None:
|
||||
"""Debounce window configure events to persist geometry live."""
|
||||
# Skip when user disabled remembering geometry
|
||||
with contextlib.suppress(Exception):
|
||||
if not get_pref("remember_window_geometry", True):
|
||||
return
|
||||
|
||||
# Avoid saving while minimized
|
||||
with contextlib.suppress(Exception):
|
||||
if getattr(self.root, "state", lambda: "normal")() == "iconic":
|
||||
return
|
||||
|
||||
# Debounce saves to limit disk writes
|
||||
if self._geom_save_job is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
self.root.after_cancel(self._geom_save_job)
|
||||
self._geom_save_job = None
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
self._geom_save_job = self.root.after(600, self._save_geometry_now)
|
||||
|
||||
def _save_geometry_now(self) -> None:
|
||||
"""Capture current geometry and persist to preferences if changed."""
|
||||
try:
|
||||
geom = self.root.geometry()
|
||||
if geom and geom != self._last_saved_geometry:
|
||||
set_pref("last_window_geometry", geom)
|
||||
save_preferences()
|
||||
self._last_saved_geometry = geom
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_backup_status(self, msg: str) -> None:
|
||||
"""Handle backup-related status updates with status bar and toast."""
|
||||
try:
|
||||
self.ui_manager.update_status(msg, "success")
|
||||
# Show a brief toast for backup events if available
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
# Keep toast short to avoid annoyance during startup/shutdown
|
||||
self.ui_manager.show_toast(msg, 1500)
|
||||
# Update 'Last backup' indicator on backup creation
|
||||
if "Backup created:" in msg and hasattr(
|
||||
self.ui_manager, "update_last_backup"
|
||||
):
|
||||
when = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
self.ui_manager.update_last_backup(when)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to show backup status: {exc}")
|
||||
|
||||
def _restore_from_backup(self) -> None:
|
||||
"""Prompt user to select a backup CSV and restore it."""
|
||||
initial_dir = getattr(self.backup_manager, "backup_directory", os.getcwd())
|
||||
file_path = filedialog.askopenfilename(
|
||||
parent=self.root,
|
||||
title="Restore from Backup",
|
||||
initialdir=initial_dir,
|
||||
filetypes=[("CSV Files", "*.csv"), ("All Files", "*.*")],
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
# Build a detailed confirmation with file info
|
||||
try:
|
||||
size_b = os.path.getsize(file_path)
|
||||
|
||||
def _fmt_size(n: int) -> str:
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
mtime_str = mtime.strftime("%Y-%m-%d %H:%M")
|
||||
confirm_msg = (
|
||||
"You're about to restore data from this backup file:\n\n"
|
||||
f"• File: {os.path.basename(file_path)}\n"
|
||||
f"• Size: {_fmt_size(size_b)}\n"
|
||||
f"• Modified: {mtime_str}\n\n"
|
||||
f"This will replace: {os.path.abspath(self.filename)}\n\n"
|
||||
"A pre-restore backup of the current data will be created.\n\n"
|
||||
"Proceed with restore?"
|
||||
)
|
||||
except Exception:
|
||||
confirm_msg = "Restore selected backup? Current data will be saved first."
|
||||
|
||||
if not messagebox.askyesno("Confirm Restore", confirm_msg, parent=self.root):
|
||||
return
|
||||
try:
|
||||
# Create a safety backup of the current data before restoring
|
||||
try:
|
||||
self.backup_manager.create_backup("pre_restore")
|
||||
except Exception as _exc:
|
||||
logger.warning(f"Pre-restore backup failed: {_exc}")
|
||||
|
||||
ok = self.backup_manager.restore_from_backup(file_path)
|
||||
if ok:
|
||||
if hasattr(self.data_manager, "_invalidate_cache"):
|
||||
self.data_manager._invalidate_cache()
|
||||
self.refresh_data_display()
|
||||
base = os.path.basename(file_path)
|
||||
self.ui_manager.update_status(f"Restored from: {base}", "success")
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast(f"Restored: {base}", 1800)
|
||||
|
||||
# Offer to open the folder containing the restored file (if opted-in)
|
||||
try:
|
||||
if get_pref(
|
||||
"prompt_open_folder_after_restore", False
|
||||
) and messagebox.askyesno(
|
||||
"Restore Complete",
|
||||
(
|
||||
f"Restored from '{base}'.\n\n"
|
||||
"Open the containing backups folder now?"
|
||||
),
|
||||
parent=self.root,
|
||||
):
|
||||
path = os.path.dirname(file_path)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
except Exception as _e:
|
||||
logger.warning(f"Failed to open restored folder: {_e}")
|
||||
else:
|
||||
self.ui_manager.update_status("Restore failed", "error")
|
||||
messagebox.showerror(
|
||||
"Restore Failed",
|
||||
"Could not restore backup.",
|
||||
parent=self.root,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Restore from backup failed: {e}")
|
||||
self.ui_manager.update_status("Restore failed", "error")
|
||||
messagebox.showerror("Restore Failed", str(e), parent=self.root)
|
||||
|
||||
def _center_window(self) -> None:
|
||||
"""Center the main window on the screen."""
|
||||
# Update the window to get accurate dimensions
|
||||
@@ -226,41 +395,79 @@ class MedTrackerApp:
|
||||
file_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0)
|
||||
menubar.add_cascade(label="File", menu=file_menu)
|
||||
file_menu.add_command(
|
||||
label="Export Data...",
|
||||
label="Export Data... (Ctrl+E)",
|
||||
command=self._open_export_window,
|
||||
accelerator="Ctrl+E",
|
||||
)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_command(
|
||||
label="Exit", command=self.handle_window_closing, accelerator="Ctrl+Q"
|
||||
label="Exit (Ctrl+Q)",
|
||||
command=self.handle_window_closing,
|
||||
accelerator="Ctrl+Q",
|
||||
)
|
||||
|
||||
# Tools menu
|
||||
tools_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0)
|
||||
menubar.add_cascade(label="Tools", menu=tools_menu)
|
||||
tools_menu.add_command(
|
||||
label="Manage Pathologies...",
|
||||
label="Manage Pathologies... (Ctrl+P)",
|
||||
command=self._open_pathology_manager,
|
||||
accelerator="Ctrl+P",
|
||||
)
|
||||
tools_menu.add_command(
|
||||
label="Manage Medicines...",
|
||||
label="Manage Medicines... (Ctrl+M)",
|
||||
command=self._open_medicine_manager,
|
||||
accelerator="Ctrl+M",
|
||||
)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(
|
||||
label="Clear Entries", command=self._clear_entries, accelerator="Ctrl+N"
|
||||
label="Clear Entries (Ctrl+N)",
|
||||
command=self._clear_entries,
|
||||
accelerator="Ctrl+N",
|
||||
)
|
||||
tools_menu.add_command(
|
||||
label="Refresh Data", command=self.refresh_data_display, accelerator="F5"
|
||||
label="Refresh Data (F5)",
|
||||
command=self.refresh_data_display,
|
||||
accelerator="F5",
|
||||
)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(
|
||||
label="Search & Filter",
|
||||
label="Search & Filter (Ctrl+F)",
|
||||
command=self._toggle_search_filter,
|
||||
accelerator="Ctrl+F",
|
||||
)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(
|
||||
label="Open Logs Folder (Ctrl+L)",
|
||||
command=self._open_logs_folder,
|
||||
accelerator="Ctrl+L",
|
||||
)
|
||||
tools_menu.add_command(
|
||||
label="Open Data Folder (Ctrl+D)",
|
||||
command=self._open_data_folder,
|
||||
accelerator="Ctrl+D",
|
||||
)
|
||||
tools_menu.add_command(
|
||||
label="Open Backups Folder (Ctrl+B)",
|
||||
command=self._open_backups_folder,
|
||||
accelerator="Ctrl+B",
|
||||
)
|
||||
tools_menu.add_command(
|
||||
label="Create Backup Now (Ctrl+Shift+B)",
|
||||
command=self._create_manual_backup,
|
||||
accelerator="Ctrl+Shift+B",
|
||||
)
|
||||
tools_menu.add_command(
|
||||
label="Restore from Backup... (Ctrl+Shift+R)",
|
||||
command=self._restore_from_backup,
|
||||
accelerator="Ctrl+Shift+R",
|
||||
)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(
|
||||
label="Open Config Folder (Ctrl+Shift+C)",
|
||||
command=self._open_config_folder,
|
||||
accelerator="Ctrl+Shift+C",
|
||||
)
|
||||
|
||||
# Theme menu
|
||||
theme_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0)
|
||||
@@ -279,66 +486,92 @@ class MedTrackerApp:
|
||||
|
||||
theme_menu.add_separator()
|
||||
theme_menu.add_command(
|
||||
label="More Settings...",
|
||||
label="More Settings... (F2)",
|
||||
command=self._open_settings_window,
|
||||
accelerator="F2",
|
||||
)
|
||||
|
||||
# Help menu
|
||||
help_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0)
|
||||
menubar.add_cascade(label="Help", menu=help_menu)
|
||||
help_menu.add_command(
|
||||
label="Settings...",
|
||||
command=self._open_settings_window,
|
||||
accelerator="F2",
|
||||
)
|
||||
help_menu.add_separator()
|
||||
help_menu.add_command(
|
||||
label="Keyboard Shortcuts",
|
||||
label="Keyboard Shortcuts (F1)",
|
||||
command=self._show_keyboard_shortcuts,
|
||||
accelerator="F1",
|
||||
)
|
||||
help_menu.add_command(label="About", command=self._show_about_dialog)
|
||||
help_menu.add_separator()
|
||||
help_menu.add_command(
|
||||
label="Open Documentation (Ctrl+H)",
|
||||
command=self._open_documentation,
|
||||
accelerator="Ctrl+H",
|
||||
)
|
||||
|
||||
def _setup_keyboard_shortcuts(self) -> None:
|
||||
"""Set up keyboard shortcuts for common actions."""
|
||||
# Bind keyboard shortcuts to the main window
|
||||
self.root.bind("<Control-s>", lambda e: self.add_new_entry())
|
||||
self.root.bind("<Control-S>", lambda e: self.add_new_entry())
|
||||
self.root.bind("<Control-q>", lambda e: self.handle_window_closing())
|
||||
self.root.bind("<Control-Q>", lambda e: self.handle_window_closing())
|
||||
self.root.bind("<Control-e>", lambda e: self._open_export_window())
|
||||
self.root.bind("<Control-E>", lambda e: self._open_export_window())
|
||||
self.root.bind("<Control-n>", lambda e: self._clear_entries())
|
||||
self.root.bind("<Control-N>", lambda e: self._clear_entries())
|
||||
self.root.bind("<Control-r>", lambda e: self.refresh_data_display())
|
||||
self.root.bind("<Control-R>", lambda e: self.refresh_data_display())
|
||||
self.root.bind("<F5>", lambda e: self.refresh_data_display())
|
||||
self.root.bind("<Control-m>", lambda e: self._open_medicine_manager())
|
||||
self.root.bind("<Control-M>", lambda e: self._open_medicine_manager())
|
||||
self.root.bind("<Control-p>", lambda e: self._open_pathology_manager())
|
||||
self.root.bind("<Control-P>", lambda e: self._open_pathology_manager())
|
||||
self.root.bind("<Control-f>", lambda e: self._toggle_search_filter())
|
||||
self.root.bind("<Control-F>", lambda e: self._toggle_search_filter())
|
||||
self.root.bind("<Delete>", lambda e: self._delete_selected_entry())
|
||||
self.root.bind("<Escape>", lambda e: self._clear_selection())
|
||||
self.root.bind("<F1>", lambda e: self._show_keyboard_shortcuts())
|
||||
self.root.bind("<F2>", lambda e: self._open_settings_window())
|
||||
bindings = [
|
||||
("<Control-s>", self.add_new_entry),
|
||||
("<Control-S>", self.add_new_entry),
|
||||
("<Control-q>", self.handle_window_closing),
|
||||
("<Control-Q>", self.handle_window_closing),
|
||||
("<Control-e>", self._open_export_window),
|
||||
("<Control-E>", self._open_export_window),
|
||||
("<Control-n>", self._clear_entries),
|
||||
("<Control-N>", self._clear_entries),
|
||||
("<Control-r>", self.refresh_data_display),
|
||||
("<Control-R>", self.refresh_data_display),
|
||||
("<F5>", self.refresh_data_display),
|
||||
("<Control-m>", self._open_medicine_manager),
|
||||
("<Control-M>", self._open_medicine_manager),
|
||||
("<Control-p>", self._open_pathology_manager),
|
||||
("<Control-P>", self._open_pathology_manager),
|
||||
("<Control-f>", self._toggle_search_filter),
|
||||
("<Control-F>", self._toggle_search_filter),
|
||||
("<Delete>", self._delete_selected_entry),
|
||||
("<Escape>", self._clear_selection),
|
||||
("<F1>", self._show_keyboard_shortcuts),
|
||||
("<F2>", self._open_settings_window),
|
||||
("<Control-z>", self._undo_last),
|
||||
("<Control-Z>", self._undo_last),
|
||||
("<Control-l>", self._open_logs_folder),
|
||||
("<Control-L>", self._open_logs_folder),
|
||||
("<Control-d>", self._open_data_folder),
|
||||
("<Control-D>", self._open_data_folder),
|
||||
("<Control-b>", self._open_backups_folder),
|
||||
("<Control-B>", self._open_backups_folder),
|
||||
("<Control-h>", self._open_documentation),
|
||||
("<Control-H>", self._open_documentation),
|
||||
("<Control-Shift-B>", self._create_manual_backup),
|
||||
("<Control-Shift-R>", self._restore_from_backup),
|
||||
("<Control-Shift-C>", self._open_config_folder),
|
||||
]
|
||||
for seq, func in bindings:
|
||||
self.root.bind(seq, lambda e, f=func: f())
|
||||
|
||||
# Make the window focusable so it can receive key events
|
||||
self.root.focus_set()
|
||||
|
||||
logger.info("Keyboard shortcuts configured:")
|
||||
logger.info(" Ctrl+S: Save/Add new entry")
|
||||
logger.info(" Ctrl+Q: Quit application")
|
||||
logger.info(" Ctrl+E: Export data")
|
||||
logger.info(" Ctrl+N: Clear entries")
|
||||
logger.info(" Ctrl+R/F5: Refresh data")
|
||||
logger.info(" Ctrl+M: Manage medicines")
|
||||
logger.info(" Ctrl+P: Manage pathologies")
|
||||
logger.info(" Ctrl+F: Toggle search/filter")
|
||||
logger.info(" Delete: Delete selected entry")
|
||||
logger.info(" Escape: Clear selection")
|
||||
logger.info(" F1: Show keyboard shortcuts help")
|
||||
for desc in [
|
||||
"Ctrl+S: Save/Add new entry",
|
||||
"Ctrl+Q: Quit application",
|
||||
"Ctrl+E: Export data",
|
||||
"Ctrl+N: Clear entries",
|
||||
"Ctrl+R/F5: Refresh data",
|
||||
"Ctrl+M: Manage medicines",
|
||||
"Ctrl+P: Manage pathologies",
|
||||
"Ctrl+F: Toggle search/filter",
|
||||
"Ctrl+L: Open logs folder",
|
||||
"Ctrl+D: Open data folder",
|
||||
"Ctrl+B: Open backups folder",
|
||||
"Ctrl+Shift+B: Create backup now",
|
||||
"Ctrl+Shift+R: Restore from backup...",
|
||||
"Ctrl+Shift+C: Open config folder",
|
||||
"Ctrl+H: Open documentation",
|
||||
"Delete: Delete selected entry",
|
||||
"Escape: Clear selection",
|
||||
"F1: Show keyboard shortcuts help",
|
||||
"Ctrl+Z: Undo last change",
|
||||
]:
|
||||
logger.info(" " + desc)
|
||||
|
||||
def _show_keyboard_shortcuts(self) -> None:
|
||||
"""Show a dialog with keyboard shortcuts information."""
|
||||
@@ -353,6 +586,8 @@ Data Management:
|
||||
• Ctrl+N: Clear entries
|
||||
• Ctrl+R / F5: Refresh data
|
||||
• Ctrl+F: Toggle search/filter
|
||||
• Ctrl+L: Open logs folder
|
||||
• Ctrl+D: Open data folder
|
||||
|
||||
Window Management:
|
||||
• Ctrl+M: Manage medicines
|
||||
@@ -362,21 +597,49 @@ Table Operations:
|
||||
• Delete: Delete selected entry
|
||||
• Escape: Clear selection
|
||||
• Double-click: Edit entry
|
||||
• Ctrl+Z: Undo last change
|
||||
|
||||
Help:
|
||||
• F1: Show this help dialog
|
||||
• F2: Open settings window"""
|
||||
• F2: Open settings window
|
||||
• Ctrl+H: Open documentation
|
||||
• Ctrl+Shift+B: Create backup now
|
||||
• Ctrl+Shift+R: Restore from backup...
|
||||
• Ctrl+Shift+C: Open config folder
|
||||
"""
|
||||
|
||||
messagebox.showinfo("Keyboard Shortcuts", shortcuts_text, parent=self.root)
|
||||
|
||||
def _open_documentation(self) -> None:
|
||||
"""Open the docs directory in your default file viewer or README in browser."""
|
||||
# Prefer docs/ directory; else open README.md
|
||||
docs_dir = os.path.join(os.getcwd(), "docs")
|
||||
target = (
|
||||
docs_dir
|
||||
if os.path.isdir(docs_dir)
|
||||
else os.path.join(os.getcwd(), "README.md")
|
||||
)
|
||||
try:
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{target}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(target) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{target}" >/dev/null 2>&1 &')
|
||||
self.ui_manager.update_status("Opened documentation", "success")
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Documentation opened", 1500)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open documentation: {e}")
|
||||
self.ui_manager.update_status("Failed to open documentation", "error")
|
||||
|
||||
def _change_theme(self, theme_name: str) -> None:
|
||||
"""Change the application theme."""
|
||||
if self.theme_manager.apply_theme(theme_name):
|
||||
self.ui_manager.update_status(
|
||||
f"Theme changed to: {theme_name.title()}", "info"
|
||||
)
|
||||
# Refresh the menu to update radio button selection
|
||||
self._setup_menu()
|
||||
self._setup_menu() # Refresh menu radio selection
|
||||
else:
|
||||
self.ui_manager.update_status(
|
||||
f"Failed to apply theme: {theme_name}", "error"
|
||||
@@ -402,6 +665,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
"""Open the export window."""
|
||||
self.ui_manager.update_status("Opening export window", "info")
|
||||
ExportWindow(self.root, self.export_manager)
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Export window opened", 1200)
|
||||
|
||||
def _open_pathology_manager(self) -> None:
|
||||
"""Open the pathology management window."""
|
||||
@@ -417,10 +682,106 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
self.root, self.medicine_manager, self._refresh_ui_after_config_change
|
||||
)
|
||||
|
||||
def _open_logs_folder(self) -> None:
|
||||
"""Open the application logs directory in the system file explorer."""
|
||||
path = LOG_PATH
|
||||
try:
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
# Cross-platform opener
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
# Linux
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
self.ui_manager.update_status("Opened logs folder", "success")
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Logs folder opened", 1500)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open logs folder: {e}")
|
||||
self.ui_manager.update_status("Failed to open logs folder", "error")
|
||||
|
||||
def _open_data_folder(self) -> None:
|
||||
"""Open the data file's directory in the system file explorer."""
|
||||
try:
|
||||
folder = os.path.dirname(os.path.abspath(self.filename)) or "."
|
||||
if not os.path.exists(folder):
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{folder}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(folder) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{folder}" >/dev/null 2>&1 &')
|
||||
self.ui_manager.update_status("Opened data folder", "success")
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Data folder opened", 1500)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open data folder: {e}")
|
||||
self.ui_manager.update_status("Failed to open data folder", "error")
|
||||
|
||||
def _open_settings_window(self) -> None:
|
||||
"""Open the settings window."""
|
||||
self.ui_manager.update_status("Opening settings window", "info")
|
||||
SettingsWindow(self.root, self.theme_manager, self.ui_manager)
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Settings opened", 1200)
|
||||
|
||||
def _open_config_folder(self) -> None:
|
||||
"""Open the application configuration folder in the file explorer."""
|
||||
try:
|
||||
path = get_config_dir()
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
self.ui_manager.update_status("Opened config folder", "success")
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Config folder opened", 1500)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open config folder: {e}")
|
||||
self.ui_manager.update_status("Failed to open config folder", "error")
|
||||
|
||||
def _create_manual_backup(self) -> None:
|
||||
"""Create a manual backup immediately."""
|
||||
try:
|
||||
self.ui_manager.update_status("Creating backup...", "info")
|
||||
self.backup_manager.create_backup("manual")
|
||||
# Optional cleanup to enforce retention policy
|
||||
if hasattr(self.backup_manager, "cleanup_old_backups"):
|
||||
self.backup_manager.cleanup_old_backups(keep_count=5)
|
||||
except Exception as e:
|
||||
logger.error(f"Manual backup failed: {e}")
|
||||
self.ui_manager.update_status("Manual backup failed", "error")
|
||||
|
||||
def _open_backups_folder(self) -> None:
|
||||
"""Open the backups directory in the system file explorer."""
|
||||
# Prefer the manager's directory if available
|
||||
path = getattr(self.backup_manager, "backup_directory", None)
|
||||
if not path:
|
||||
# Fallback to data file's directory
|
||||
path = os.path.dirname(os.path.abspath(self.filename)) or "."
|
||||
try:
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
self.ui_manager.update_status("Opened backups folder", "success")
|
||||
if hasattr(self.ui_manager, "show_toast"):
|
||||
self.ui_manager.show_toast("Backups folder opened", 1500)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open backups folder: {e}")
|
||||
self.ui_manager.update_status("Failed to open backups folder", "error")
|
||||
|
||||
def _refresh_ui_after_config_change(self) -> None:
|
||||
"""Refresh UI components after pathology or medicine configuration changes."""
|
||||
@@ -430,9 +791,15 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
|
||||
# Clear caches in optimized data manager
|
||||
if hasattr(self.data_manager, "_invalidate_cache"):
|
||||
self.data_manager._invalidate_cache()
|
||||
self.data_manager._headers_cache = None
|
||||
self.data_manager._dtype_cache = None
|
||||
# Use public structural invalidation method if available
|
||||
if hasattr(self.data_manager, "invalidate_structure"):
|
||||
self.data_manager.invalidate_structure()
|
||||
else:
|
||||
self.data_manager._invalidate_cache()
|
||||
if hasattr(self.data_manager, "_headers_cache"):
|
||||
self.data_manager._headers_cache = None
|
||||
if hasattr(self.data_manager, "_dtype_cache"):
|
||||
self.data_manager._dtype_cache = None
|
||||
|
||||
# Recreate the input frame with new pathologies and medicines
|
||||
self.input_frame.destroy()
|
||||
@@ -488,7 +855,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
):
|
||||
date: str = item_values[0]
|
||||
logger.debug(f"Deleting entry with date={date}")
|
||||
|
||||
# Capture row BEFORE deletion for undo
|
||||
deleted_row = self.data_manager.get_row(date)
|
||||
self.ui_manager.update_status("Deleting entry...", "info")
|
||||
if self.data_manager.delete_entry(date):
|
||||
self._mark_data_modified() # Mark for auto-save
|
||||
@@ -497,6 +865,25 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
"Success", "Entry deleted successfully!", parent=self.root
|
||||
)
|
||||
self.refresh_data_display()
|
||||
if deleted_row:
|
||||
|
||||
def _undo_del() -> None:
|
||||
import csv as _csv
|
||||
|
||||
existing = self.data_manager.load_data()
|
||||
if (
|
||||
not existing.empty
|
||||
and "date" in existing.columns
|
||||
and date in existing["date"].values
|
||||
):
|
||||
return # Already restored
|
||||
with open(self.filename, "a", newline="") as _f:
|
||||
_csv.writer(_f).writerow(deleted_row)
|
||||
if hasattr(self.data_manager, "_invalidate_cache"):
|
||||
self.data_manager._invalidate_cache()
|
||||
self.refresh_data_display()
|
||||
|
||||
self.undo_manager.push(UndoAction(f"Delete {date}", _undo_del))
|
||||
else:
|
||||
self.ui_manager.update_status("Failed to delete entry", "error")
|
||||
messagebox.showerror(
|
||||
@@ -624,6 +1011,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
values.append(note)
|
||||
|
||||
self.ui_manager.update_status("Saving changes...", "info")
|
||||
# Capture previous row BEFORE updating
|
||||
prev_row = self.data_manager.get_row(original_date)
|
||||
if self.data_manager.update_entry(original_date, values):
|
||||
self._mark_data_modified() # Mark for auto-save
|
||||
edit_win.destroy()
|
||||
@@ -633,6 +1022,22 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
)
|
||||
self._clear_entries()
|
||||
self.refresh_data_display()
|
||||
new_date = values[0]
|
||||
|
||||
def _undo_update() -> None:
|
||||
import csv as _csv
|
||||
|
||||
# Remove the updated (new) row
|
||||
self.data_manager.delete_entry(str(new_date))
|
||||
# Restore previous row
|
||||
if prev_row:
|
||||
with open(self.filename, "a", newline="") as _f:
|
||||
_csv.writer(_f).writerow(prev_row)
|
||||
if hasattr(self.data_manager, "_invalidate_cache"):
|
||||
self.data_manager._invalidate_cache()
|
||||
self.refresh_data_display()
|
||||
|
||||
self.undo_manager.push(UndoAction(f"Update {original_date}", _undo_update))
|
||||
else:
|
||||
# Check if it's a duplicate date issue
|
||||
df = self.data_manager.load_data()
|
||||
@@ -653,6 +1058,11 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
if messagebox.askokcancel(
|
||||
"Quit", "Do you want to quit the application?", parent=self.root
|
||||
):
|
||||
# Save window geometry if preference is enabled
|
||||
with contextlib.suppress(Exception):
|
||||
if get_pref("remember_window_geometry", True):
|
||||
set_pref("last_window_geometry", self.root.geometry())
|
||||
save_preferences()
|
||||
# Clean up auto-save and create final backup
|
||||
if hasattr(self, "auto_save_manager"):
|
||||
self.auto_save_manager.cleanup()
|
||||
@@ -686,7 +1096,19 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
|
||||
def _on_filter_update(self) -> None:
|
||||
"""Handle filter updates from the search widget."""
|
||||
self.refresh_data_display(apply_filters=True)
|
||||
# Debounce rapid filter changes to avoid repeated heavy refresh.
|
||||
if not hasattr(self, "_filter_debounce_id"):
|
||||
self._filter_debounce_id = None # type: ignore[attr-defined]
|
||||
|
||||
if self._filter_debounce_id is not None: # type: ignore[attr-defined]
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
self.root.after_cancel(self._filter_debounce_id) # type: ignore[attr-defined]
|
||||
# Schedule refresh after short delay
|
||||
self._filter_debounce_id = self.root.after( # type: ignore[attr-defined]
|
||||
250, lambda: self.refresh_data_display(apply_filters=True)
|
||||
)
|
||||
|
||||
def _mark_data_modified(self) -> None:
|
||||
"""Mark that data has been modified for auto-save."""
|
||||
@@ -807,6 +1229,13 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
)
|
||||
self._clear_entries()
|
||||
self.refresh_data_display()
|
||||
added_date = entry[0]
|
||||
|
||||
def _undo_add() -> None:
|
||||
self.data_manager.delete_entry(str(added_date))
|
||||
self.refresh_data_display()
|
||||
|
||||
self.undo_manager.push(UndoAction(f"Add {added_date}", _undo_add))
|
||||
else:
|
||||
# Check if it's a duplicate date by trying to load existing data
|
||||
df = self.data_manager.load_data()
|
||||
@@ -822,6 +1251,16 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
self.ui_manager.update_status("Failed to add entry", "error")
|
||||
messagebox.showerror("Error", "Failed to add entry", parent=self.root)
|
||||
|
||||
def _undo_last(self) -> None:
|
||||
"""Undo the last data modifying action."""
|
||||
result = self.undo_manager.undo()
|
||||
if result:
|
||||
self._mark_data_modified()
|
||||
self.refresh_data_display()
|
||||
self.ui_manager.update_status(f"Undid: {result}", "info")
|
||||
else:
|
||||
self.ui_manager.update_status("Nothing to undo", "warning")
|
||||
|
||||
def _delete_entry(self, edit_win: tk.Toplevel, item_id: str) -> None:
|
||||
"""Delete the selected entry from the CSV file."""
|
||||
logger.debug(f"Delete requested for item_id={item_id}")
|
||||
@@ -833,7 +1272,7 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
# Get the date of the entry to delete
|
||||
date: str = self.tree.item(item_id, "values")[0]
|
||||
logger.debug(f"Deleting entry with date={date}")
|
||||
|
||||
deleted_row = self.data_manager.get_row(date)
|
||||
self.ui_manager.update_status("Deleting entry...", "info")
|
||||
if self.data_manager.delete_entry(date):
|
||||
self._mark_data_modified() # Mark for auto-save
|
||||
@@ -843,6 +1282,25 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
"Success", "Entry deleted successfully!", parent=self.root
|
||||
)
|
||||
self.refresh_data_display()
|
||||
if deleted_row:
|
||||
|
||||
def _undo_del2() -> None:
|
||||
import csv as _csv
|
||||
|
||||
existing = self.data_manager.load_data()
|
||||
if (
|
||||
not existing.empty
|
||||
and "date" in existing.columns
|
||||
and date in existing["date"].values
|
||||
):
|
||||
return
|
||||
with open(self.filename, "a", newline="") as _f:
|
||||
_csv.writer(_f).writerow(deleted_row)
|
||||
if hasattr(self.data_manager, "_invalidate_cache"):
|
||||
self.data_manager._invalidate_cache()
|
||||
self.refresh_data_display()
|
||||
|
||||
self.undo_manager.push(UndoAction(f"Delete {date}", _undo_del2))
|
||||
else:
|
||||
self.ui_manager.update_status("Failed to delete entry", "error")
|
||||
messagebox.showerror("Error", "Failed to delete entry", parent=edit_win)
|
||||
@@ -863,7 +1321,9 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
|
||||
try:
|
||||
# Load data from the CSV file once
|
||||
df: pd.DataFrame = self.data_manager.load_data()
|
||||
# Use cached graph-ready data for plotting & base data for table
|
||||
df_full: pd.DataFrame = self.data_manager.load_data()
|
||||
df: pd.DataFrame = df_full
|
||||
original_df = df.copy() # Keep a copy for graph updates
|
||||
|
||||
# Apply filters if requested and filters are active
|
||||
@@ -877,7 +1337,14 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
self._update_tree_efficiently(df)
|
||||
|
||||
# Update the graph (always use unfiltered data for complete picture)
|
||||
self.graph_manager.update_graph(original_df)
|
||||
# Graph gets preprocessed, use dedicated cached transformation
|
||||
if hasattr(self.data_manager, "get_graph_ready_data"):
|
||||
graph_df = self.data_manager.get_graph_ready_data()
|
||||
self.graph_manager.update_graph(
|
||||
graph_df.reset_index().rename(columns={"date": "date"})
|
||||
)
|
||||
else:
|
||||
self.graph_manager.update_graph(original_df)
|
||||
|
||||
# Update status bar with file info
|
||||
total_entries = len(original_df) if apply_filters else len(df)
|
||||
@@ -929,42 +1396,30 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
|
||||
# Use update_idletasks to batch operations and reduce flickering
|
||||
try:
|
||||
# Clear existing data efficiently
|
||||
children = self.tree.get_children()
|
||||
if children:
|
||||
self.tree.delete(*children)
|
||||
|
||||
# Update the treeview with the data
|
||||
# Build display dataframe (strip dose columns) once
|
||||
if not df.empty:
|
||||
# Build display columns dynamically
|
||||
# (exclude dose columns for table view)
|
||||
display_columns = ["date"]
|
||||
|
||||
# Add pathology columns
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
display_columns.append(pathology_key)
|
||||
|
||||
# Add medicine columns (without dose columns)
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
display_columns.append(medicine_key)
|
||||
|
||||
display_columns.extend(self.pathology_manager.get_pathology_keys())
|
||||
display_columns.extend(self.medicine_manager.get_medicine_keys())
|
||||
display_columns.append("note")
|
||||
|
||||
# Filter to only the columns we want to display
|
||||
if all(col in df.columns for col in display_columns):
|
||||
display_df = df[display_columns]
|
||||
else:
|
||||
# Fallback - just use all columns
|
||||
display_df = df
|
||||
else:
|
||||
display_df = df
|
||||
|
||||
# Batch insert for better performance with alternating row colors
|
||||
# Use diff-based update if available
|
||||
if hasattr(self.ui_manager, "diff_update_tree"):
|
||||
self.ui_manager.diff_update_tree(self.tree, display_df)
|
||||
else:
|
||||
children = self.tree.get_children()
|
||||
if children:
|
||||
self.tree.delete(*children)
|
||||
for index, row in display_df.iterrows():
|
||||
# Add alternating row tags for better visibility
|
||||
tag = "evenrow" if index % 2 == 0 else "oddrow"
|
||||
self.tree.insert(
|
||||
parent="", index="end", values=list(row), tags=(tag,)
|
||||
)
|
||||
logger.debug(f"Loaded {len(display_df)} entries into treeview.")
|
||||
self.tree.insert("", "end", values=list(row), tags=(tag,))
|
||||
logger.debug(f"Loaded {len(display_df)} entries into treeview.")
|
||||
|
||||
# Process pending events to update display
|
||||
self.root.update_idletasks()
|
||||
|
||||
Reference in New Issue
Block a user