This commit updates the DatabaseManager to support case-insensitive tag lookups and additions, ensuring consistent tag handling. The SearchGUI and TagManagerGUI have been modified to reflect these changes, allowing for improved user experience when managing tags. Additionally, the search logic in SearchStats and TagManagement has been adjusted for case-insensitive tag ID retrieval, enhancing overall functionality and reliability in tag management across the application.
996 lines
46 KiB
Python
996 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Search GUI implementation for PunimTag
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
from typing import List
|
|
|
|
from gui_core import GUICore
|
|
from search_stats import SearchStats
|
|
from database import DatabaseManager
|
|
from tag_management import TagManager
|
|
|
|
|
|
class SearchGUI:
|
|
"""GUI for searching photos by different criteria."""
|
|
|
|
SEARCH_TYPES = [
|
|
"Search photos by name",
|
|
"Search photos by date (planned)",
|
|
"Search photos by tags",
|
|
"Search photos by multiple people (planned)",
|
|
"Most common tags (planned)",
|
|
"Most photographed people (planned)",
|
|
"Photos without faces (planned)",
|
|
"Photos without tags (planned)",
|
|
"Duplicate faces (planned)",
|
|
"Face quality distribution (planned)",
|
|
]
|
|
|
|
def __init__(self, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, tag_manager: TagManager = None, verbose: int = 0):
|
|
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 search_gui(self) -> int:
|
|
"""Open the Search GUI window."""
|
|
root = tk.Tk()
|
|
root.title("Search Photos")
|
|
root.resizable(True, True)
|
|
|
|
# Hide to center and size
|
|
root.withdraw()
|
|
|
|
main = ttk.Frame(root, padding="10")
|
|
main.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Search type selector
|
|
type_frame = ttk.Frame(main)
|
|
type_frame.pack(fill=tk.X)
|
|
ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
|
|
search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0])
|
|
type_combo = ttk.Combobox(type_frame, textvariable=search_type_var, values=self.SEARCH_TYPES, state="readonly")
|
|
type_combo.pack(side=tk.LEFT, padx=(8, 0), fill=tk.X, expand=True)
|
|
|
|
# Inputs area
|
|
inputs = ttk.Frame(main)
|
|
inputs.pack(fill=tk.X, pady=(10, 6))
|
|
|
|
# Name search input
|
|
name_frame = ttk.Frame(inputs)
|
|
ttk.Label(name_frame, text="Person name:").pack(side=tk.LEFT)
|
|
name_var = tk.StringVar()
|
|
name_entry = ttk.Entry(name_frame, textvariable=name_var)
|
|
name_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True)
|
|
|
|
# Tag search input
|
|
tag_frame = ttk.Frame(inputs)
|
|
ttk.Label(tag_frame, text="Tags:").pack(side=tk.LEFT)
|
|
tag_var = tk.StringVar()
|
|
tag_entry = ttk.Entry(tag_frame, textvariable=tag_var)
|
|
tag_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True)
|
|
ttk.Label(tag_frame, text="(comma-separated)").pack(side=tk.LEFT, padx=(6, 0))
|
|
|
|
# Tag search mode
|
|
tag_mode_frame = ttk.Frame(inputs)
|
|
ttk.Label(tag_mode_frame, text="Match mode:").pack(side=tk.LEFT)
|
|
tag_mode_var = tk.StringVar(value="ANY")
|
|
tag_mode_combo = ttk.Combobox(tag_mode_frame, textvariable=tag_mode_var,
|
|
values=["ANY", "ALL"], state="readonly", width=8)
|
|
tag_mode_combo.pack(side=tk.LEFT, padx=(6, 0))
|
|
ttk.Label(tag_mode_frame, text="(ANY = photos with any tag, ALL = photos with all tags)").pack(side=tk.LEFT, padx=(6, 0))
|
|
|
|
# Planned inputs (stubs)
|
|
planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888")
|
|
|
|
# Results area
|
|
results_frame = ttk.Frame(main)
|
|
results_frame.pack(fill=tk.BOTH, expand=True)
|
|
ttk.Label(results_frame, text="Results:", font=("Arial", 10, "bold")).pack(anchor="w")
|
|
|
|
columns = ("select", "person", "tags", "open_dir", "open_photo", "path")
|
|
tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse")
|
|
tree.heading("select", text="☑")
|
|
tree.heading("person", text="Person", command=lambda: sort_treeview("person"))
|
|
tree.heading("tags", text="Tags", command=lambda: sort_treeview("tags"))
|
|
tree.heading("open_dir", text="📁")
|
|
tree.heading("open_photo", text="👤")
|
|
tree.heading("path", text="Photo path", command=lambda: sort_treeview("path"))
|
|
tree.column("select", width=50, anchor="center")
|
|
tree.column("person", width=180, anchor="w")
|
|
tree.column("tags", width=200, anchor="w")
|
|
tree.column("open_dir", width=50, anchor="center")
|
|
tree.column("open_photo", width=50, anchor="center")
|
|
tree.column("path", width=400, anchor="w")
|
|
tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0))
|
|
|
|
# Buttons
|
|
btns = ttk.Frame(main)
|
|
btns.pack(fill=tk.X, pady=(8, 0))
|
|
search_btn = ttk.Button(btns, text="Search", command=lambda: do_search())
|
|
search_btn.pack(side=tk.LEFT)
|
|
tag_btn = ttk.Button(btns, text="Tag selected photos", command=lambda: tag_selected_photos())
|
|
tag_btn.pack(side=tk.LEFT, padx=(6, 0))
|
|
clear_btn = ttk.Button(btns, text="Clear all selected", command=lambda: clear_all_selected())
|
|
clear_btn.pack(side=tk.LEFT, padx=(6, 0))
|
|
close_btn = ttk.Button(btns, text="Close", command=lambda: root.destroy())
|
|
close_btn.pack(side=tk.RIGHT)
|
|
|
|
# Sorting functionality
|
|
def sort_treeview(col: str):
|
|
"""Sort the treeview by the specified column."""
|
|
# Get all items and their values
|
|
items = [(tree.set(child, col), child) for child in 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 icon columns, maintain original order
|
|
if col in ['person', 'tags', 'path']:
|
|
items.sort(key=lambda x: x[0].lower(), 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):
|
|
tree.move(child, '', index)
|
|
|
|
# Update header display
|
|
update_header_display()
|
|
|
|
def update_header_display():
|
|
"""Update header display to show sort indicators."""
|
|
# Reset all headers
|
|
tree.heading("person", text="Person")
|
|
tree.heading("tags", text="Tags")
|
|
tree.heading("path", text="Photo path")
|
|
|
|
# Add sort indicator to current sort column
|
|
if self.sort_column == "person":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
tree.heading("person", text="Person" + indicator)
|
|
elif self.sort_column == "tags":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
tree.heading("tags", text="Tags" + indicator)
|
|
elif self.sort_column == "path":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
tree.heading("path", text="Photo path" + indicator)
|
|
|
|
# Behavior
|
|
def switch_inputs(*_):
|
|
# Clear results when search type changes
|
|
clear_results()
|
|
|
|
for w in inputs.winfo_children():
|
|
w.pack_forget()
|
|
choice = search_type_var.get()
|
|
if choice == self.SEARCH_TYPES[0]: # Search photos by name
|
|
name_frame.pack(fill=tk.X)
|
|
name_entry.configure(state="normal")
|
|
search_btn.configure(state="normal")
|
|
# Show person column for name search
|
|
tree.column("person", width=180, minwidth=50, anchor="w")
|
|
tree.heading("person", text="Person", command=lambda: sort_treeview("person"))
|
|
# Restore all columns to display
|
|
tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path")
|
|
elif choice == self.SEARCH_TYPES[2]: # Search photos by tags
|
|
tag_frame.pack(fill=tk.X)
|
|
tag_mode_frame.pack(fill=tk.X, pady=(4, 0))
|
|
tag_entry.configure(state="normal")
|
|
tag_mode_combo.configure(state="readonly")
|
|
search_btn.configure(state="normal")
|
|
# Hide person column completely for tag search
|
|
tree.column("person", width=0, minwidth=0, anchor="w")
|
|
tree.heading("person", text="")
|
|
# Also hide the column from display
|
|
tree["displaycolumns"] = ("select", "tags", "open_dir", "open_photo", "path")
|
|
else:
|
|
planned_label.pack(anchor="w")
|
|
name_entry.configure(state="disabled")
|
|
tag_entry.configure(state="disabled")
|
|
tag_mode_combo.configure(state="disabled")
|
|
search_btn.configure(state="disabled")
|
|
# Show person column for other search types
|
|
tree.column("person", width=180, minwidth=50, anchor="w")
|
|
tree.heading("person", text="Person", command=lambda: sort_treeview("person"))
|
|
# Restore all columns to display
|
|
tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path")
|
|
|
|
def clear_results():
|
|
for i in tree.get_children():
|
|
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()
|
|
update_header_display()
|
|
|
|
def add_results(rows: List[tuple]):
|
|
# rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search
|
|
for row in rows:
|
|
if len(row) == 2:
|
|
if 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 = get_photo_tags_for_display(path)
|
|
tree.insert("", tk.END, values=("☐", "", photo_tags, "📁", "👤", path))
|
|
else:
|
|
# For name search: (full_name, path) - show person column
|
|
full_name, p = row
|
|
# Get tags for this photo
|
|
photo_tags = get_photo_tags_for_display(p)
|
|
tree.insert("", tk.END, values=("☐", full_name, photo_tags, "📁", "👤", p))
|
|
|
|
# Sort by appropriate column by default when results are first loaded
|
|
if rows and self.sort_column is None:
|
|
if search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search
|
|
# Sort by tags column for tag search
|
|
self.sort_column = "tags"
|
|
else:
|
|
# Sort by person column for name search
|
|
self.sort_column = "person"
|
|
|
|
self.sort_reverse = False
|
|
# Get all items and sort them directly
|
|
items = [(tree.set(child, self.sort_column), child) for child in tree.get_children('')]
|
|
items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending
|
|
# Reorder items in treeview
|
|
for index, (val, child) in enumerate(items):
|
|
tree.move(child, '', index)
|
|
# Update header display
|
|
update_header_display()
|
|
|
|
def do_search():
|
|
clear_results()
|
|
choice = search_type_var.get()
|
|
if choice == self.SEARCH_TYPES[0]: # Search photos by name
|
|
query = name_var.get().strip()
|
|
if not query:
|
|
messagebox.showinfo("Search", "Please enter a name to search.", parent=root)
|
|
return
|
|
rows = self.search_stats.search_faces(query)
|
|
if not rows:
|
|
messagebox.showinfo("Search", f"No photos found for '{query}'.", parent=root)
|
|
add_results(rows)
|
|
elif choice == self.SEARCH_TYPES[2]: # Search photos by tags
|
|
tag_query = tag_var.get().strip()
|
|
if not tag_query:
|
|
messagebox.showinfo("Search", "Please enter tags to search for.", parent=root)
|
|
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=root)
|
|
return
|
|
|
|
# Determine match mode
|
|
match_all = (tag_mode_var.get() == "ALL")
|
|
|
|
rows = self.search_stats.search_photos_by_tags(tags, match_all)
|
|
if not rows:
|
|
mode_text = "all" if match_all else "any"
|
|
messagebox.showinfo("Search", f"No photos found with {mode_text} of the tags: {', '.join(tags)}", parent=root)
|
|
add_results(rows)
|
|
|
|
def open_dir(path: str):
|
|
try:
|
|
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=root)
|
|
|
|
def toggle_photo_selection(row_id, vals):
|
|
"""Toggle checkbox selection for a photo."""
|
|
if len(vals) < 6:
|
|
return
|
|
current_state = vals[0] # Checkbox is now in column 0 (first)
|
|
path = vals[5] # Photo path is now in column 5 (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
|
|
tree.item(row_id, values=new_vals)
|
|
|
|
def tag_selected_photos():
|
|
"""Open linkage dialog for selected photos."""
|
|
if not self.selected_photos:
|
|
messagebox.showinfo("Tag Photos", "Please select photos to tag first.", parent=root)
|
|
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=root)
|
|
return
|
|
|
|
# Open the linkage dialog
|
|
open_linkage_dialog(selected_photo_ids)
|
|
|
|
def clear_all_selected():
|
|
"""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 tree.get_children():
|
|
vals = tree.item(item, "values")
|
|
if len(vals) >= 6 and vals[0] == "☑":
|
|
new_vals = list(vals)
|
|
new_vals[0] = "☐"
|
|
tree.item(item, values=new_vals)
|
|
|
|
|
|
def show_photo_tags(photo_path):
|
|
"""Show tags for a specific photo in a popup."""
|
|
# Get photo ID
|
|
photo_id = None
|
|
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 result:
|
|
photo_id = result[0]
|
|
|
|
if not photo_id:
|
|
messagebox.showerror("Error", "Could not find photo ID", parent=root)
|
|
return
|
|
|
|
# Get tags for this photo
|
|
tag_names = []
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
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()]
|
|
|
|
# Create popup
|
|
popup = tk.Toplevel(root)
|
|
popup.title("Photo Tags")
|
|
popup.transient(root)
|
|
popup.grab_set()
|
|
popup.geometry("300x200")
|
|
|
|
# Center the popup
|
|
popup.update_idletasks()
|
|
x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
|
|
y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
|
|
popup.geometry(f"+{x}+{y}")
|
|
|
|
frame = ttk.Frame(popup, padding="10")
|
|
frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Photo filename
|
|
filename = os.path.basename(photo_path)
|
|
ttk.Label(frame, text=f"Tags for: {filename}", font=("Arial", 10, "bold")).pack(anchor="w", pady=(0, 10))
|
|
|
|
if tag_names:
|
|
# Create scrollable list
|
|
canvas = tk.Canvas(frame, height=100)
|
|
scrollbar = ttk.Scrollbar(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")
|
|
|
|
for tag_name in tag_names:
|
|
ttk.Label(scrollable_frame, text=f"• {tag_name}").pack(anchor="w", pady=1)
|
|
else:
|
|
ttk.Label(frame, text="No tags found for this photo", foreground="gray").pack(anchor="w")
|
|
|
|
# Close button
|
|
ttk.Button(frame, text="Close", command=popup.destroy).pack(pady=(10, 0))
|
|
|
|
def get_person_name_for_photo(photo_path):
|
|
"""Get person name for a photo (if any faces are identified)."""
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT DISTINCT pe.first_name, pe.last_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
|
|
LIMIT 1
|
|
''', (photo_path,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
first = (result[0] or "").strip()
|
|
last = (result[1] or "").strip()
|
|
return f"{first} {last}".strip() or "Unknown"
|
|
except Exception:
|
|
pass
|
|
return "No person identified"
|
|
|
|
def get_photo_tags_for_display(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_people_tooltip(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 get_photo_tags_tooltip(photo_path):
|
|
"""Get tags for a photo to display in tooltip."""
|
|
# Get photo ID
|
|
photo_id = None
|
|
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 result:
|
|
photo_id = result[0]
|
|
|
|
if not photo_id:
|
|
return "No photo found"
|
|
|
|
# Get tags for this photo
|
|
tag_names = []
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
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()]
|
|
|
|
if tag_names:
|
|
if len(tag_names) <= 5:
|
|
return f"Tags: {', '.join(tag_names)}"
|
|
else:
|
|
return f"Tags: {', '.join(tag_names[:5])}... (+{len(tag_names)-5} more)"
|
|
else:
|
|
return "No tags"
|
|
|
|
def open_linkage_dialog(photo_ids):
|
|
"""Open the linkage dialog for selected photos using tag manager functionality."""
|
|
popup = tk.Toplevel(root)
|
|
popup.title("Tag Selected Photos")
|
|
popup.transient(root)
|
|
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 get_saved_tag_types_for_photo(photo_id: int):
|
|
"""Get saved linkage types for a photo {tag_id: type_int}"""
|
|
types = {}
|
|
try:
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
|
|
for row in cursor.fetchall():
|
|
try:
|
|
types[row[0]] = int(row[1]) if row[1] is not None else 0
|
|
except Exception:
|
|
types[row[0]] = 0
|
|
except Exception:
|
|
pass
|
|
return types
|
|
|
|
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 tree.get_children():
|
|
vals = tree.item(item, "values")
|
|
if len(vals) >= 6:
|
|
photo_path = vals[5] # Photo path is at index 5
|
|
if photo_path in affected_photo_paths:
|
|
# Get current tags for this photo from cache
|
|
current_tags = get_photo_tags_for_display(photo_path)
|
|
# Update the tags column (index 2)
|
|
new_vals = list(vals)
|
|
new_vals[2] = current_tags
|
|
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()
|
|
|
|
# Click handling on icon columns
|
|
def on_tree_click(event):
|
|
region = tree.identify("region", event.x, event.y)
|
|
if region != "cell":
|
|
return
|
|
row_id = tree.identify_row(event.y)
|
|
col_id = tree.identify_column(event.x) # '#1', '#2', ...
|
|
if not row_id or not col_id:
|
|
return
|
|
vals = tree.item(row_id, "values")
|
|
if not vals or len(vals) < 6:
|
|
return
|
|
|
|
# Determine column offsets based on search type
|
|
is_tag_search = (search_type_var.get() == self.SEARCH_TYPES[2])
|
|
if is_tag_search:
|
|
# Tag search: person column is hidden, select is first
|
|
select_col = "#1" # select is now column 1
|
|
open_dir_col = "#3" # open_dir is now column 3
|
|
face_col = "#4" # open_photo is now column 4
|
|
path_col = "#5" # path is now column 5
|
|
path_index = 5 # path is at index 5 in values array
|
|
else:
|
|
# Name search: all columns visible, select is first
|
|
select_col = "#1" # select is now 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 = 5 # path is at index 5 in values array
|
|
|
|
path = vals[path_index] # Photo path
|
|
if col_id == open_dir_col: # Open directory column
|
|
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:
|
|
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=root)
|
|
elif col_id == select_col: # Checkbox column
|
|
toggle_photo_selection(row_id, vals)
|
|
|
|
# Tooltip for icon cells
|
|
tooltip = None
|
|
def show_tooltip(widget, x, y, text: str):
|
|
nonlocal tooltip
|
|
hide_tooltip()
|
|
try:
|
|
tooltip = tk.Toplevel(widget)
|
|
tooltip.wm_overrideredirect(True)
|
|
tooltip.wm_geometry(f"+{x+12}+{y+12}")
|
|
lbl = tk.Label(tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9))
|
|
lbl.pack()
|
|
except Exception:
|
|
tooltip = None
|
|
|
|
def hide_tooltip(*_):
|
|
nonlocal tooltip
|
|
if tooltip is not None:
|
|
try:
|
|
tooltip.destroy()
|
|
except Exception:
|
|
pass
|
|
tooltip = None
|
|
|
|
def on_tree_motion(event):
|
|
region = tree.identify("region", event.x, event.y)
|
|
if region != "cell":
|
|
hide_tooltip()
|
|
tree.config(cursor="")
|
|
return
|
|
col_id = tree.identify_column(event.x)
|
|
row_id = tree.identify_row(event.y)
|
|
|
|
# Determine column offsets based on search type
|
|
is_tag_search = (search_type_var.get() == self.SEARCH_TYPES[2])
|
|
if is_tag_search:
|
|
# Tag search: person column is hidden, select is first
|
|
tags_col = "#2" # tags is now column 2
|
|
open_dir_col = "#3" # open_dir is now column 3
|
|
face_col = "#4" # open_photo is now column 4
|
|
path_col = "#5" # path is now column 5
|
|
path_index = 5 # path is at index 5 in values array
|
|
else:
|
|
# Name search: all columns visible, select is first
|
|
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 = 5 # path is at index 5 in values array
|
|
|
|
if col_id == tags_col: # Tags column
|
|
tree.config(cursor="")
|
|
# Show tags tooltip
|
|
if row_id:
|
|
vals = tree.item(row_id, "values")
|
|
if len(vals) >= 3:
|
|
tags_text = vals[2] # Tags are at index 2 (after select and person)
|
|
show_tooltip(tree, event.x_root, event.y_root, f"Tags: {tags_text}")
|
|
elif col_id == open_dir_col: # Open directory column
|
|
tree.config(cursor="hand2")
|
|
show_tooltip(tree, event.x_root, event.y_root, "Open file location")
|
|
elif col_id == face_col: # Face icon column
|
|
tree.config(cursor="hand2")
|
|
# Show people tooltip
|
|
if row_id:
|
|
vals = tree.item(row_id, "values")
|
|
if len(vals) >= 5:
|
|
path = vals[path_index]
|
|
people_text = get_photo_people_tooltip(path)
|
|
show_tooltip(tree, event.x_root, event.y_root, people_text)
|
|
elif col_id == path_col: # Photo path column
|
|
tree.config(cursor="hand2")
|
|
show_tooltip(tree, event.x_root, event.y_root, "Open photo")
|
|
else:
|
|
tree.config(cursor="")
|
|
hide_tooltip()
|
|
|
|
type_combo.bind("<<ComboboxSelected>>", switch_inputs)
|
|
switch_inputs()
|
|
tree.bind("<Button-1>", on_tree_click)
|
|
tree.bind("<Motion>", on_tree_motion)
|
|
tree.bind("<Leave>", hide_tooltip)
|
|
|
|
# Enter key in name field triggers search
|
|
name_entry.bind("<Return>", lambda e: do_search())
|
|
# Enter key in tag field triggers search
|
|
tag_entry.bind("<Return>", lambda e: do_search())
|
|
|
|
# Show and center
|
|
root.update_idletasks()
|
|
# Widened to ensure all columns are visible by default
|
|
self.gui_core.center_window(root, 1000, 520)
|
|
root.deiconify()
|
|
root.mainloop()
|
|
return 0
|
|
|
|
|