Enhance Search GUI with tag search functionality and improved result display
This commit updates the SearchGUI class to include a new feature for searching photos by tags, allowing users to input multiple tags and choose match modes (ANY or ALL). The results now display associated tags for each photo, and the GUI has been adjusted to accommodate these changes, including sorting capabilities for the tags column. Additionally, the search logic in the SearchStats class has been implemented to retrieve photos based on the specified tags, enhancing the overall search experience.
This commit is contained in:
parent
1972a69685
commit
d92f5750b7
396
search_gui.py
396
search_gui.py
@ -21,7 +21,7 @@ class SearchGUI:
|
||||
SEARCH_TYPES = [
|
||||
"Search photos by name",
|
||||
"Search photos by date (planned)",
|
||||
"Search photos by tags (planned)",
|
||||
"Search photos by tags",
|
||||
"Search photos by multiple people (planned)",
|
||||
"Most common tags (planned)",
|
||||
"Most photographed people (planned)",
|
||||
@ -44,6 +44,9 @@ class SearchGUI:
|
||||
|
||||
# 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."""
|
||||
@ -76,6 +79,23 @@ class SearchGUI:
|
||||
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")
|
||||
|
||||
@ -84,17 +104,19 @@ class SearchGUI:
|
||||
results_frame.pack(fill=tk.BOTH, expand=True)
|
||||
ttk.Label(results_frame, text="Results:", font=("Arial", 10, "bold")).pack(anchor="w")
|
||||
|
||||
columns = ("person", "open_dir", "open_photo", "path", "select")
|
||||
columns = ("person", "tags", "open_dir", "open_photo", "path", "select")
|
||||
tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse")
|
||||
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("open_photo", text="👤")
|
||||
tree.heading("path", text="Photo path", command=lambda: sort_treeview("path"))
|
||||
tree.heading("select", text="☑")
|
||||
tree.column("person", width=220, anchor="w")
|
||||
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=520, anchor="w")
|
||||
tree.column("path", width=400, anchor="w")
|
||||
tree.column("select", width=50, anchor="center")
|
||||
tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0))
|
||||
|
||||
@ -103,8 +125,6 @@ class SearchGUI:
|
||||
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)
|
||||
open_btn = ttk.Button(btns, text="Open Photo", command=lambda: open_selected())
|
||||
open_btn.pack(side=tk.LEFT, padx=(6, 0))
|
||||
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())
|
||||
@ -128,9 +148,9 @@ class SearchGUI:
|
||||
self.sort_column = col
|
||||
|
||||
# Sort the items
|
||||
# For person and path columns, sort alphabetically
|
||||
# For person, tags, and path columns, sort alphabetically
|
||||
# For icon columns, maintain original order
|
||||
if col in ['person', 'path']:
|
||||
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
|
||||
@ -148,29 +168,59 @@ class SearchGUI:
|
||||
"""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]:
|
||||
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"] = ("person", "tags", "open_dir", "open_photo", "path", "select")
|
||||
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"] = ("tags", "open_dir", "open_photo", "path", "select")
|
||||
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"] = ("person", "tags", "open_dir", "open_photo", "path", "select")
|
||||
|
||||
def clear_results():
|
||||
for i in tree.get_children():
|
||||
@ -180,20 +230,39 @@ class SearchGUI:
|
||||
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)]
|
||||
for full_name, p in rows:
|
||||
tree.insert("", tk.END, values=(full_name, "📁", "🏷️", p, "☐"))
|
||||
# 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 person name by default when results are first loaded
|
||||
# Sort by appropriate column by default when results are first loaded
|
||||
if rows and self.sort_column is None:
|
||||
# Force ascending sort on first load
|
||||
self.sort_column = "person"
|
||||
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, "person"), child) for child in tree.get_children('')]
|
||||
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):
|
||||
@ -204,7 +273,7 @@ class SearchGUI:
|
||||
def do_search():
|
||||
clear_results()
|
||||
choice = search_type_var.get()
|
||||
if choice == self.SEARCH_TYPES[0]:
|
||||
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)
|
||||
@ -213,24 +282,26 @@ class SearchGUI:
|
||||
if not rows:
|
||||
messagebox.showinfo("Search", f"No photos found for '{query}'.", parent=root)
|
||||
add_results(rows)
|
||||
|
||||
def open_selected():
|
||||
sel = tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
vals = tree.item(sel[0], "values")
|
||||
path = vals[3] if len(vals) >= 4 else None
|
||||
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 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:
|
||||
@ -248,10 +319,10 @@ class SearchGUI:
|
||||
|
||||
def toggle_photo_selection(row_id, vals):
|
||||
"""Toggle checkbox selection for a photo."""
|
||||
if len(vals) < 5:
|
||||
if len(vals) < 6:
|
||||
return
|
||||
path = vals[3]
|
||||
current_state = vals[4]
|
||||
path = vals[4] # Photo path is now in column 4
|
||||
current_state = vals[5] # Checkbox is now in column 5
|
||||
if current_state == "☐":
|
||||
# Select photo
|
||||
new_state = "☑"
|
||||
@ -267,7 +338,7 @@ class SearchGUI:
|
||||
|
||||
# Update the treeview
|
||||
new_vals = list(vals)
|
||||
new_vals[4] = new_state
|
||||
new_vals[5] = new_state
|
||||
tree.item(row_id, values=new_vals)
|
||||
|
||||
def tag_selected_photos():
|
||||
@ -304,11 +375,12 @@ class SearchGUI:
|
||||
# Update all checkboxes to unselected state
|
||||
for item in tree.get_children():
|
||||
vals = tree.item(item, "values")
|
||||
if len(vals) >= 5 and vals[4] == "☑":
|
||||
if len(vals) >= 6 and vals[5] == "☑":
|
||||
new_vals = list(vals)
|
||||
new_vals[4] = "☐"
|
||||
new_vals[5] = "☐"
|
||||
tree.item(item, values=new_vals)
|
||||
|
||||
|
||||
def show_photo_tags(photo_path):
|
||||
"""Show tags for a specific photo in a popup."""
|
||||
# Get photo ID
|
||||
@ -376,6 +448,112 @@ class SearchGUI:
|
||||
# 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
|
||||
@ -419,6 +597,10 @@ class SearchGUI:
|
||||
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))
|
||||
@ -497,6 +679,10 @@ class SearchGUI:
|
||||
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("")
|
||||
@ -596,9 +782,11 @@ class SearchGUI:
|
||||
|
||||
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
|
||||
@ -614,10 +802,51 @@ class SearchGUI:
|
||||
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[4] # Photo path is at index 4
|
||||
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 1)
|
||||
new_vals = list(vals)
|
||||
new_vals[1] = 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=popup.destroy).pack(side=tk.RIGHT)
|
||||
ttk.Button(bottom_frame, text="Close", command=close_dialog).pack(side=tk.RIGHT)
|
||||
refresh_tag_list()
|
||||
|
||||
# Click handling on icon columns
|
||||
@ -630,14 +859,33 @@ class SearchGUI:
|
||||
if not row_id or not col_id:
|
||||
return
|
||||
vals = tree.item(row_id, "values")
|
||||
if not vals or len(vals) < 5:
|
||||
if not vals or len(vals) < 6:
|
||||
return
|
||||
path = vals[3]
|
||||
if col_id == "#2":
|
||||
|
||||
# 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, so all columns shift left by 1
|
||||
open_dir_col = "#2" # open_dir is now column 2
|
||||
face_col = "#3" # open_photo is now column 3
|
||||
path_col = "#4" # path is now column 4
|
||||
select_col = "#5" # select is now column 5
|
||||
path_index = 4 # path is at index 4 in values array
|
||||
else:
|
||||
# Name search: all columns visible
|
||||
open_dir_col = "#3" # open_dir is column 3
|
||||
face_col = "#4" # open_photo is column 4
|
||||
path_col = "#5" # path is column 5
|
||||
select_col = "#6" # select is column 6
|
||||
path_index = 4 # path is at index 4 in values array
|
||||
|
||||
path = vals[path_index] # Photo path
|
||||
if col_id == open_dir_col: # Open directory column
|
||||
open_dir(path)
|
||||
elif col_id == "#3": # Tag icon column
|
||||
show_photo_tags(path)
|
||||
elif col_id == "#4": # Photo path column - clickable to open photo
|
||||
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]
|
||||
@ -649,7 +897,7 @@ class SearchGUI:
|
||||
subprocess.run(["xdg-open", path], check=False)
|
||||
except Exception:
|
||||
messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root)
|
||||
elif col_id == "#5": # Checkbox column
|
||||
elif col_id == select_col: # Checkbox column
|
||||
toggle_photo_selection(row_id, vals)
|
||||
|
||||
# Tooltip for icon cells
|
||||
@ -683,19 +931,45 @@ class SearchGUI:
|
||||
return
|
||||
col_id = tree.identify_column(event.x)
|
||||
row_id = tree.identify_row(event.y)
|
||||
if col_id == "#2":
|
||||
tree.config(cursor="hand2")
|
||||
show_tooltip(tree, event.x_root, event.y_root, "Open file location")
|
||||
elif col_id == "#3":
|
||||
tree.config(cursor="hand2")
|
||||
|
||||
# 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, so all columns shift left by 1
|
||||
tags_col = "#1" # tags is now column 1
|
||||
open_dir_col = "#2" # open_dir is now column 2
|
||||
face_col = "#3" # open_photo is now column 3
|
||||
path_col = "#4" # path is now column 4
|
||||
path_index = 4 # path is at index 4 in values array
|
||||
else:
|
||||
# Name search: all columns visible
|
||||
tags_col = "#2" # tags is column 2
|
||||
open_dir_col = "#3" # open_dir is column 3
|
||||
face_col = "#4" # open_photo is column 4
|
||||
path_col = "#5" # path is column 5
|
||||
path_index = 4 # path is at index 4 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) >= 4:
|
||||
path = vals[3]
|
||||
tags_text = get_photo_tags_tooltip(path)
|
||||
show_tooltip(tree, event.x_root, event.y_root, tags_text)
|
||||
elif col_id == "#4":
|
||||
if len(vals) >= 2:
|
||||
tags_text = vals[1] # Tags are at index 1
|
||||
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:
|
||||
@ -710,6 +984,8 @@ class SearchGUI:
|
||||
|
||||
# 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()
|
||||
|
||||
@ -191,10 +191,69 @@ class SearchStats:
|
||||
return []
|
||||
|
||||
def search_photos_by_tags(self, tags: List[str], match_all: bool = False) -> List[Tuple]:
|
||||
"""Search photos by tags"""
|
||||
# This would need to be implemented in the database module
|
||||
# For now, return empty list
|
||||
return []
|
||||
"""Search photos by tags
|
||||
|
||||
Args:
|
||||
tags: List of tag names to search for
|
||||
match_all: If True, photos must have ALL tags. If False, photos with ANY tag.
|
||||
|
||||
Returns:
|
||||
List of tuples: (photo_path, tag_info)
|
||||
"""
|
||||
if not tags:
|
||||
return []
|
||||
|
||||
# Get tag IDs for the provided tag names
|
||||
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
||||
tag_ids = []
|
||||
|
||||
for tag_name in tags:
|
||||
if tag_name in tag_name_to_id:
|
||||
tag_ids.append(tag_name_to_id[tag_name])
|
||||
|
||||
if not tag_ids:
|
||||
return []
|
||||
|
||||
results = []
|
||||
try:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if match_all:
|
||||
# Photos that have ALL specified tags
|
||||
placeholders = ",".join(["?"] * len(tag_ids))
|
||||
cursor.execute(f'''
|
||||
SELECT p.path, GROUP_CONCAT(t.tag_name, ', ') as tag_names
|
||||
FROM photos p
|
||||
JOIN phototaglinkage ptl ON p.id = ptl.photo_id
|
||||
JOIN tags t ON ptl.tag_id = t.id
|
||||
WHERE ptl.tag_id IN ({placeholders})
|
||||
GROUP BY p.id, p.path
|
||||
HAVING COUNT(DISTINCT ptl.tag_id) = ?
|
||||
ORDER BY p.path
|
||||
''', tuple(tag_ids) + (len(tag_ids),))
|
||||
else:
|
||||
# Photos that have ANY of the specified tags
|
||||
placeholders = ",".join(["?"] * len(tag_ids))
|
||||
cursor.execute(f'''
|
||||
SELECT DISTINCT p.path, GROUP_CONCAT(t.tag_name, ', ') as tag_names
|
||||
FROM photos p
|
||||
JOIN phototaglinkage ptl ON p.id = ptl.photo_id
|
||||
JOIN tags t ON ptl.tag_id = t.id
|
||||
WHERE ptl.tag_id IN ({placeholders})
|
||||
GROUP BY p.id, p.path
|
||||
ORDER BY p.path
|
||||
''', tuple(tag_ids))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
if row and row[0]:
|
||||
results.append((row[0], row[1] or ""))
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose > 0:
|
||||
print(f"Error searching photos by tags: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def search_photos_by_people(self, people: List[str]) -> List[Tuple]:
|
||||
"""Search photos by people"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user