This commit introduces the ModifyPanel class into the Dashboard GUI, providing a fully integrated interface for editing identified faces. Users can now view and modify person details, unmatch faces, and perform bulk operations with visual confirmation. The panel includes a responsive layout, search functionality for filtering people by last name, and a calendar interface for date selection. The README has been updated to reflect the new capabilities of the Modify Panel, emphasizing its full functionality and improved user experience in managing photo identifications.
1958 lines
94 KiB
Python
1958 lines
94 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 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 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 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 = [
|
|
("📁 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 person name"),
|
|
("✏️ Edit Identified", "modify", "View and modify identified faces"),
|
|
("🏷️ Tags", "tags", "Manage photo tags"),
|
|
]
|
|
|
|
for i, (text, panel_name, tooltip) in enumerate(menu_buttons):
|
|
btn = ttk.Button(
|
|
buttons_frame,
|
|
text=text,
|
|
command=lambda p=panel_name: self.show_panel(p),
|
|
width=12 # Fixed width for consistent layout
|
|
)
|
|
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()
|
|
|
|
# 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()
|
|
|
|
# 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()
|
|
|
|
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 - Find photos by person name\n"
|
|
"• ✏️ Modify - Edit face identifications\n"
|
|
"• 🏷️ Tags - 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
|
|
process_btn = ttk.Button(form_frame, text="🚀 Start Processing", command=self._run_process)
|
|
process_btn.grid(row=1, column=0, sticky=tk.W, pady=(20, 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)
|
|
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)
|
|
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)
|
|
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 (placeholder)"""
|
|
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_label = tk.Label(panel, text="🏷️ Tag Manager", font=("Arial", 24, "bold"))
|
|
title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20))
|
|
|
|
placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", 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 = "Tag management functionality will be integrated here."
|
|
placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14))
|
|
placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N))
|
|
|
|
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:
|
|
messagebox.showwarning("Scan", "Please enter a folder path.", parent=self.root)
|
|
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
|
|
|
|
def worker():
|
|
try:
|
|
self.status_label.config(text="Processing...")
|
|
result = self.on_process(limit_value)
|
|
messagebox.showinfo("Process", f"Processing completed. Result: {result}", parent=self.root)
|
|
self.status_label.config(text="Ready")
|
|
except Exception as e:
|
|
messagebox.showerror("Process", f"Error during processing: {e}", parent=self.root)
|
|
self.status_label.config(text="Ready")
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
|