2061 lines
100 KiB
Python
2061 lines
100 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Unified Dashboard GUI for PunimTag features
|
|
Designed with web migration in mind - single window with menu bar and content area
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
import time
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
from typing import Dict, Optional, Callable
|
|
|
|
from gui_core import GUICore
|
|
from identify_panel import IdentifyPanel
|
|
from modify_panel import ModifyPanel
|
|
from auto_match_panel import AutoMatchPanel
|
|
from tag_manager_panel import TagManagerPanel
|
|
from search_stats import SearchStats
|
|
from database import DatabaseManager
|
|
from tag_management import TagManager
|
|
from face_processing import FaceProcessor
|
|
class SearchPanel:
|
|
"""Search panel with full functionality from search_gui.py"""
|
|
|
|
SEARCH_TYPES = [
|
|
"Search photos by name",
|
|
"Search photos by date",
|
|
"Search photos by tags",
|
|
"Search photos by multiple people (planned)",
|
|
"Most common tags (planned)",
|
|
"Most photographed people (planned)",
|
|
"Photos without faces",
|
|
"Photos without tags",
|
|
"Duplicate faces (planned)",
|
|
"Face quality distribution (planned)",
|
|
]
|
|
|
|
def __init__(self, parent_frame, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, tag_manager: TagManager = None, verbose: int = 0):
|
|
self.parent_frame = parent_frame
|
|
self.db = db_manager
|
|
self.search_stats = search_stats
|
|
self.gui_core = gui_core
|
|
self.tag_manager = tag_manager or TagManager(db_manager, verbose)
|
|
self.verbose = verbose
|
|
|
|
# Sorting state
|
|
self.sort_column = None
|
|
self.sort_reverse = False
|
|
|
|
# Selection tracking
|
|
self.selected_photos = {} # photo_path -> photo_data
|
|
|
|
# Cache for photo tags to avoid database access during updates
|
|
self.photo_tags_cache = {} # photo_path -> list of tag names
|
|
|
|
def create_panel(self) -> ttk.Frame:
|
|
"""Create the search panel with all functionality"""
|
|
panel = ttk.Frame(self.parent_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Configure rows: results area (row 3) should expand, buttons (row 4) should not
|
|
panel.rowconfigure(3, weight=1)
|
|
panel.rowconfigure(4, weight=0)
|
|
|
|
# Search type selector
|
|
type_frame = ttk.Frame(panel)
|
|
type_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
type_frame.columnconfigure(1, weight=1)
|
|
|
|
ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky=tk.W)
|
|
self.search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0])
|
|
type_combo = ttk.Combobox(type_frame, textvariable=self.search_type_var, values=self.SEARCH_TYPES, state="readonly")
|
|
type_combo.grid(row=0, column=1, padx=(8, 0), sticky=(tk.W, tk.E))
|
|
|
|
# Filters area with expand/collapse functionality
|
|
filters_container = ttk.LabelFrame(panel, text="", padding="8")
|
|
filters_container.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
filters_container.columnconfigure(0, weight=1)
|
|
|
|
# Filters header with toggle text
|
|
filters_header = ttk.Frame(filters_container)
|
|
filters_header.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
|
|
# Toggle text for expand/collapse
|
|
self.filters_expanded = tk.BooleanVar(value=False) # Start collapsed
|
|
|
|
def toggle_filters():
|
|
if self.filters_expanded.get():
|
|
# Collapse filters
|
|
filters_content.grid_remove()
|
|
toggle_text.config(text="+")
|
|
self.filters_expanded.set(False)
|
|
update_toggle_tooltip()
|
|
else:
|
|
# Expand filters
|
|
filters_content.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0))
|
|
toggle_text.config(text="-")
|
|
self.filters_expanded.set(True)
|
|
update_toggle_tooltip()
|
|
|
|
def update_toggle_tooltip():
|
|
"""Update tooltip text based on current state"""
|
|
if self.filters_expanded.get():
|
|
tooltip_text = "Click to collapse filters"
|
|
else:
|
|
tooltip_text = "Click to expand filters"
|
|
toggle_text.tooltip_text = tooltip_text
|
|
|
|
filters_label = ttk.Label(filters_header, text="Filters", font=("Arial", 10, "bold"))
|
|
filters_label.grid(row=0, column=0, sticky=tk.W)
|
|
|
|
toggle_text = ttk.Label(filters_header, text="+", font=("Arial", 10, "bold"), cursor="hand2")
|
|
toggle_text.grid(row=0, column=1, padx=(6, 0))
|
|
toggle_text.bind("<Button-1>", lambda e: toggle_filters())
|
|
|
|
# Initialize tooltip
|
|
toggle_text.tooltip_text = "Click to expand filters"
|
|
update_toggle_tooltip()
|
|
|
|
# Filters content area (start hidden)
|
|
filters_content = ttk.Frame(filters_container)
|
|
filters_content.columnconfigure(0, weight=1)
|
|
|
|
# Folder location filter
|
|
folder_filter_frame = ttk.Frame(filters_content)
|
|
folder_filter_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 4))
|
|
folder_filter_frame.columnconfigure(1, weight=1)
|
|
|
|
ttk.Label(folder_filter_frame, text="Folder location:").grid(row=0, column=0, sticky=tk.W)
|
|
self.folder_var = tk.StringVar()
|
|
folder_entry = ttk.Entry(folder_filter_frame, textvariable=self.folder_var, width=40)
|
|
folder_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E))
|
|
ttk.Label(folder_filter_frame, text="(optional - filter by folder path)").grid(row=0, column=2, padx=(6, 0))
|
|
|
|
# Browse button for folder selection
|
|
def browse_folder():
|
|
from tkinter import filedialog
|
|
from path_utils import normalize_path
|
|
folder_path = filedialog.askdirectory(title="Select folder to filter by")
|
|
if folder_path:
|
|
try:
|
|
# Normalize to absolute path
|
|
normalized_path = normalize_path(folder_path)
|
|
self.folder_var.set(normalized_path)
|
|
except ValueError as e:
|
|
messagebox.showerror("Invalid Path", f"Invalid folder path: {e}", parent=panel)
|
|
|
|
browse_btn = ttk.Button(folder_filter_frame, text="Browse", command=browse_folder)
|
|
browse_btn.grid(row=0, column=3, padx=(6, 0))
|
|
|
|
# Clear folder filter button
|
|
def clear_folder_filter():
|
|
self.folder_var.set("")
|
|
|
|
clear_folder_btn = ttk.Button(folder_filter_frame, text="Clear", command=clear_folder_filter)
|
|
clear_folder_btn.grid(row=0, column=4, padx=(6, 0))
|
|
|
|
# Apply filters button
|
|
apply_filters_btn = ttk.Button(filters_content, text="Apply filters", command=lambda: self.do_search())
|
|
apply_filters_btn.grid(row=1, column=0, pady=(8, 0))
|
|
|
|
# Inputs area
|
|
inputs = ttk.Frame(panel)
|
|
inputs.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
inputs.columnconfigure(0, weight=1)
|
|
|
|
# Name search input
|
|
self.name_frame = ttk.Frame(inputs)
|
|
ttk.Label(self.name_frame, text="Person name:").grid(row=0, column=0, sticky=tk.W)
|
|
self.name_var = tk.StringVar()
|
|
self.name_entry = ttk.Entry(self.name_frame, textvariable=self.name_var)
|
|
self.name_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E))
|
|
self.name_frame.columnconfigure(1, weight=1)
|
|
|
|
# Tag search input
|
|
self.tag_frame = ttk.Frame(inputs)
|
|
ttk.Label(self.tag_frame, text="Tags:").grid(row=0, column=0, sticky=tk.W)
|
|
self.tag_var = tk.StringVar()
|
|
self.tag_entry = ttk.Entry(self.tag_frame, textvariable=self.tag_var)
|
|
self.tag_entry.grid(row=0, column=1, padx=(6, 0), sticky=(tk.W, tk.E))
|
|
self.tag_frame.columnconfigure(1, weight=1)
|
|
|
|
# Help icon for available tags
|
|
self.tag_help_icon = ttk.Label(self.tag_frame, text="❓", font=("Arial", 10), cursor="hand2")
|
|
self.tag_help_icon.grid(row=0, column=2, padx=(6, 0))
|
|
|
|
ttk.Label(self.tag_frame, text="(comma-separated)").grid(row=0, column=3, padx=(6, 0))
|
|
|
|
# Tag search mode
|
|
self.tag_mode_frame = ttk.Frame(inputs)
|
|
ttk.Label(self.tag_mode_frame, text="Match mode:").grid(row=0, column=0, sticky=tk.W)
|
|
self.tag_mode_var = tk.StringVar(value="ANY")
|
|
self.tag_mode_combo = ttk.Combobox(self.tag_mode_frame, textvariable=self.tag_mode_var,
|
|
values=["ANY", "ALL"], state="readonly", width=8)
|
|
self.tag_mode_combo.grid(row=0, column=1, padx=(6, 0))
|
|
ttk.Label(self.tag_mode_frame, text="(ANY = photos with any tag, ALL = photos with all tags)").grid(row=0, column=2, padx=(6, 0))
|
|
|
|
# Date search inputs
|
|
self.date_frame = ttk.Frame(inputs)
|
|
ttk.Label(self.date_frame, text="From date:").grid(row=0, column=0, sticky=tk.W)
|
|
self.date_from_var = tk.StringVar()
|
|
self.date_from_entry = ttk.Entry(self.date_frame, textvariable=self.date_from_var, width=12, state="readonly")
|
|
self.date_from_entry.grid(row=0, column=1, padx=(6, 0))
|
|
|
|
# Calendar button for date from
|
|
def open_calendar_from():
|
|
current_date = self.date_from_var.get()
|
|
selected_date = self.gui_core.create_calendar_dialog(panel, "Select From Date", current_date)
|
|
if selected_date is not None:
|
|
self.date_from_var.set(selected_date)
|
|
|
|
self.date_from_btn = ttk.Button(self.date_frame, text="📅", width=3, command=open_calendar_from)
|
|
self.date_from_btn.grid(row=0, column=2, padx=(6, 0))
|
|
ttk.Label(self.date_frame, text="(YYYY-MM-DD)").grid(row=0, column=3, padx=(6, 0))
|
|
|
|
self.date_to_frame = ttk.Frame(inputs)
|
|
ttk.Label(self.date_to_frame, text="To date:").grid(row=0, column=0, sticky=tk.W)
|
|
self.date_to_var = tk.StringVar()
|
|
self.date_to_entry = ttk.Entry(self.date_to_frame, textvariable=self.date_to_var, width=12, state="readonly")
|
|
self.date_to_entry.grid(row=0, column=1, padx=(6, 0))
|
|
|
|
# Calendar button for date to
|
|
def open_calendar_to():
|
|
current_date = self.date_to_var.get()
|
|
selected_date = self.gui_core.create_calendar_dialog(panel, "Select To Date", current_date)
|
|
if selected_date is not None:
|
|
self.date_to_var.set(selected_date)
|
|
|
|
self.date_to_btn = ttk.Button(self.date_to_frame, text="📅", width=3, command=open_calendar_to)
|
|
self.date_to_btn.grid(row=0, column=2, padx=(6, 0))
|
|
ttk.Label(self.date_to_frame, text="(YYYY-MM-DD, optional)").grid(row=0, column=3, padx=(6, 0))
|
|
|
|
# Planned inputs (stubs)
|
|
self.planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888")
|
|
|
|
# Results area
|
|
results_frame = ttk.Frame(panel)
|
|
results_frame.grid(row=3, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10))
|
|
results_frame.columnconfigure(0, weight=1)
|
|
results_frame.rowconfigure(1, weight=1)
|
|
|
|
# Results header with count
|
|
results_header = ttk.Frame(results_frame)
|
|
results_header.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
results_label = ttk.Label(results_header, text="Results:", font=("Arial", 10, "bold"))
|
|
results_label.grid(row=0, column=0, sticky=tk.W)
|
|
self.results_count_label = ttk.Label(results_header, text="(0 items)", font=("Arial", 10), foreground="gray")
|
|
self.results_count_label.grid(row=0, column=1, padx=(6, 0))
|
|
|
|
columns = ("select", "person", "tags", "processed", "open_dir", "open_photo", "path", "date_taken")
|
|
self.tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse")
|
|
self.tree.heading("select", text="☑")
|
|
self.tree.heading("person", text="Person", command=lambda: self.sort_treeview("person"))
|
|
self.tree.heading("tags", text="Tags", command=lambda: self.sort_treeview("tags"))
|
|
self.tree.heading("processed", text="Processed", command=lambda: self.sort_treeview("processed"))
|
|
self.tree.heading("open_dir", text="📁")
|
|
self.tree.heading("open_photo", text="👤")
|
|
self.tree.heading("path", text="Photo path", command=lambda: self.sort_treeview("path"))
|
|
self.tree.heading("date_taken", text="Date Taken", command=lambda: self.sort_treeview("date_taken"))
|
|
self.tree.column("select", width=50, anchor="center")
|
|
self.tree.column("person", width=180, anchor="w")
|
|
self.tree.column("tags", width=200, anchor="w")
|
|
self.tree.column("processed", width=80, anchor="center")
|
|
self.tree.column("open_dir", width=50, anchor="center")
|
|
self.tree.column("open_photo", width=50, anchor="center")
|
|
self.tree.column("path", width=400, anchor="w")
|
|
self.tree.column("date_taken", width=100, anchor="center")
|
|
|
|
# Add vertical scrollbar for the treeview
|
|
tree_v_scrollbar = ttk.Scrollbar(results_frame, orient="vertical", command=self.tree.yview)
|
|
self.tree.configure(yscrollcommand=tree_v_scrollbar.set)
|
|
|
|
# Pack treeview and scrollbar
|
|
self.tree.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(4, 0))
|
|
tree_v_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S), pady=(4, 0))
|
|
|
|
# Buttons
|
|
btns = ttk.Frame(panel)
|
|
btns.grid(row=4, column=0, sticky=(tk.W, tk.E), pady=(8, 0))
|
|
search_btn = ttk.Button(btns, text="Search", command=lambda: self.do_search())
|
|
search_btn.grid(row=0, column=0, sticky=tk.W)
|
|
tag_btn = ttk.Button(btns, text="Tag selected photos", command=lambda: self.tag_selected_photos())
|
|
tag_btn.grid(row=0, column=1, padx=(6, 0), sticky=tk.W)
|
|
clear_btn = ttk.Button(btns, text="Clear all selected", command=lambda: self.clear_all_selected())
|
|
clear_btn.grid(row=0, column=2, padx=(6, 0), sticky=tk.W)
|
|
|
|
# Set up event handlers
|
|
type_combo.bind("<<ComboboxSelected>>", self.switch_inputs)
|
|
self.switch_inputs()
|
|
self.tree.bind("<Button-1>", self.on_tree_click)
|
|
self.tree.bind("<Motion>", self.on_tree_motion)
|
|
self.tree.bind("<Leave>", self.hide_tooltip)
|
|
|
|
# Enter key bindings
|
|
self.name_entry.bind("<Return>", lambda e: self.do_search())
|
|
self.tag_entry.bind("<Return>", lambda e: self.do_search())
|
|
folder_entry.bind("<Return>", lambda e: self.do_search())
|
|
|
|
# Initialize tooltip system
|
|
self.tooltip = None
|
|
|
|
# Set up help icon tooltip
|
|
self._setup_help_icon_tooltip()
|
|
|
|
return panel
|
|
|
|
def _setup_help_icon_tooltip(self):
|
|
"""Set up tooltip for the help icon"""
|
|
def show_available_tags_tooltip(event):
|
|
# Get all available tags from database
|
|
try:
|
|
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
|
available_tags = sorted(tag_name_to_id.keys())
|
|
|
|
if available_tags:
|
|
# Create tooltip with tags in a column format
|
|
tag_list = "\n".join(available_tags)
|
|
tooltip_text = f"Available tags:\n{tag_list}"
|
|
else:
|
|
tooltip_text = "No tags available in database"
|
|
|
|
self.show_tooltip(self.tag_help_icon, event.x_root, event.y_root, tooltip_text)
|
|
except Exception:
|
|
self.show_tooltip(self.tag_help_icon, event.x_root, event.y_root, "Error loading tags")
|
|
|
|
# Bind tooltip events to help icon
|
|
self.tag_help_icon.bind("<Enter>", show_available_tags_tooltip)
|
|
self.tag_help_icon.bind("<Leave>", self.hide_tooltip)
|
|
|
|
def switch_inputs(self, *_):
|
|
"""Switch input fields based on search type"""
|
|
# Clear results when search type changes
|
|
self.clear_results()
|
|
|
|
for w in self.name_frame.master.winfo_children():
|
|
if w != self.name_frame.master: # Don't hide the inputs frame itself
|
|
w.grid_remove()
|
|
|
|
choice = self.search_type_var.get()
|
|
if choice == self.SEARCH_TYPES[0]: # Search photos by name
|
|
self.name_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
self.name_entry.configure(state="normal")
|
|
self.tag_entry.configure(state="disabled")
|
|
self.tag_mode_combo.configure(state="disabled")
|
|
self.date_from_entry.configure(state="disabled")
|
|
self.date_to_entry.configure(state="disabled")
|
|
self.date_from_btn.configure(state="disabled")
|
|
self.date_to_btn.configure(state="disabled")
|
|
# Show person column for name search
|
|
self.tree.column("person", width=180, minwidth=50, anchor="w")
|
|
self.tree.heading("person", text="Person", command=lambda: self.sort_treeview("person"))
|
|
# Restore people icon column for name search
|
|
self.tree.column("open_photo", width=50, minwidth=50, anchor="center")
|
|
self.tree.heading("open_photo", text="👤")
|
|
# Restore all columns to display (hide processed column for name search)
|
|
self.tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken")
|
|
elif choice == self.SEARCH_TYPES[1]: # Search photos by date
|
|
self.date_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
self.date_to_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0))
|
|
self.name_entry.configure(state="disabled")
|
|
self.tag_entry.configure(state="disabled")
|
|
self.tag_mode_combo.configure(state="disabled")
|
|
self.date_from_entry.configure(state="readonly")
|
|
self.date_to_entry.configure(state="readonly")
|
|
self.date_from_btn.configure(state="normal")
|
|
self.date_to_btn.configure(state="normal")
|
|
# Hide person column for date search
|
|
self.tree.column("person", width=0, minwidth=0, anchor="w")
|
|
self.tree.heading("person", text="")
|
|
# Restore people icon column for date search
|
|
self.tree.column("open_photo", width=50, minwidth=50, anchor="center")
|
|
self.tree.heading("open_photo", text="👤")
|
|
# Show all columns except person for date search
|
|
self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken")
|
|
elif choice == self.SEARCH_TYPES[2]: # Search photos by tags
|
|
self.tag_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
self.tag_mode_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(4, 0))
|
|
self.name_entry.configure(state="disabled")
|
|
self.tag_entry.configure(state="normal")
|
|
self.tag_mode_combo.configure(state="readonly")
|
|
self.date_from_entry.configure(state="disabled")
|
|
self.date_to_entry.configure(state="disabled")
|
|
self.date_from_btn.configure(state="disabled")
|
|
self.date_to_btn.configure(state="disabled")
|
|
# Hide person column completely for tag search
|
|
self.tree.column("person", width=0, minwidth=0, anchor="w")
|
|
self.tree.heading("person", text="")
|
|
# Restore people icon column for tag search
|
|
self.tree.column("open_photo", width=50, minwidth=50, anchor="center")
|
|
self.tree.heading("open_photo", text="👤")
|
|
# Also hide the column from display
|
|
self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken")
|
|
elif choice == self.SEARCH_TYPES[6]: # Photos without faces
|
|
# No input needed for this search type
|
|
# Hide person column since photos without faces won't have person info
|
|
self.tree.column("person", width=0, minwidth=0, anchor="w")
|
|
self.tree.heading("person", text="")
|
|
# Hide the people icon column since there are no faces/people
|
|
self.tree.column("open_photo", width=0, minwidth=0, anchor="center")
|
|
self.tree.heading("open_photo", text="")
|
|
# Also hide the columns from display
|
|
self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "path", "date_taken")
|
|
# Auto-run search for photos without faces
|
|
self.do_search()
|
|
elif choice == self.SEARCH_TYPES[7]: # Photos without tags
|
|
# No input needed for this search type
|
|
# Hide person column for photos without tags search
|
|
self.tree.column("person", width=0, minwidth=0, anchor="w")
|
|
self.tree.heading("person", text="")
|
|
# Show the people icon column since there might be faces/people
|
|
self.tree.column("open_photo", width=50, minwidth=50, anchor="center")
|
|
self.tree.heading("open_photo", text="👤")
|
|
# Show all columns except person for photos without tags search
|
|
self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken")
|
|
# Auto-run search for photos without tags
|
|
self.do_search()
|
|
else:
|
|
self.planned_label.grid(row=0, column=0, sticky=tk.W)
|
|
self.name_entry.configure(state="disabled")
|
|
self.tag_entry.configure(state="disabled")
|
|
self.tag_mode_combo.configure(state="disabled")
|
|
self.date_from_entry.configure(state="disabled")
|
|
self.date_to_entry.configure(state="disabled")
|
|
self.date_from_btn.configure(state="disabled")
|
|
self.date_to_btn.configure(state="disabled")
|
|
# Hide person column for other search types
|
|
self.tree.column("person", width=0, minwidth=0, anchor="w")
|
|
self.tree.heading("person", text="")
|
|
# Restore people icon column for other search types
|
|
self.tree.column("open_photo", width=50, minwidth=50, anchor="center")
|
|
self.tree.heading("open_photo", text="👤")
|
|
# Show all columns except person for other search types
|
|
self.tree["displaycolumns"] = ("select", "tags", "processed", "open_dir", "open_photo", "path", "date_taken")
|
|
|
|
def filter_results_by_folder(self, results, folder_path):
|
|
"""Filter search results by folder path if specified."""
|
|
if not folder_path or not folder_path.strip():
|
|
return results
|
|
|
|
folder_path = folder_path.strip()
|
|
filtered_results = []
|
|
|
|
for result in results:
|
|
if len(result) >= 1:
|
|
# Extract photo path from result tuple (always at index 0)
|
|
photo_path = result[0]
|
|
|
|
# Check if photo path starts with the specified folder path
|
|
if photo_path.startswith(folder_path):
|
|
filtered_results.append(result)
|
|
|
|
return filtered_results
|
|
|
|
def clear_results(self):
|
|
"""Clear all results from the treeview"""
|
|
for i in self.tree.get_children():
|
|
self.tree.delete(i)
|
|
# Reset sorting state for new search
|
|
self.sort_column = None
|
|
self.sort_reverse = False
|
|
# Clear selection tracking
|
|
self.selected_photos.clear()
|
|
# Clear tag cache
|
|
self.photo_tags_cache.clear()
|
|
# Reset results count
|
|
self.results_count_label.config(text="(0 items)")
|
|
self.update_header_display()
|
|
|
|
def add_results(self, rows):
|
|
"""Add search results to the treeview"""
|
|
# rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search or List[(path, date_taken)] for date search
|
|
for row in rows:
|
|
if len(row) == 2:
|
|
if self.search_type_var.get() == self.SEARCH_TYPES[1]: # Date search
|
|
# For date search: (path, date_taken) - hide person column
|
|
path, date_taken = row
|
|
photo_tags = self.get_photo_tags_for_display(path)
|
|
processed_status = self.get_photo_processed_status(path)
|
|
self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "📁", "👤", path, date_taken))
|
|
elif self.search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search
|
|
# For tag search: (path, tag_info) - hide person column
|
|
# Show ALL tags for the photo, not just matching ones
|
|
path, tag_info = row
|
|
photo_tags = self.get_photo_tags_for_display(path)
|
|
date_taken = self.get_photo_date_taken(path)
|
|
processed_status = self.get_photo_processed_status(path)
|
|
self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "📁", "👤", path, date_taken))
|
|
elif self.search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces
|
|
# For photos without faces: (path, tag_info) - hide person and people icon columns
|
|
path, tag_info = row
|
|
photo_tags = self.get_photo_tags_for_display(path)
|
|
date_taken = self.get_photo_date_taken(path)
|
|
processed_status = self.get_photo_processed_status(path)
|
|
self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "📁", "", path, date_taken))
|
|
elif self.search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags
|
|
# For photos without tags: (path, filename) - hide person column
|
|
path, filename = row
|
|
photo_tags = self.get_photo_tags_for_display(path) # Will be "No tags"
|
|
date_taken = self.get_photo_date_taken(path)
|
|
processed_status = self.get_photo_processed_status(path)
|
|
self.tree.insert("", tk.END, values=("☐", "", photo_tags, processed_status, "📁", "👤", path, date_taken))
|
|
else:
|
|
# For name search: (path, full_name) - show person column
|
|
p, full_name = row
|
|
# Get tags for this photo
|
|
photo_tags = self.get_photo_tags_for_display(p)
|
|
date_taken = self.get_photo_date_taken(p)
|
|
processed_status = self.get_photo_processed_status(p)
|
|
self.tree.insert("", tk.END, values=("☐", full_name, photo_tags, processed_status, "📁", "👤", p, date_taken))
|
|
|
|
# Sort by appropriate column by default when results are first loaded
|
|
if rows and self.sort_column is None:
|
|
if self.search_type_var.get() == self.SEARCH_TYPES[1]: # Date search
|
|
# Sort by date_taken column for date search
|
|
self.sort_column = "date_taken"
|
|
elif self.search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search
|
|
# Sort by tags column for tag search
|
|
self.sort_column = "tags"
|
|
elif self.search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces
|
|
# Sort by path column for photos without faces
|
|
self.sort_column = "path"
|
|
elif self.search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags
|
|
# Sort by path column for photos without tags (person column is hidden)
|
|
self.sort_column = "path"
|
|
else:
|
|
# Sort by person column for name search
|
|
self.sort_column = "person"
|
|
|
|
self.sort_reverse = False
|
|
# Get all items and sort them directly
|
|
items = [(self.tree.set(child, self.sort_column), child) for child in self.tree.get_children('')]
|
|
if self.sort_column == 'date_taken':
|
|
# Sort by date, handling "No date" entries
|
|
def date_sort_key(item):
|
|
date_str = item[0]
|
|
if date_str == "No date":
|
|
return "9999-12-31" # Put "No date" entries at the end
|
|
return date_str
|
|
items.sort(key=date_sort_key, reverse=False) # Ascending
|
|
else:
|
|
items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending
|
|
# Reorder items in treeview
|
|
for index, (val, child) in enumerate(items):
|
|
self.tree.move(child, '', index)
|
|
# Update header display
|
|
self.update_header_display()
|
|
|
|
# Update results count
|
|
item_count = len(self.tree.get_children())
|
|
self.results_count_label.config(text=f"({item_count} items)")
|
|
|
|
def do_search(self):
|
|
"""Perform the search based on current search type and parameters"""
|
|
self.clear_results()
|
|
choice = self.search_type_var.get()
|
|
folder_filter = self.folder_var.get().strip()
|
|
|
|
if choice == self.SEARCH_TYPES[0]: # Search photos by name
|
|
query = self.name_var.get().strip()
|
|
if not query:
|
|
messagebox.showinfo("Search", "Please enter a name to search.", parent=self.parent_frame)
|
|
return
|
|
rows = self.search_stats.search_faces(query)
|
|
# Apply folder filter
|
|
rows = self.filter_results_by_folder(rows, folder_filter)
|
|
if not rows:
|
|
folder_msg = f" in folder '{folder_filter}'" if folder_filter else ""
|
|
messagebox.showinfo("Search", f"No photos found for '{query}'{folder_msg}.", parent=self.parent_frame)
|
|
self.add_results(rows)
|
|
elif choice == self.SEARCH_TYPES[1]: # Search photos by date
|
|
date_from = self.date_from_var.get().strip()
|
|
date_to = self.date_to_var.get().strip()
|
|
|
|
# Validate date format if provided
|
|
if date_from:
|
|
try:
|
|
from datetime import datetime
|
|
datetime.strptime(date_from, '%Y-%m-%d')
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Date", "From date must be in YYYY-MM-DD format.", parent=self.parent_frame)
|
|
return
|
|
|
|
if date_to:
|
|
try:
|
|
from datetime import datetime
|
|
datetime.strptime(date_to, '%Y-%m-%d')
|
|
except ValueError:
|
|
messagebox.showerror("Invalid Date", "To date must be in YYYY-MM-DD format.", parent=self.parent_frame)
|
|
return
|
|
|
|
# Check if at least one date is provided
|
|
if not date_from and not date_to:
|
|
messagebox.showinfo("Search", "Please enter at least one date (from date or to date).", parent=self.parent_frame)
|
|
return
|
|
|
|
rows = self.search_stats.search_photos_by_date(date_from if date_from else None,
|
|
date_to if date_to else None)
|
|
# Apply folder filter
|
|
rows = self.filter_results_by_folder(rows, folder_filter)
|
|
if not rows:
|
|
date_range_text = ""
|
|
if date_from and date_to:
|
|
date_range_text = f" between {date_from} and {date_to}"
|
|
elif date_from:
|
|
date_range_text = f" from {date_from}"
|
|
elif date_to:
|
|
date_range_text = f" up to {date_to}"
|
|
folder_msg = f" in folder '{folder_filter}'" if folder_filter else ""
|
|
messagebox.showinfo("Search", f"No photos found{date_range_text}{folder_msg}.", parent=self.parent_frame)
|
|
else:
|
|
# Convert to the format expected by add_results: (path, date_taken)
|
|
formatted_rows = [(path, date_taken) for path, date_taken in rows]
|
|
self.add_results(formatted_rows)
|
|
elif choice == self.SEARCH_TYPES[2]: # Search photos by tags
|
|
tag_query = self.tag_var.get().strip()
|
|
if not tag_query:
|
|
messagebox.showinfo("Search", "Please enter tags to search for.", parent=self.parent_frame)
|
|
return
|
|
|
|
# Parse comma-separated tags
|
|
tags = [tag.strip() for tag in tag_query.split(',') if tag.strip()]
|
|
if not tags:
|
|
messagebox.showinfo("Search", "Please enter valid tags to search for.", parent=self.parent_frame)
|
|
return
|
|
|
|
# Determine match mode
|
|
match_all = (self.tag_mode_var.get() == "ALL")
|
|
|
|
rows = self.search_stats.search_photos_by_tags(tags, match_all)
|
|
# Apply folder filter
|
|
rows = self.filter_results_by_folder(rows, folder_filter)
|
|
if not rows:
|
|
mode_text = "all" if match_all else "any"
|
|
folder_msg = f" in folder '{folder_filter}'" if folder_filter else ""
|
|
messagebox.showinfo("Search", f"No photos found with {mode_text} of the tags: {', '.join(tags)}{folder_msg}", parent=self.parent_frame)
|
|
self.add_results(rows)
|
|
elif choice == self.SEARCH_TYPES[6]: # Photos without faces
|
|
rows = self.search_stats.get_photos_without_faces()
|
|
# Apply folder filter
|
|
rows = self.filter_results_by_folder(rows, folder_filter)
|
|
if not rows:
|
|
folder_msg = f" in folder '{folder_filter}'" if folder_filter else ""
|
|
messagebox.showinfo("Search", f"No photos without faces found{folder_msg}.", parent=self.parent_frame)
|
|
else:
|
|
# Convert to the format expected by add_results: (path, tag_info)
|
|
# For photos without faces, we don't have person info, so we use empty string
|
|
formatted_rows = [(path, "") for path, filename in rows]
|
|
self.add_results(formatted_rows)
|
|
elif choice == self.SEARCH_TYPES[7]: # Photos without tags
|
|
rows = self.search_stats.get_photos_without_tags()
|
|
# Apply folder filter
|
|
rows = self.filter_results_by_folder(rows, folder_filter)
|
|
if not rows:
|
|
folder_msg = f" in folder '{folder_filter}'" if folder_filter else ""
|
|
messagebox.showinfo("Search", f"No photos without tags found{folder_msg}.", parent=self.parent_frame)
|
|
else:
|
|
# Convert to the format expected by add_results: (path, filename)
|
|
# For photos without tags, we have both path and filename
|
|
formatted_rows = [(path, filename) for path, filename in rows]
|
|
self.add_results(formatted_rows)
|
|
|
|
def sort_treeview(self, col: str):
|
|
"""Sort the treeview by the specified column."""
|
|
# Get all items and their values
|
|
items = [(self.tree.set(child, col), child) for child in self.tree.get_children('')]
|
|
|
|
# Determine sort direction
|
|
if self.sort_column == col:
|
|
# Same column clicked - toggle direction
|
|
self.sort_reverse = not self.sort_reverse
|
|
else:
|
|
# Different column clicked - start with ascending
|
|
self.sort_reverse = False
|
|
self.sort_column = col
|
|
|
|
# Sort the items
|
|
# For person, tags, and path columns, sort alphabetically
|
|
# For date_taken column, sort by date
|
|
# For processed column, sort by processed status (Yes/No)
|
|
# For icon columns, maintain original order
|
|
if col in ['person', 'tags', 'path']:
|
|
items.sort(key=lambda x: x[0].lower(), reverse=self.sort_reverse)
|
|
elif col == 'date_taken':
|
|
# Sort by date, handling "No date" entries
|
|
def date_sort_key(item):
|
|
date_str = item[0]
|
|
if date_str == "No date":
|
|
return "9999-12-31" # Put "No date" entries at the end
|
|
return date_str
|
|
items.sort(key=date_sort_key, reverse=self.sort_reverse)
|
|
elif col == 'processed':
|
|
# Sort by processed status (Yes comes before No)
|
|
def processed_sort_key(item):
|
|
processed_str = item[0]
|
|
if processed_str == "Yes":
|
|
return "0" # Yes comes first
|
|
else:
|
|
return "1" # No comes second
|
|
items.sort(key=processed_sort_key, reverse=self.sort_reverse)
|
|
else:
|
|
# For icon columns, just reverse if clicking same column
|
|
if self.sort_column == col and self.sort_reverse:
|
|
items.reverse()
|
|
|
|
# Reorder items in treeview
|
|
for index, (val, child) in enumerate(items):
|
|
self.tree.move(child, '', index)
|
|
|
|
# Update header display
|
|
self.update_header_display()
|
|
|
|
def update_header_display(self):
|
|
"""Update header display to show sort indicators."""
|
|
# Reset all headers
|
|
self.tree.heading("person", text="Person")
|
|
self.tree.heading("tags", text="Tags")
|
|
self.tree.heading("processed", text="Processed")
|
|
self.tree.heading("path", text="Photo path")
|
|
self.tree.heading("date_taken", text="Date Taken")
|
|
|
|
# Add sort indicator to current sort column
|
|
if self.sort_column == "person":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
self.tree.heading("person", text="Person" + indicator)
|
|
elif self.sort_column == "tags":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
self.tree.heading("tags", text="Tags" + indicator)
|
|
elif self.sort_column == "processed":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
self.tree.heading("processed", text="Processed" + indicator)
|
|
elif self.sort_column == "path":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
self.tree.heading("path", text="Photo path" + indicator)
|
|
elif self.sort_column == "date_taken":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
self.tree.heading("date_taken", text="Date Taken" + indicator)
|
|
|
|
def on_tree_click(self, event):
|
|
"""Handle clicks on the treeview"""
|
|
region = self.tree.identify("region", event.x, event.y)
|
|
if region != "cell":
|
|
return
|
|
row_id = self.tree.identify_row(event.y)
|
|
col_id = self.tree.identify_column(event.x) # '#1', '#2', ...
|
|
if not row_id or not col_id:
|
|
return
|
|
vals = self.tree.item(row_id, "values")
|
|
if not vals or len(vals) < 6:
|
|
return
|
|
|
|
# Determine column offsets based on search type
|
|
is_name_search = (self.search_type_var.get() == self.SEARCH_TYPES[0])
|
|
is_photos_without_faces = (self.search_type_var.get() == self.SEARCH_TYPES[6])
|
|
|
|
if is_name_search:
|
|
# Name search: all columns visible including person (processed column hidden)
|
|
select_col = "#1" # select is column 1
|
|
open_dir_col = "#4" # open_dir is column 4
|
|
face_col = "#5" # open_photo is column 5
|
|
path_col = "#6" # path is column 6
|
|
path_index = 6 # path is at index 6 in values array (still same since processed is hidden from display)
|
|
elif is_photos_without_faces:
|
|
# Photos without faces: person and people icon columns are hidden
|
|
select_col = "#1" # select is column 1
|
|
open_dir_col = "#4" # open_dir is column 4
|
|
face_col = "#5" # open_photo is column 5 (but hidden)
|
|
path_col = "#5" # path is column 5 (since people icon is hidden)
|
|
path_index = 6 # path is at index 6 in values array
|
|
else:
|
|
# All other searches: person column is hidden, people icon visible
|
|
select_col = "#1" # select is column 1
|
|
open_dir_col = "#4" # open_dir is column 4
|
|
face_col = "#5" # open_photo is column 5
|
|
path_col = "#6" # path is column 6
|
|
path_index = 6 # path is at index 6 in values array
|
|
|
|
path = vals[path_index] # Photo path
|
|
if col_id == open_dir_col: # Open directory column
|
|
self.open_dir(path)
|
|
elif col_id == face_col: # Face icon column
|
|
# No popup needed, just tooltip
|
|
pass
|
|
elif col_id == path_col: # Photo path column - clickable to open photo
|
|
try:
|
|
import os
|
|
import sys
|
|
if os.name == "nt":
|
|
os.startfile(path) # type: ignore[attr-defined]
|
|
elif sys.platform == "darwin":
|
|
import subprocess
|
|
subprocess.run(["open", path], check=False)
|
|
else:
|
|
import subprocess
|
|
subprocess.run(["xdg-open", path], check=False)
|
|
except Exception:
|
|
messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=self.parent_frame)
|
|
elif col_id == select_col: # Checkbox column
|
|
self.toggle_photo_selection(row_id, vals)
|
|
|
|
def on_tree_motion(self, event):
|
|
"""Handle mouse motion over the treeview for tooltips"""
|
|
region = self.tree.identify("region", event.x, event.y)
|
|
if region != "cell":
|
|
self.hide_tooltip()
|
|
self.tree.config(cursor="")
|
|
return
|
|
col_id = self.tree.identify_column(event.x)
|
|
row_id = self.tree.identify_row(event.y)
|
|
|
|
# Determine column offsets based on search type
|
|
is_name_search = (self.search_type_var.get() == self.SEARCH_TYPES[0])
|
|
is_photos_without_faces = (self.search_type_var.get() == self.SEARCH_TYPES[6])
|
|
|
|
if is_name_search:
|
|
# Name search: all columns visible including person (processed column hidden)
|
|
tags_col = "#3" # tags is column 3
|
|
open_dir_col = "#4" # open_dir is column 4
|
|
face_col = "#5" # open_photo is column 5
|
|
path_col = "#6" # path is column 6
|
|
path_index = 6 # path is at index 6 in values array (still same since processed is hidden from display)
|
|
elif is_photos_without_faces:
|
|
# Photos without faces: person and people icon columns are hidden
|
|
tags_col = "#2" # tags is column 2
|
|
open_dir_col = "#4" # open_dir is column 4
|
|
face_col = "#5" # open_photo is column 5 (but hidden)
|
|
path_col = "#5" # path is column 5 (since people icon is hidden)
|
|
path_index = 6 # path is at index 6 in values array
|
|
else:
|
|
# All other searches: person column is hidden, people icon visible
|
|
tags_col = "#2" # tags is column 2
|
|
open_dir_col = "#4" # open_dir is column 4
|
|
face_col = "#5" # open_photo is column 5
|
|
path_col = "#6" # path is column 6
|
|
path_index = 6 # path is at index 6 in values array
|
|
|
|
if col_id == tags_col: # Tags column
|
|
self.tree.config(cursor="")
|
|
# Show tags tooltip
|
|
if row_id:
|
|
vals = self.tree.item(row_id, "values")
|
|
if len(vals) >= 3:
|
|
# Tags are at index 2 for all search types (after select, person is hidden in most)
|
|
tags_text = vals[2]
|
|
self.show_tooltip(self.tree, event.x_root, event.y_root, f"Tags: {tags_text}")
|
|
elif col_id == open_dir_col: # Open directory column
|
|
self.tree.config(cursor="hand2")
|
|
self.show_tooltip(self.tree, event.x_root, event.y_root, "Open file location")
|
|
elif col_id == face_col: # Face icon column
|
|
self.tree.config(cursor="hand2")
|
|
# Show people tooltip
|
|
if row_id:
|
|
vals = self.tree.item(row_id, "values")
|
|
if len(vals) >= 5:
|
|
path = vals[path_index]
|
|
people_text = self.get_photo_people_tooltip(path)
|
|
self.show_tooltip(self.tree, event.x_root, event.y_root, people_text)
|
|
elif col_id == path_col: # Photo path column
|
|
self.tree.config(cursor="hand2")
|
|
self.show_tooltip(self.tree, event.x_root, event.y_root, "Open photo")
|
|
else:
|
|
self.tree.config(cursor="")
|
|
self.hide_tooltip()
|
|
|
|
def show_tooltip(self, widget, x, y, text: str):
|
|
"""Show a tooltip"""
|
|
self.hide_tooltip()
|
|
try:
|
|
self.tooltip = tk.Toplevel(widget)
|
|
self.tooltip.wm_overrideredirect(True)
|
|
self.tooltip.wm_geometry(f"+{x+12}+{y+12}")
|
|
lbl = tk.Label(self.tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9))
|
|
lbl.pack()
|
|
except Exception:
|
|
self.tooltip = None
|
|
|
|
def hide_tooltip(self, *_):
|
|
"""Hide the current tooltip"""
|
|
if self.tooltip is not None:
|
|
try:
|
|
self.tooltip.destroy()
|
|
except Exception:
|
|
pass
|
|
self.tooltip = None
|
|
|
|
def open_dir(self, path: str):
|
|
"""Open the directory containing the photo"""
|
|
try:
|
|
import os
|
|
import sys
|
|
folder = os.path.dirname(path)
|
|
if os.name == "nt":
|
|
os.startfile(folder) # type: ignore[attr-defined]
|
|
elif sys.platform == "darwin":
|
|
import subprocess
|
|
subprocess.run(["open", folder], check=False)
|
|
else:
|
|
import subprocess
|
|
subprocess.run(["xdg-open", folder], check=False)
|
|
except Exception:
|
|
messagebox.showerror("Open Location", "Failed to open the file location.", parent=self.parent_frame)
|
|
|
|
def toggle_photo_selection(self, row_id, vals):
|
|
"""Toggle checkbox selection for a photo."""
|
|
if len(vals) < 7:
|
|
return
|
|
current_state = vals[0] # Checkbox is now in column 0 (first)
|
|
path = vals[6] # Photo path is now in column 6 (last)
|
|
if current_state == "☐":
|
|
# Select photo
|
|
new_state = "☑"
|
|
self.selected_photos[path] = {
|
|
'person': vals[1], # Person is now in column 1
|
|
'path': path
|
|
}
|
|
else:
|
|
# Deselect photo
|
|
new_state = "☐"
|
|
if path in self.selected_photos:
|
|
del self.selected_photos[path]
|
|
|
|
# Update the treeview
|
|
new_vals = list(vals)
|
|
new_vals[0] = new_state
|
|
self.tree.item(row_id, values=new_vals)
|
|
|
|
def tag_selected_photos(self):
|
|
"""Open linkage dialog for selected photos."""
|
|
if not self.selected_photos:
|
|
messagebox.showinfo("Tag Photos", "Please select photos to tag first.", parent=self.parent_frame)
|
|
return
|
|
|
|
# Get photo IDs for selected photos
|
|
selected_photo_ids = []
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for path in self.selected_photos.keys():
|
|
cursor.execute('SELECT id FROM photos WHERE path = ?', (path,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
selected_photo_ids.append(result[0])
|
|
|
|
if not selected_photo_ids:
|
|
messagebox.showerror("Tag Photos", "Could not find photo IDs for selected photos.", parent=self.parent_frame)
|
|
return
|
|
|
|
# Open the linkage dialog
|
|
self.open_linkage_dialog(selected_photo_ids)
|
|
|
|
def clear_all_selected(self):
|
|
"""Clear all selected photos and update checkboxes."""
|
|
if not self.selected_photos:
|
|
return
|
|
|
|
# Clear the selection tracking
|
|
self.selected_photos.clear()
|
|
|
|
# Update all checkboxes to unselected state
|
|
for item in self.tree.get_children():
|
|
vals = self.tree.item(item, "values")
|
|
if len(vals) >= 7 and vals[0] == "☑":
|
|
new_vals = list(vals)
|
|
new_vals[0] = "☐"
|
|
self.tree.item(item, values=new_vals)
|
|
|
|
def get_photo_tags_for_display(self, photo_path):
|
|
"""Get tags for a photo to display in the tags column."""
|
|
# Check cache first
|
|
if photo_path in self.photo_tags_cache:
|
|
tag_names = self.photo_tags_cache[photo_path]
|
|
else:
|
|
# Load from database and cache
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,))
|
|
result = cursor.fetchone()
|
|
if not result:
|
|
return "No photo found"
|
|
|
|
photo_id = result[0]
|
|
cursor.execute('''
|
|
SELECT t.tag_name
|
|
FROM tags t
|
|
JOIN phototaglinkage ptl ON t.id = ptl.tag_id
|
|
WHERE ptl.photo_id = ?
|
|
ORDER BY t.tag_name
|
|
''', (photo_id,))
|
|
tag_names = [row[0] for row in cursor.fetchall()]
|
|
self.photo_tags_cache[photo_path] = tag_names
|
|
except Exception:
|
|
return "No tags"
|
|
|
|
# Format for display - show all tags
|
|
if tag_names:
|
|
return ', '.join(tag_names)
|
|
else:
|
|
return "No tags"
|
|
|
|
def get_photo_date_taken(self, photo_path):
|
|
"""Get date_taken for a photo to display in the date_taken column."""
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT date_taken FROM photos WHERE path = ?', (photo_path,))
|
|
result = cursor.fetchone()
|
|
if result and result[0]:
|
|
return result[0] # Return the date as stored in database
|
|
else:
|
|
return "No date" # No date_taken available
|
|
except Exception:
|
|
return "No date"
|
|
|
|
def get_photo_processed_status(self, photo_path):
|
|
"""Get processed status for a photo to display in the processed column."""
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT processed FROM photos WHERE path = ?', (photo_path,))
|
|
result = cursor.fetchone()
|
|
if result and result[0] is not None:
|
|
return "Yes" if result[0] else "No"
|
|
else:
|
|
return "No" # Default to not processed
|
|
except Exception:
|
|
return "No"
|
|
|
|
def get_photo_people_tooltip(self, photo_path):
|
|
"""Get people information for a photo to display in tooltip."""
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT DISTINCT pe.first_name, pe.last_name, pe.middle_name, pe.maiden_name
|
|
FROM photos p
|
|
JOIN faces f ON p.id = f.photo_id
|
|
JOIN people pe ON f.person_id = pe.id
|
|
WHERE p.path = ? AND f.person_id IS NOT NULL
|
|
ORDER BY pe.last_name, pe.first_name
|
|
''', (photo_path,))
|
|
people = cursor.fetchall()
|
|
|
|
if not people:
|
|
return "No people identified"
|
|
|
|
people_names = []
|
|
for person in people:
|
|
first = (person[0] or "").strip()
|
|
last = (person[1] or "").strip()
|
|
middle = (person[2] or "").strip()
|
|
maiden = (person[3] or "").strip()
|
|
|
|
# Build full name
|
|
name_parts = []
|
|
if first:
|
|
name_parts.append(first)
|
|
if middle:
|
|
name_parts.append(middle)
|
|
if last:
|
|
name_parts.append(last)
|
|
if maiden and maiden != last:
|
|
name_parts.append(f"({maiden})")
|
|
|
|
full_name = " ".join(name_parts) if name_parts else "Unknown"
|
|
people_names.append(full_name)
|
|
|
|
if people_names:
|
|
if len(people_names) <= 3:
|
|
return f"People: {', '.join(people_names)}"
|
|
else:
|
|
return f"People: {', '.join(people_names[:3])}... (+{len(people_names)-3} more)"
|
|
else:
|
|
return "No people identified"
|
|
except Exception:
|
|
pass
|
|
return "No people identified"
|
|
|
|
def open_linkage_dialog(self, photo_ids):
|
|
"""Open the linkage dialog for selected photos using tag manager functionality."""
|
|
popup = tk.Toplevel(self.parent_frame)
|
|
popup.title("Tag Selected Photos")
|
|
popup.transient(self.parent_frame)
|
|
popup.grab_set()
|
|
popup.geometry("500x400")
|
|
popup.resizable(True, True)
|
|
|
|
# Track tag changes for updating results
|
|
tags_added = set() # tag names that were added
|
|
tags_removed = set() # tag names that were removed
|
|
|
|
top_frame = ttk.Frame(popup, padding="8")
|
|
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
|
list_frame = ttk.Frame(popup, padding="8")
|
|
list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
|
|
bottom_frame = ttk.Frame(popup, padding="8")
|
|
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E))
|
|
popup.columnconfigure(0, weight=1)
|
|
popup.rowconfigure(1, weight=1)
|
|
|
|
ttk.Label(top_frame, text=f"Selected Photos: {len(photo_ids)}").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6))
|
|
ttk.Label(top_frame, text="Add tag:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W)
|
|
|
|
# Get existing tags using tag manager
|
|
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
|
existing_tags = sorted(tag_name_to_id.keys())
|
|
|
|
tag_var = tk.StringVar()
|
|
combo = ttk.Combobox(top_frame, textvariable=tag_var, values=existing_tags, width=30)
|
|
combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E))
|
|
combo.focus_set()
|
|
|
|
def add_selected_tag():
|
|
tag_name = tag_var.get().strip()
|
|
if not tag_name:
|
|
return
|
|
|
|
# Resolve or create tag id (case-insensitive)
|
|
normalized_tag_name = tag_name.lower().strip()
|
|
if normalized_tag_name in tag_name_to_id:
|
|
tag_id = tag_name_to_id[normalized_tag_name]
|
|
else:
|
|
# Create new tag in database using the database method
|
|
tag_id = self.db.add_tag(tag_name)
|
|
if tag_id:
|
|
# Update mappings
|
|
tag_name_to_id[normalized_tag_name] = tag_id
|
|
tag_id_to_name[tag_id] = tag_name
|
|
if tag_name not in existing_tags:
|
|
existing_tags.append(tag_name)
|
|
existing_tags.sort()
|
|
# Update the combobox values to include the new tag
|
|
combo['values'] = existing_tags
|
|
else:
|
|
messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup)
|
|
return
|
|
|
|
# Add tag to all selected photos with single linkage type (0)
|
|
affected = 0
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id in photo_ids:
|
|
# Check if tag already exists for this photo
|
|
cursor.execute('SELECT linkage_id FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
|
|
if not cursor.fetchone():
|
|
# Add the tag with single linkage type (0)
|
|
cursor.execute('INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) VALUES (?, ?, 0)', (photo_id, tag_id))
|
|
affected += 1
|
|
|
|
# Track that this tag was added
|
|
if affected > 0:
|
|
tags_added.add(tag_name)
|
|
|
|
# Refresh the tag list to show the new tag
|
|
refresh_tag_list()
|
|
tag_var.set("")
|
|
|
|
ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=1, column=2, padx=(8, 0))
|
|
|
|
# Allow Enter key to add tag
|
|
combo.bind('<Return>', lambda e: add_selected_tag())
|
|
|
|
# Create scrollable tag list
|
|
canvas = tk.Canvas(list_frame, height=200)
|
|
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
|
|
scrollable_frame = ttk.Frame(canvas)
|
|
scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
|
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
|
canvas.configure(yscrollcommand=scrollbar.set)
|
|
canvas.pack(side="left", fill="both", expand=True)
|
|
scrollbar.pack(side="right", fill="y")
|
|
|
|
selected_tag_vars = {}
|
|
|
|
def refresh_tag_list():
|
|
for widget in scrollable_frame.winfo_children():
|
|
widget.destroy()
|
|
selected_tag_vars.clear()
|
|
|
|
# Get tags that exist in ALL selected photos
|
|
# First, get all tags for each photo
|
|
photo_tags = {} # photo_id -> set of tag_ids
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id in photo_ids:
|
|
photo_tags[photo_id] = set()
|
|
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
|
|
for row in cursor.fetchall():
|
|
photo_tags[photo_id].add(row[0])
|
|
|
|
# Find intersection - tags that exist in ALL selected photos
|
|
if not photo_tags:
|
|
ttk.Label(scrollable_frame, text="No tags linked to selected photos", foreground="gray").pack(anchor=tk.W, pady=5)
|
|
return
|
|
|
|
# Start with tags from first photo, then intersect with others
|
|
common_tag_ids = set(photo_tags[photo_ids[0]])
|
|
for photo_id in photo_ids[1:]:
|
|
common_tag_ids = common_tag_ids.intersection(photo_tags[photo_id])
|
|
|
|
if not common_tag_ids:
|
|
ttk.Label(scrollable_frame, text="No common tags found across all selected photos", foreground="gray").pack(anchor=tk.W, pady=5)
|
|
return
|
|
|
|
# Get linkage type information for common tags
|
|
# For tags that exist in all photos, we need to determine the linkage type
|
|
# If a tag has different linkage types across photos, we'll show the most restrictive
|
|
common_tag_data = {} # tag_id -> {linkage_type, photo_count}
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id in photo_ids:
|
|
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id IN ({})'.format(','.join('?' * len(common_tag_ids))), [photo_id] + list(common_tag_ids))
|
|
for row in cursor.fetchall():
|
|
tag_id = row[0]
|
|
linkage_type = int(row[1]) if row[1] is not None else 0
|
|
if tag_id not in common_tag_data:
|
|
common_tag_data[tag_id] = {'linkage_type': linkage_type, 'photo_count': 0}
|
|
common_tag_data[tag_id]['photo_count'] += 1
|
|
# If we find a bulk linkage type (1), use that as it's more restrictive
|
|
if linkage_type == 1:
|
|
common_tag_data[tag_id]['linkage_type'] = 1
|
|
|
|
# Sort tags by name for consistent display
|
|
for tag_id in sorted(common_tag_data.keys()):
|
|
tag_name = tag_id_to_name.get(tag_id, f"Unknown {tag_id}")
|
|
var = tk.BooleanVar()
|
|
selected_tag_vars[tag_name] = var
|
|
frame = ttk.Frame(scrollable_frame)
|
|
frame.pack(fill=tk.X, pady=1)
|
|
|
|
# Determine if this tag can be selected for deletion
|
|
# In single linkage dialog, only allow deleting single linkage type (0) tags
|
|
linkage_type = common_tag_data[tag_id]['linkage_type']
|
|
can_select = (linkage_type == 0) # Only single linkage type can be deleted
|
|
|
|
cb = ttk.Checkbutton(frame, variable=var)
|
|
if not can_select:
|
|
try:
|
|
cb.state(["disabled"]) # disable selection for bulk tags
|
|
except Exception:
|
|
pass
|
|
cb.pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
# Display tag name with status information
|
|
type_label = 'single' if linkage_type == 0 else 'bulk'
|
|
photo_count = common_tag_data[tag_id]['photo_count']
|
|
status_text = f" (saved {type_label})"
|
|
status_color = "black" if can_select else "gray"
|
|
ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT)
|
|
|
|
def remove_selected_tags():
|
|
tag_ids_to_remove = []
|
|
tag_names_to_remove = []
|
|
for tag_name, var in selected_tag_vars.items():
|
|
if var.get() and tag_name in tag_name_to_id:
|
|
tag_ids_to_remove.append(tag_name_to_id[tag_name])
|
|
tag_names_to_remove.append(tag_name)
|
|
|
|
if not tag_ids_to_remove:
|
|
return
|
|
|
|
# Only remove single linkage type tags (bulk tags should be disabled anyway)
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id in photo_ids:
|
|
for tag_id in tag_ids_to_remove:
|
|
# Double-check that this is a single linkage type before deleting
|
|
cursor.execute('SELECT linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
|
|
result = cursor.fetchone()
|
|
if result and int(result[0]) == 0: # Only delete single linkage type
|
|
cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
|
|
|
|
# Track that these tags were removed
|
|
tags_removed.update(tag_names_to_remove)
|
|
|
|
refresh_tag_list()
|
|
|
|
def update_search_results():
|
|
"""Update the search results to reflect tag changes without database access."""
|
|
if not tags_added and not tags_removed:
|
|
return # No changes to apply
|
|
|
|
# Get photo paths for the affected photos from selected_photos
|
|
affected_photo_paths = set(self.selected_photos.keys())
|
|
|
|
# Update cache for affected photos
|
|
for photo_path in affected_photo_paths:
|
|
if photo_path in self.photo_tags_cache:
|
|
# Update cached tags based on changes
|
|
current_tags = set(self.photo_tags_cache[photo_path])
|
|
# Add new tags
|
|
current_tags.update(tags_added)
|
|
# Remove deleted tags
|
|
current_tags.difference_update(tags_removed)
|
|
# Update cache with sorted list
|
|
self.photo_tags_cache[photo_path] = sorted(list(current_tags))
|
|
|
|
# Update each affected row in the search results
|
|
for item in self.tree.get_children():
|
|
vals = self.tree.item(item, "values")
|
|
if len(vals) >= 7:
|
|
photo_path = vals[6] # Photo path is at index 6
|
|
if photo_path in affected_photo_paths:
|
|
# Get current tags for this photo from cache
|
|
current_tags = self.get_photo_tags_for_display(photo_path)
|
|
# Update the tags column (index 2)
|
|
new_vals = list(vals)
|
|
new_vals[2] = current_tags
|
|
self.tree.item(item, values=new_vals)
|
|
|
|
def close_dialog():
|
|
"""Close dialog and update search results if needed."""
|
|
update_search_results()
|
|
popup.destroy()
|
|
|
|
ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8))
|
|
ttk.Button(bottom_frame, text="Close", command=close_dialog).pack(side=tk.RIGHT)
|
|
refresh_tag_list()
|
|
|
|
"""
|
|
Unified Dashboard GUI for PunimTag features
|
|
Designed with web migration in mind - single window with menu bar and content area
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
from typing import Dict, Optional, Callable
|
|
|
|
from gui_core import GUICore
|
|
from identify_panel import IdentifyPanel
|
|
from modify_panel import ModifyPanel
|
|
from auto_match_panel import AutoMatchPanel
|
|
from tag_manager_panel import TagManagerPanel
|
|
from search_stats import SearchStats
|
|
from database import DatabaseManager
|
|
from tag_management import TagManager
|
|
from face_processing import FaceProcessor
|
|
class DashboardGUI:
|
|
"""Unified Dashboard with menu bar and content area for all features.
|
|
|
|
Designed to be web-migration friendly with clear separation between
|
|
navigation (menu bar) and content (panels).
|
|
"""
|
|
|
|
def __init__(self, gui_core: GUICore, db_manager=None, face_processor=None, on_scan=None, on_process=None, on_identify=None, search_stats=None, tag_manager=None):
|
|
self.gui_core = gui_core
|
|
self.db_manager = db_manager
|
|
self.face_processor = face_processor
|
|
self.on_scan = on_scan
|
|
self.on_process = on_process
|
|
self.on_identify = on_identify
|
|
self.search_stats = search_stats
|
|
self.tag_manager = tag_manager
|
|
|
|
# Panel management for future web migration
|
|
self.panels: Dict[str, ttk.Frame] = {}
|
|
self.current_panel: Optional[str] = None
|
|
self.root: Optional[tk.Tk] = None
|
|
|
|
def open(self) -> int:
|
|
"""Open the unified dashboard with menu bar and content area"""
|
|
self.root = tk.Tk()
|
|
self.root.title("PunimTag - Unified Dashboard")
|
|
self.root.resizable(True, True)
|
|
self.root.withdraw()
|
|
|
|
# Make window full screen - use geometry instead of state for better compatibility
|
|
try:
|
|
# Try Windows-style maximized state first
|
|
self.root.state('zoomed')
|
|
except tk.TclError:
|
|
try:
|
|
# Try Linux-style maximized attribute
|
|
self.root.attributes('-zoomed', True)
|
|
except tk.TclError:
|
|
# Fallback: set geometry to screen size
|
|
screen_width = self.root.winfo_screenwidth()
|
|
screen_height = self.root.winfo_screenheight()
|
|
self.root.geometry(f"{screen_width}x{screen_height}+0+0")
|
|
|
|
# Get screen dimensions for dynamic sizing
|
|
screen_width = self.root.winfo_screenwidth()
|
|
screen_height = self.root.winfo_screenheight()
|
|
|
|
# Set minimum window size
|
|
self.root.minsize(800, 600)
|
|
|
|
# Create main container with proper grid configuration
|
|
main_container = ttk.Frame(self.root)
|
|
main_container.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Configure main container grid weights for responsiveness
|
|
main_container.columnconfigure(0, weight=1)
|
|
main_container.rowconfigure(0, weight=0) # Menu bar - fixed height
|
|
main_container.rowconfigure(1, weight=0) # Separator - fixed height
|
|
main_container.rowconfigure(2, weight=1) # Content area - expandable
|
|
|
|
# Add window resize handler for dynamic responsiveness
|
|
self.root.bind('<Configure>', self._on_window_resize)
|
|
|
|
# Create menu bar
|
|
self._create_menu_bar(main_container)
|
|
|
|
# Create content area
|
|
self._create_content_area(main_container)
|
|
|
|
# Initialize panels
|
|
self._initialize_panels()
|
|
|
|
# Show default panel
|
|
self.show_panel("home")
|
|
|
|
# Show window
|
|
self.root.deiconify()
|
|
self.root.mainloop()
|
|
return 0
|
|
|
|
def _create_menu_bar(self, parent: ttk.Frame):
|
|
"""Create the top menu bar with all functionality buttons"""
|
|
menu_frame = ttk.Frame(parent)
|
|
menu_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=15, pady=10)
|
|
menu_frame.columnconfigure(0, weight=0) # Title - fixed width
|
|
menu_frame.columnconfigure(1, weight=1) # Buttons - expandable
|
|
menu_frame.columnconfigure(2, weight=0) # Status - fixed width
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(menu_frame, text="PunimTag", font=("Arial", 20, "bold"))
|
|
title_label.grid(row=0, column=0, padx=(0, 30), sticky=tk.W)
|
|
|
|
# Create buttons frame for better organization
|
|
buttons_frame = ttk.Frame(menu_frame)
|
|
buttons_frame.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=20)
|
|
|
|
# Menu buttons with larger size for full screen
|
|
menu_buttons = [
|
|
("🏠", "home", "Go to the welcome screen"),
|
|
("📁 Scan", "scan", "Scan folders and add photos"),
|
|
("🔍 Process", "process", "Detect faces in photos"),
|
|
("👤 Identify", "identify", "Identify faces in photos"),
|
|
("🎯 Auto-Match", "auto_match", "Find and confirm matching faces"),
|
|
("🔎 Search", "search", "Search photos by people, dates, tags, and more"),
|
|
("✏️ Edit Identified", "modify", "View and modify identified faces"),
|
|
("🏷️ Tag Photos", "tags", "Manage photo tags"),
|
|
]
|
|
|
|
for i, (text, panel_name, tooltip) in enumerate(menu_buttons):
|
|
# Make home button smaller than other buttons
|
|
if panel_name == "home":
|
|
btn_width = 4 # Smaller width for icon-only home button
|
|
else:
|
|
btn_width = 16 # Standard width for other buttons
|
|
|
|
btn = ttk.Button(
|
|
buttons_frame,
|
|
text=text,
|
|
command=lambda p=panel_name: self.show_panel(p),
|
|
width=btn_width
|
|
)
|
|
btn.grid(row=0, column=i, padx=3, sticky=tk.W)
|
|
|
|
# Add tooltip functionality
|
|
self._add_tooltip(btn, tooltip)
|
|
|
|
# Status/Info area with better styling
|
|
status_frame = ttk.Frame(menu_frame)
|
|
status_frame.grid(row=0, column=2, sticky=tk.E, padx=(20, 0))
|
|
|
|
self.status_label = tk.Label(status_frame, text="Ready", foreground="#666", font=("Arial", 10))
|
|
self.status_label.pack(side=tk.RIGHT)
|
|
|
|
# Add a subtle separator line below the menu
|
|
separator = ttk.Separator(parent, orient='horizontal')
|
|
separator.grid(row=1, column=0, sticky=(tk.W, tk.E), padx=15, pady=(0, 5))
|
|
|
|
def _create_content_area(self, parent: ttk.Frame):
|
|
"""Create the main content area where panels will be displayed"""
|
|
self.content_frame = ttk.Frame(parent)
|
|
self.content_frame.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=15, pady=(0, 15))
|
|
|
|
# Configure content frame to expand both horizontally and vertically
|
|
self.content_frame.columnconfigure(0, weight=1)
|
|
self.content_frame.rowconfigure(0, weight=1)
|
|
|
|
# Add a subtle border
|
|
self.content_frame.configure(relief='sunken', borderwidth=1)
|
|
|
|
def _initialize_panels(self):
|
|
"""Initialize all panels (currently placeholders)"""
|
|
# Home panel (default)
|
|
self.panels["home"] = self._create_home_panel()
|
|
|
|
# Functional panels (placeholders for now)
|
|
self.panels["scan"] = self._create_scan_panel()
|
|
self.panels["process"] = self._create_process_panel()
|
|
self.panels["identify"] = self._create_identify_panel()
|
|
self.panels["auto_match"] = self._create_auto_match_panel()
|
|
self.panels["search"] = self._create_search_panel()
|
|
self.panels["modify"] = self._create_modify_panel()
|
|
self.panels["tags"] = self._create_tags_panel()
|
|
|
|
def show_panel(self, panel_name: str):
|
|
"""Show the specified panel in the content area"""
|
|
if panel_name not in self.panels:
|
|
messagebox.showerror("Error", f"Panel '{panel_name}' not found", parent=self.root)
|
|
return
|
|
|
|
# Deactivate current panel if it has activation/deactivation methods
|
|
if self.current_panel:
|
|
self.panels[self.current_panel].grid_remove()
|
|
# Deactivate identify panel if it's active
|
|
if hasattr(self, 'identify_panel') and self.identify_panel and self.current_panel == "identify":
|
|
self.identify_panel.deactivate()
|
|
# Deactivate auto-match panel if it's active
|
|
if hasattr(self, 'auto_match_panel') and self.auto_match_panel and self.current_panel == "auto_match":
|
|
self.auto_match_panel.deactivate()
|
|
# Deactivate modify panel if it's active
|
|
if hasattr(self, 'modify_panel') and self.modify_panel and self.current_panel == "modify":
|
|
self.modify_panel.deactivate()
|
|
# Deactivate tag manager panel if it's active
|
|
if hasattr(self, 'tag_manager_panel') and self.tag_manager_panel and self.current_panel == "tags":
|
|
self.tag_manager_panel.deactivate()
|
|
|
|
# Show new panel - expand both horizontally and vertically
|
|
self.panels[panel_name].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=15, pady=15)
|
|
self.current_panel = panel_name
|
|
|
|
# Activate new panel if it has activation/deactivation methods
|
|
if panel_name == "identify" and hasattr(self, 'identify_panel') and self.identify_panel:
|
|
self.identify_panel.activate()
|
|
elif panel_name == "auto_match" and hasattr(self, 'auto_match_panel') and self.auto_match_panel:
|
|
self.auto_match_panel.activate()
|
|
elif panel_name == "modify" and hasattr(self, 'modify_panel') and self.modify_panel:
|
|
self.modify_panel.activate()
|
|
elif panel_name == "tags" and hasattr(self, 'tag_manager_panel') and self.tag_manager_panel:
|
|
self.tag_manager_panel.activate()
|
|
|
|
# Update status
|
|
self.status_label.config(text=f"Viewing: {panel_name.replace('_', ' ').title()}")
|
|
|
|
def _add_tooltip(self, widget, text):
|
|
"""Add a simple tooltip to a widget"""
|
|
def show_tooltip(event):
|
|
tooltip = tk.Toplevel()
|
|
tooltip.wm_overrideredirect(True)
|
|
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
|
|
label = ttk.Label(tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1)
|
|
label.pack()
|
|
widget.tooltip = tooltip
|
|
|
|
def hide_tooltip(event):
|
|
if hasattr(widget, 'tooltip'):
|
|
widget.tooltip.destroy()
|
|
del widget.tooltip
|
|
|
|
widget.bind("<Enter>", show_tooltip)
|
|
widget.bind("<Leave>", hide_tooltip)
|
|
|
|
def _on_window_resize(self, event):
|
|
"""Handle window resize events for dynamic responsiveness"""
|
|
# Only handle resize events for the main window, not child widgets
|
|
if event.widget == self.root:
|
|
# Update status with current window size
|
|
width = self.root.winfo_width()
|
|
height = self.root.winfo_height()
|
|
self.status_label.config(text=f"Ready - {width}x{height}")
|
|
|
|
# Force update of all panels to ensure proper resizing
|
|
if hasattr(self, 'identify_panel') and self.identify_panel:
|
|
# Update identify panel layout if it's active
|
|
if self.current_panel == "identify":
|
|
self.identify_panel.update_layout()
|
|
if hasattr(self, 'tag_manager_panel') and self.tag_manager_panel:
|
|
# Update tag manager panel layout if it's active
|
|
if self.current_panel == "tags":
|
|
self.tag_manager_panel.update_layout()
|
|
|
|
def _create_home_panel(self) -> ttk.Frame:
|
|
"""Create the home/welcome panel"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Remove weight=1 from row to prevent empty space expansion
|
|
|
|
# Welcome content
|
|
welcome_frame = ttk.Frame(panel)
|
|
welcome_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N), padx=20, pady=20)
|
|
welcome_frame.columnconfigure(0, weight=1)
|
|
# Remove weight=1 to prevent vertical centering
|
|
# welcome_frame.rowconfigure(0, weight=1)
|
|
|
|
# Content starts at the top instead of being centered
|
|
center_frame = ttk.Frame(welcome_frame)
|
|
center_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N))
|
|
center_frame.columnconfigure(0, weight=1)
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(center_frame, text="Welcome to PunimTag", font=("Arial", 32, "bold"))
|
|
title_label.grid(row=0, column=0, pady=(0, 30))
|
|
|
|
# Description with larger font
|
|
desc_text = (
|
|
"PunimTag is a powerful photo face recognition and tagging system.\n\n"
|
|
"Use the menu above to access different features:\n\n"
|
|
"• 📁 Scan - Add photos to your collection\n"
|
|
"• 🔍 Process - Detect faces in photos\n"
|
|
"• 👤 Identify - Identify people in photos\n"
|
|
"• 🎯 Auto-Match - Find matching faces automatically\n"
|
|
"• 🔎 Search - Search photos by people, dates, tags, and more\n"
|
|
"• ✏️ Edit Identified - Edit face identifications\n"
|
|
"• 🏷️ Tag Photos - Manage photo tags\n\n"
|
|
"Select a feature from the menu to get started!"
|
|
)
|
|
|
|
desc_label = tk.Label(center_frame, text=desc_text, font=("Arial", 14), justify=tk.LEFT)
|
|
desc_label.grid(row=1, column=0, pady=(0, 20))
|
|
|
|
return panel
|
|
|
|
def _create_scan_panel(self) -> ttk.Frame:
|
|
"""Create the scan panel (migrated from original dashboard)"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Remove weight=1 from row to prevent empty space expansion
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(panel, text="📁 Scan Photos", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
# Scan form
|
|
form_frame = ttk.LabelFrame(panel, text="Scan Configuration", padding="20")
|
|
form_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 20))
|
|
form_frame.columnconfigure(0, weight=1)
|
|
|
|
# Folder selection
|
|
folder_frame = ttk.Frame(form_frame)
|
|
folder_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 15))
|
|
folder_frame.columnconfigure(0, weight=1)
|
|
|
|
tk.Label(folder_frame, text="Folder to scan:", font=("Arial", 12)).grid(row=0, column=0, sticky=tk.W)
|
|
|
|
folder_input_frame = ttk.Frame(folder_frame)
|
|
folder_input_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(5, 0))
|
|
folder_input_frame.columnconfigure(0, weight=1)
|
|
|
|
self.folder_var = tk.StringVar()
|
|
folder_entry = tk.Entry(folder_input_frame, textvariable=self.folder_var, font=("Arial", 11))
|
|
folder_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 10))
|
|
|
|
def browse_folder():
|
|
from tkinter import filedialog
|
|
folder_path = filedialog.askdirectory(title="Select folder to scan for photos")
|
|
if folder_path:
|
|
self.folder_var.set(folder_path)
|
|
|
|
browse_btn = ttk.Button(folder_input_frame, text="Browse", command=browse_folder)
|
|
browse_btn.grid(row=0, column=1)
|
|
|
|
# Recursive option
|
|
self.recursive_var = tk.BooleanVar(value=True)
|
|
recursive_check = tk.Checkbutton(
|
|
form_frame,
|
|
text="Include photos in sub-folders",
|
|
variable=self.recursive_var,
|
|
font=("Arial", 11)
|
|
)
|
|
recursive_check.grid(row=1, column=0, sticky=tk.W, pady=(15, 0))
|
|
|
|
# Action button
|
|
scan_btn = ttk.Button(form_frame, text="🔍 Start Scan", command=self._run_scan)
|
|
scan_btn.grid(row=2, column=0, sticky=tk.W, pady=(20, 0))
|
|
|
|
return panel
|
|
|
|
def _create_process_panel(self) -> ttk.Frame:
|
|
"""Create the process panel (migrated from original dashboard)"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Remove weight=1 from row to prevent empty space expansion
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(panel, text="🔍 Process Faces", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
# Process form
|
|
form_frame = ttk.LabelFrame(panel, text="Processing Configuration", padding="20")
|
|
form_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 20))
|
|
form_frame.columnconfigure(0, weight=1)
|
|
|
|
# Limit option
|
|
limit_frame = ttk.Frame(form_frame)
|
|
limit_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 15))
|
|
|
|
self.limit_enabled = tk.BooleanVar(value=False)
|
|
limit_check = tk.Checkbutton(limit_frame, text="Limit processing to", variable=self.limit_enabled, font=("Arial", 11))
|
|
limit_check.grid(row=0, column=0, sticky=tk.W)
|
|
|
|
self.limit_var = tk.StringVar(value="50")
|
|
limit_entry = tk.Entry(limit_frame, textvariable=self.limit_var, width=8, font=("Arial", 11))
|
|
limit_entry.grid(row=0, column=1, padx=(10, 5))
|
|
|
|
tk.Label(limit_frame, text="photos", font=("Arial", 11)).grid(row=0, column=2, sticky=tk.W)
|
|
|
|
# Action button
|
|
self.process_btn = ttk.Button(form_frame, text="🚀 Start Processing", command=self._run_process)
|
|
self.process_btn.grid(row=1, column=0, sticky=tk.W, pady=(20, 0))
|
|
|
|
# Cancel button (initially hidden/disabled)
|
|
self.cancel_btn = tk.Button(form_frame, text="✖ Cancel", command=self._cancel_process, state="disabled")
|
|
self.cancel_btn.grid(row=1, column=0, sticky=tk.E, pady=(20, 0))
|
|
|
|
# Progress bar
|
|
self.progress_var = tk.DoubleVar()
|
|
self.progress_bar = ttk.Progressbar(form_frame, variable=self.progress_var,
|
|
maximum=100, length=400, mode='determinate')
|
|
self.progress_bar.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(15, 0))
|
|
|
|
# Progress status label
|
|
self.progress_status_var = tk.StringVar(value="Ready to process")
|
|
progress_status_label = tk.Label(form_frame, textvariable=self.progress_status_var,
|
|
font=("Arial", 11), fg="gray")
|
|
progress_status_label.grid(row=3, column=0, sticky=tk.W, pady=(5, 0))
|
|
|
|
return panel
|
|
|
|
def _create_identify_panel(self) -> ttk.Frame:
|
|
"""Create the identify panel with full functionality"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Configure rows: title (row 0) fixed, identify content (row 1) should expand
|
|
panel.rowconfigure(0, weight=0)
|
|
panel.rowconfigure(1, weight=1)
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(panel, text="👤 Identify Faces", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
# Create the identify panel if we have the required dependencies
|
|
if self.db_manager and self.face_processor:
|
|
self.identify_panel = IdentifyPanel(panel, self.db_manager, self.face_processor, self.gui_core, on_navigate_home=lambda: self.show_panel("home"))
|
|
identify_frame = self.identify_panel.create_panel()
|
|
identify_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
else:
|
|
# Fallback placeholder if dependencies are not available
|
|
placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20")
|
|
placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N))
|
|
placeholder_frame.columnconfigure(0, weight=1)
|
|
# Remove weight=1 to prevent vertical centering
|
|
# placeholder_frame.rowconfigure(0, weight=1)
|
|
|
|
placeholder_text = (
|
|
"Identify panel requires database and face processor to be configured.\n\n"
|
|
"This will contain the full face identification interface\n"
|
|
"currently available in the separate Identify window.\n\n"
|
|
"Features will include:\n"
|
|
"• Face browsing and identification\n"
|
|
"• Similar face matching\n"
|
|
"• Person management\n"
|
|
"• Batch processing options"
|
|
)
|
|
|
|
placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT)
|
|
placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
return panel
|
|
|
|
def _create_auto_match_panel(self) -> ttk.Frame:
|
|
"""Create the auto-match panel with full functionality"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Configure rows: title (row 0) fixed, auto-match content (row 1) should expand
|
|
panel.rowconfigure(0, weight=0)
|
|
panel.rowconfigure(1, weight=1)
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(panel, text="🔗 Auto-Match Faces", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
# Create the auto-match panel if we have the required dependencies
|
|
if self.db_manager and self.face_processor:
|
|
self.auto_match_panel = AutoMatchPanel(panel, self.db_manager, self.face_processor, self.gui_core, on_navigate_home=lambda: self.show_panel("home"))
|
|
auto_match_frame = self.auto_match_panel.create_panel()
|
|
auto_match_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
else:
|
|
# Fallback placeholder if dependencies are not available
|
|
placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20")
|
|
placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N))
|
|
placeholder_frame.columnconfigure(0, weight=1)
|
|
# Remove weight=1 to prevent vertical centering
|
|
# placeholder_frame.rowconfigure(0, weight=1)
|
|
|
|
placeholder_text = (
|
|
"Auto-Match panel requires database and face processor to be configured.\n\n"
|
|
"This will contain the full auto-match interface\n"
|
|
"currently available in the separate Auto-Match window.\n\n"
|
|
"Features will include:\n"
|
|
"• Person-centric matching workflow\n"
|
|
"• Visual confirmation of matches\n"
|
|
"• Batch identification of similar faces\n"
|
|
"• Search and filter by person name\n"
|
|
"• Smart pre-selection of previously identified faces"
|
|
)
|
|
|
|
placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT)
|
|
placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
return panel
|
|
|
|
def _create_search_panel(self) -> ttk.Frame:
|
|
"""Create the search panel with full functionality"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Configure rows: title (row 0) fixed, search content (row 1) should expand
|
|
panel.rowconfigure(0, weight=0)
|
|
panel.rowconfigure(1, weight=1)
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(panel, text="🔎 Search Photos", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
# Create the search panel if we have the required dependencies
|
|
if self.db_manager and self.search_stats:
|
|
self.search_panel = SearchPanel(panel, self.db_manager, self.search_stats, self.gui_core, self.tag_manager)
|
|
search_frame = self.search_panel.create_panel()
|
|
search_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
else:
|
|
# Fallback placeholder if dependencies are not available
|
|
placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20")
|
|
placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N))
|
|
placeholder_frame.columnconfigure(0, weight=1)
|
|
# Remove weight=1 to prevent vertical centering
|
|
# placeholder_frame.rowconfigure(0, weight=1)
|
|
|
|
placeholder_text = (
|
|
"Search panel requires database and search stats to be configured.\n\n"
|
|
"This will contain the full search interface\n"
|
|
"currently available in the separate Search window.\n\n"
|
|
"Features will include:\n"
|
|
"• Search photos by person name\n"
|
|
"• Search photos by date range\n"
|
|
"• Search photos by tags\n"
|
|
"• Find photos without faces\n"
|
|
"• Find photos without tags\n"
|
|
"• Advanced filtering options"
|
|
)
|
|
|
|
placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT)
|
|
placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
return panel
|
|
|
|
def _create_modify_panel(self) -> ttk.Frame:
|
|
"""Create the modify panel with full functionality"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Configure rows: title (row 0) fixed, modify content (row 1) should expand
|
|
panel.rowconfigure(0, weight=0)
|
|
panel.rowconfigure(1, weight=1)
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(panel, text="✏️ Modify Identified", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
# Create the modify panel if we have the required dependencies
|
|
if self.db_manager and self.face_processor:
|
|
self.modify_panel = ModifyPanel(panel, self.db_manager, self.face_processor, self.gui_core, on_navigate_home=lambda: self.show_panel("home"))
|
|
modify_frame = self.modify_panel.create_panel()
|
|
modify_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
else:
|
|
# Fallback placeholder if dependencies are not available
|
|
placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20")
|
|
placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N))
|
|
placeholder_frame.columnconfigure(0, weight=1)
|
|
|
|
placeholder_text = (
|
|
"Modify panel requires database and face processor to be configured.\n\n"
|
|
"This will contain the full modify interface\n"
|
|
"currently available in the separate Modify window.\n\n"
|
|
"Features will include:\n"
|
|
"• View and edit identified people\n"
|
|
"• Rename people across all photos\n"
|
|
"• Unmatch faces from people\n"
|
|
"• Bulk operations for face management"
|
|
)
|
|
|
|
placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT)
|
|
placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
return panel
|
|
|
|
def _create_tags_panel(self) -> ttk.Frame:
|
|
"""Create the tags panel with full functionality"""
|
|
panel = ttk.Frame(self.content_frame)
|
|
|
|
# Configure panel grid for responsiveness
|
|
panel.columnconfigure(0, weight=1)
|
|
# Configure rows: title (row 0) fixed, tag manager content (row 1) should expand
|
|
panel.rowconfigure(0, weight=0)
|
|
panel.rowconfigure(1, weight=1)
|
|
|
|
# Title with larger font for full screen
|
|
title_label = tk.Label(panel, text="🏷️ Tag Manager", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
# Create the tag manager panel if we have the required dependencies
|
|
if self.db_manager and self.tag_manager and self.face_processor:
|
|
self.tag_manager_panel = TagManagerPanel(panel, self.db_manager, self.gui_core, self.tag_manager, self.face_processor, on_navigate_home=lambda: self.show_panel("home"))
|
|
tag_manager_frame = self.tag_manager_panel.create_panel()
|
|
tag_manager_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
else:
|
|
# Fallback placeholder if dependencies are not available
|
|
placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20")
|
|
placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N))
|
|
placeholder_frame.columnconfigure(0, weight=1)
|
|
|
|
placeholder_text = (
|
|
"Tag manager panel requires database, tag manager, and face processor to be configured.\n\n"
|
|
"This will contain the full tag management interface\n"
|
|
"currently available in the separate Tag Manager window.\n\n"
|
|
"Features will include:\n"
|
|
"• Photo explorer with folder grouping\n"
|
|
"• Tag management and bulk operations\n"
|
|
"• Multiple view modes (list, icons, compact)\n"
|
|
"• Tag creation, editing, and deletion\n"
|
|
"• Bulk tag linking to folders\n"
|
|
"• Photo preview and people identification"
|
|
)
|
|
|
|
placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT)
|
|
placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
return panel
|
|
|
|
def _run_scan(self):
|
|
"""Run the scan operation (migrated from original dashboard)"""
|
|
folder = self.folder_var.get().strip()
|
|
recursive = bool(self.recursive_var.get())
|
|
|
|
if not folder:
|
|
self.gui_core.create_large_messagebox(self.root, "Scan", "Please enter a folder path.", "warning")
|
|
return
|
|
|
|
# Validate folder path using path utilities
|
|
from path_utils import validate_path_exists, normalize_path
|
|
try:
|
|
folder = normalize_path(folder)
|
|
if not validate_path_exists(folder):
|
|
messagebox.showerror("Scan", f"Folder does not exist or is not accessible: {folder}", parent=self.root)
|
|
return
|
|
except ValueError as e:
|
|
messagebox.showerror("Scan", f"Invalid folder path: {e}", parent=self.root)
|
|
return
|
|
|
|
if not callable(self.on_scan):
|
|
messagebox.showinfo("Scan", "Scan functionality is not wired yet.", parent=self.root)
|
|
return
|
|
|
|
def worker():
|
|
try:
|
|
self.status_label.config(text="Scanning...")
|
|
result = self.on_scan(folder, recursive)
|
|
messagebox.showinfo("Scan", f"Scan completed. Result: {result}", parent=self.root)
|
|
self.status_label.config(text="Ready")
|
|
except Exception as e:
|
|
messagebox.showerror("Scan", f"Error during scan: {e}", parent=self.root)
|
|
self.status_label.config(text="Ready")
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
def _run_process(self):
|
|
"""Run the process operation (migrated from original dashboard)"""
|
|
if not callable(self.on_process):
|
|
messagebox.showinfo("Process", "Process functionality is not wired yet.", parent=self.root)
|
|
return
|
|
|
|
limit_value = None
|
|
if self.limit_enabled.get():
|
|
try:
|
|
limit_value = int(self.limit_var.get().strip())
|
|
if limit_value <= 0:
|
|
raise ValueError
|
|
except Exception:
|
|
messagebox.showerror("Process", "Please enter a valid positive integer for limit.", parent=self.root)
|
|
return
|
|
|
|
# Allow cancellation across worker thread
|
|
self._process_stop_event = threading.Event()
|
|
|
|
def worker():
|
|
try:
|
|
# Disable the button and initialize progress
|
|
self.process_btn.config(state="disabled", text="⏳ Processing...")
|
|
self.cancel_btn.config(state="normal")
|
|
self.progress_var.set(0)
|
|
self.progress_status_var.set("Starting processing...")
|
|
self.status_label.config(text="Processing...")
|
|
|
|
# Prepare real progress callback to be invoked per photo
|
|
def progress_callback(index, total, filename):
|
|
try:
|
|
percent = int((index / max(total, 1)) * 100)
|
|
def _update():
|
|
self.progress_var.set(percent)
|
|
self.progress_status_var.set(f"Processing {index}/{total}: {filename}")
|
|
self.root.after(0, _update)
|
|
except Exception:
|
|
pass
|
|
|
|
# Run the actual processing with real progress updates and stop event
|
|
result = self.on_process(limit_value, progress_callback, self._process_stop_event)
|
|
|
|
# Ensure progress reaches 100% at the end
|
|
self.progress_var.set(100)
|
|
self.progress_status_var.set("Processing completed successfully!")
|
|
|
|
messagebox.showinfo("Process", f"Processing completed. Result: {result}", parent=self.root)
|
|
self.status_label.config(text="Ready")
|
|
|
|
except Exception as e:
|
|
self.progress_var.set(0)
|
|
self.progress_status_var.set("Processing failed")
|
|
messagebox.showerror("Process", f"Error during processing: {e}", parent=self.root)
|
|
self.status_label.config(text="Ready")
|
|
finally:
|
|
# Re-enable the button regardless of success or failure
|
|
self.process_btn.config(state="normal", text="🚀 Start Processing")
|
|
self.cancel_btn.config(state="disabled")
|
|
# Clear stop event
|
|
self._process_stop_event = None
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
|
|
def _cancel_process(self):
|
|
"""Signal the running process to stop, if any."""
|
|
try:
|
|
if getattr(self, "_process_stop_event", None) is not None:
|
|
self._process_stop_event.set()
|
|
# Inform the user via UI immediately
|
|
def _update():
|
|
self.progress_status_var.set("Cancelling...")
|
|
self.cancel_btn.config(state="disabled")
|
|
self.root.after(0, _update)
|
|
except Exception:
|
|
pass
|
|
|