diff --git a/README.md b/README.md index 1a9b776..5d29458 100644 --- a/README.md +++ b/README.md @@ -926,6 +926,7 @@ python3 photo_tagger.py identify --show-faces --batch 10 # Opens GUI python3 photo_tagger.py auto-match --show-faces # Opens GUI python3 photo_tagger.py search-gui # Opens Search GUI python3 photo_tagger.py modifyidentified # Opens GUI to view/modify +python3 photo_tagger.py dashboard # Opens Dashboard (placeholders) python3 photo_tagger.py tag-manager # Opens GUI for tag management python3 photo_tagger.py stats ``` \ No newline at end of file diff --git a/dashboard_gui.py b/dashboard_gui.py new file mode 100644 index 0000000..4772b8b --- /dev/null +++ b/dashboard_gui.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Dashboard GUI for PunimTag features +""" + +import os +import threading +import tkinter as tk +from tkinter import ttk, messagebox + +from gui_core import GUICore + + +class DashboardGUI: + """Dashboard with launchers for core features. + + on_scan: callable taking (folder_path: str, recursive: bool) -> int + """ + + def __init__(self, gui_core: GUICore, on_scan=None, on_process=None, on_identify=None): + self.gui_core = gui_core + self.on_scan = on_scan + self.on_process = on_process + self.on_identify = on_identify + + def open(self) -> int: + root = tk.Tk() + root.title("PunimTag Dashboard") + root.resizable(True, True) + root.withdraw() + + main = ttk.Frame(root, padding="16") + main.pack(fill=tk.BOTH, expand=True) + + title = ttk.Label(main, text="PunimTag Dashboard", font=("Arial", 16, "bold")) + title.pack(anchor="w", pady=(0, 12)) + + desc = ttk.Label( + main, + text=( + "Launch core features. Buttons are placeholders for now; " + "no actions are executed yet." + ), + foreground="#555", + ) + desc.pack(anchor="w", pady=(0, 16)) + + grid = ttk.Frame(main) + grid.pack(fill=tk.BOTH, expand=True) + + # Helper to create a feature card + def add_card(row: int, col: int, title_text: str, subtitle: str, button_text: str): + card = ttk.Frame(grid, padding="12", relief=tk.RIDGE) + card.grid(row=row, column=col, padx=8, pady=8, sticky="nsew") + ttk.Label(card, text=title_text, font=("Arial", 12, "bold")).pack(anchor="w") + ttk.Label(card, text=subtitle, foreground="#666").pack(anchor="w", pady=(2, 8)) + # Placeholder button (no-op) + ttk.Button(card, text=button_text, command=lambda: None).pack(anchor="w") + + # Layout configuration for responsive grid + for i in range(3): + grid.columnconfigure(i, weight=1) + for i in range(3): + grid.rowconfigure(i, weight=1) + + # Cards + # Custom Scan card with folder entry and recursive checkbox + scan_card = ttk.Frame(grid, padding="12", relief=tk.RIDGE) + scan_card.grid(row=0, column=0, padx=8, pady=8, sticky="nsew") + ttk.Label(scan_card, text="Scan", font=("Arial", 12, "bold")).pack(anchor="w") + ttk.Label(scan_card, text="Scan folders and add photos", foreground="#666").pack(anchor="w", pady=(2, 8)) + # Folder input + folder_row = ttk.Frame(scan_card) + folder_row.pack(fill=tk.X, pady=(0, 6)) + ttk.Label(folder_row, text="Folder:").pack(side=tk.LEFT) + folder_var = tk.StringVar() + folder_entry = ttk.Entry(folder_row, textvariable=folder_var) + folder_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) + # Recursive checkbox + recursive_var = tk.BooleanVar(value=True) + ttk.Checkbutton( + scan_card, + text="include photos in sub-folders", + variable=recursive_var + ).pack(anchor="w", pady=(0, 8)) + # Action button + scan_btn = ttk.Button(scan_card, text="Add new photos") + scan_btn.pack(anchor="w") + + def run_scan(): + folder = folder_var.get().strip() + recursive = bool(recursive_var.get()) + if not folder: + messagebox.showwarning("Scan", "Please enter a folder path.", parent=root) + return + if not os.path.isdir(folder): + messagebox.showerror("Scan", "Folder does not exist.", parent=root) + return + if not callable(self.on_scan): + messagebox.showinfo("Scan", "Scan is not wired yet.", parent=root) + return + + def worker(): + try: + scan_btn.config(state=tk.DISABLED) + result = self.on_scan(folder, recursive) + messagebox.showinfo("Scan", f"Scan completed. Result: {result}", parent=root) + except Exception as e: + messagebox.showerror("Scan", f"Error during scan: {e}", parent=root) + finally: + try: + scan_btn.config(state=tk.NORMAL) + except Exception: + pass + + threading.Thread(target=worker, daemon=True).start() + + scan_btn.config(command=run_scan) + + # Other cards remain generic + # Custom Process card with optional limit + process_card = ttk.Frame(grid, padding="12", relief=tk.RIDGE) + process_card.grid(row=0, column=1, padx=8, pady=8, sticky="nsew") + ttk.Label(process_card, text="Process", font=("Arial", 12, "bold")).pack(anchor="w") + ttk.Label(process_card, text="Detect faces in photos", foreground="#666").pack(anchor="w", pady=(2, 8)) + # Limit controls + limit_row = ttk.Frame(process_card) + limit_row.pack(fill=tk.X, pady=(0, 8)) + limit_enabled = tk.BooleanVar(value=False) + ttk.Checkbutton(limit_row, text="limit to", variable=limit_enabled).pack(side=tk.LEFT) + limit_var = tk.StringVar(value="50") + limit_entry = ttk.Entry(limit_row, textvariable=limit_var, width=8) + limit_entry.pack(side=tk.LEFT, padx=(6, 0)) + # Action button + process_btn = ttk.Button(process_card, text="Process photos") + process_btn.pack(anchor="w") + + def run_process(): + if not callable(self.on_process): + messagebox.showinfo("Process", "Process is not wired yet.", parent=root) + return + limit_value = None + if limit_enabled.get(): + try: + limit_value = int(limit_var.get().strip()) + if limit_value <= 0: + raise ValueError + except Exception: + messagebox.showerror("Process", "Please enter a valid positive integer for limit.", parent=root) + return + + def worker(): + try: + process_btn.config(state=tk.DISABLED) + result = self.on_process(limit_value) + messagebox.showinfo("Process", f"Processing completed. Result: {result}", parent=root) + except Exception as e: + messagebox.showerror("Process", f"Error during processing: {e}", parent=root) + finally: + try: + process_btn.config(state=tk.NORMAL) + except Exception: + pass + + threading.Thread(target=worker, daemon=True).start() + + process_btn.config(command=run_process) + # Custom Identify card with show faces and batch options + identify_card = ttk.Frame(grid, padding="12", relief=tk.RIDGE) + identify_card.grid(row=0, column=2, padx=8, pady=8, sticky="nsew") + ttk.Label(identify_card, text="Identify", font=("Arial", 12, "bold")).pack(anchor="w") + ttk.Label(identify_card, text="Identify faces (GUI)", foreground="#666").pack(anchor="w", pady=(2, 8)) + # Show faces checkbox + show_faces_var = tk.BooleanVar(value=False) + ttk.Checkbutton(identify_card, text="show faces", variable=show_faces_var).pack(anchor="w", pady=(0, 4)) + # Batch controls + batch_row = ttk.Frame(identify_card) + batch_row.pack(fill=tk.X, pady=(0, 8)) + batch_enabled = tk.BooleanVar(value=True) + ttk.Checkbutton(batch_row, text="batch", variable=batch_enabled).pack(side=tk.LEFT) + batch_var = tk.StringVar(value="10") + batch_entry = ttk.Entry(batch_row, textvariable=batch_var, width=8) + batch_entry.pack(side=tk.LEFT, padx=(6, 0)) + # Action button + identify_btn = ttk.Button(identify_card, text="Identify faces") + identify_btn.pack(anchor="w") + + def run_identify(): + if not callable(self.on_identify): + messagebox.showinfo("Identify", "Identify is not wired yet.", parent=root) + return + batch_value = None + if batch_enabled.get(): + try: + batch_value = int(batch_var.get().strip()) + if batch_value <= 0: + raise ValueError + except Exception: + messagebox.showerror("Identify", "Please enter a valid positive integer for batch.", parent=root) + return + + def worker(): + try: + identify_btn.config(state=tk.DISABLED) + result = self.on_identify(batch_value, show_faces_var.get()) + messagebox.showinfo("Identify", f"Identification completed. Result: {result}", parent=root) + except Exception as e: + messagebox.showerror("Identify", f"Error during identification: {e}", parent=root) + finally: + try: + identify_btn.config(state=tk.NORMAL) + except Exception: + pass + + threading.Thread(target=worker, daemon=True).start() + + identify_btn.config(command=run_identify) + add_card(1, 0, "Auto-Match", "Find & confirm matches (GUI)", "Open Auto-Match") + add_card(1, 1, "Search", "Find photos by name (GUI)", "Open Search") + add_card(1, 2, "Modify Identified", "View/modify identified faces (GUI)", "Open Modify") + add_card(2, 0, "Tag Manager", "Manage tags and photos (GUI)", "Open Tag Manager") + + # Close button + footer = ttk.Frame(main) + footer.pack(fill=tk.X, pady=(12, 0)) + ttk.Button(footer, text="Close", command=lambda: root.destroy()).pack(side=tk.RIGHT) + + root.update_idletasks() + self.gui_core.center_window(root, 900, 600) + root.deiconify() + root.mainloop() + return 0 + + diff --git a/database.py b/database.py index f58cc71..ff3e400 100644 --- a/database.py +++ b/database.py @@ -26,7 +26,7 @@ class DatabaseManager: """Context manager for database connections with connection pooling""" with self._db_lock: if self._db_connection is None: - self._db_connection = sqlite3.connect(self.db_path, timeout=DB_TIMEOUT) + self._db_connection = sqlite3.connect(self.db_path, timeout=DB_TIMEOUT, check_same_thread=False) self._db_connection.row_factory = sqlite3.Row try: yield self._db_connection diff --git a/photo_tagger.py b/photo_tagger.py index 37336c5..4d0ca3c 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -26,6 +26,7 @@ from auto_match_gui import AutoMatchGUI from modify_identified_gui import ModifyIdentifiedGUI from tag_manager_gui import TagManagerGUI from search_gui import SearchGUI +from dashboard_gui import DashboardGUI class PhotoTagger: @@ -49,6 +50,7 @@ class PhotoTagger: self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose) self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose) self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, verbose) + self.dashboard_gui = DashboardGUI(self.gui_core, on_scan=self._dashboard_scan, on_process=self._dashboard_process, on_identify=self._dashboard_identify) # Legacy compatibility - expose some methods directly self._db_connection = None @@ -193,6 +195,27 @@ class PhotoTagger: def searchgui(self) -> int: """Open the Search GUI.""" return self.search_gui.search_gui() + + def dashboard(self) -> int: + """Open the Dashboard GUI (placeholders only).""" + return self.dashboard_gui.open() + + # Dashboard callbacks + def _dashboard_scan(self, folder_path: str, recursive: bool) -> int: + """Callback to scan a folder from the dashboard.""" + return self.scan_folder(folder_path, recursive) + + def _dashboard_process(self, limit_value: Optional[int]) -> int: + """Callback to process faces from the dashboard with optional limit.""" + if limit_value is None: + return self.process_faces() + return self.process_faces(limit=limit_value) + + def _dashboard_identify(self, batch_value: Optional[int], show_faces: bool) -> int: + """Callback to identify faces from the dashboard with optional batch and show_faces.""" + if batch_value is None: + return self.identify_faces(show_faces=show_faces) + return self.identify_faces(batch_size=batch_value, show_faces=show_faces) def _setup_window_size_saving(self, root, config_file="gui_config.json"): """Set up window size saving functionality (legacy compatibility)""" @@ -291,7 +314,7 @@ Examples: ) parser.add_argument('command', - choices=['scan', 'process', 'identify', 'tag', 'search', 'search-gui', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'], + choices=['scan', 'process', 'identify', 'tag', 'search', 'search-gui', 'dashboard', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'], help='Command to execute') parser.add_argument('target', nargs='?', @@ -378,6 +401,9 @@ Examples: elif args.command == 'search-gui': tagger.searchgui() + elif args.command == 'dashboard': + tagger.dashboard() + elif args.command == 'stats': tagger.stats()