punimtag/tag_manager_gui.py
tanyar09 64c29f24de Add TagManagerGUI for enhanced tag management in PhotoTagger
This commit introduces the TagManagerGUI class, which provides a comprehensive interface for managing photo tags within the PhotoTagger application. The new GUI preserves the legacy functionality while integrating into the refactored architecture, allowing users to add, edit, and delete tags efficiently. The PhotoTagger class is updated to utilize this new feature, streamlining the tag management process. Additionally, relevant documentation in the README has been updated to reflect these changes and provide usage instructions.
2025-10-06 12:43:30 -04:00

992 lines
54 KiB
Python

"""
Legacy Tag Manager GUI ported intact into the refactored architecture.
This module preserves the original GUI and functionality exactly as in the old version.
"""
from typing import List, Dict
class TagManagerGUI:
"""GUI for managing tags, preserved from legacy implementation."""
def __init__(self, db_manager, gui_core, tag_manager, face_processor, verbose: int = 0):
self.db = db_manager
self.gui_core = gui_core
self.tag_manager = tag_manager
self.face_processor = face_processor
self.verbose = verbose
def tag_management(self) -> int:
"""Tag management GUI - file explorer-like interface for managing photo tags (legacy UI)."""
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
from PIL import Image, ImageTk
import os
# Create the main window
root = tk.Tk()
root.title("Tag Management - Photo Explorer")
root.resizable(True, True)
# Track window state to prevent multiple destroy calls
window_destroyed = False
temp_crops: List[str] = []
photo_images: List[ImageTk.PhotoImage] = [] # Keep PhotoImage refs alive
# Track folder expand/collapse states
folder_states: Dict[str, bool] = {}
# Track pending tag changes/removals using tag IDs
pending_tag_changes: Dict[int, List[int]] = {}
pending_tag_removals: Dict[int, List[int]] = {}
existing_tags: List[str] = []
tag_id_to_name: Dict[int, str] = {}
tag_name_to_id: Dict[str, int] = {}
# Hide window initially to prevent flash at corner
root.withdraw()
# Close handler
def on_closing():
nonlocal window_destroyed
for crop in list(temp_crops):
try:
if os.path.exists(crop):
os.remove(crop)
except Exception:
pass
temp_crops.clear()
if not window_destroyed:
window_destroyed = True
try:
root.destroy()
except tk.TclError:
pass
root.protocol("WM_DELETE_WINDOW", on_closing)
# Window size saving (legacy behavior)
try:
self.gui_core.setup_window_size_saving(root, "gui_config.json")
except Exception:
pass
# Main containers
main_frame = ttk.Frame(root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1)
main_frame.rowconfigure(1, weight=1)
main_frame.rowconfigure(2, weight=0)
header_frame = ttk.Frame(main_frame)
header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
header_frame.columnconfigure(1, weight=1)
title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold"))
title_label.grid(row=0, column=0, sticky=tk.W)
# View controls
view_frame = ttk.Frame(header_frame)
view_frame.grid(row=0, column=1, sticky=tk.E)
view_mode_var = tk.StringVar(value="list")
ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5))
ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list", command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5))
ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons", command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5))
ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact", command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT)
# Manage Tags button (legacy dialog)
def open_manage_tags_dialog():
dialog = tk.Toplevel(root)
dialog.title("Manage Tags")
dialog.transient(root)
dialog.grab_set()
dialog.geometry("500x500")
top_frame = ttk.Frame(dialog, padding="8")
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
list_frame = ttk.Frame(dialog, padding="8")
list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
bottom_frame = ttk.Frame(dialog, padding="8")
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E))
dialog.columnconfigure(0, weight=1)
dialog.rowconfigure(1, weight=1)
new_tag_var = tk.StringVar()
new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30)
new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E))
def add_new_tag():
tag_name = new_tag_var.get().strip()
if not tag_name:
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
conn.commit()
new_tag_var.set("")
refresh_tag_list()
load_existing_tags()
switch_view_mode(view_mode_var.get())
except Exception as e:
messagebox.showerror("Error", f"Failed to add tag: {e}")
add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag)
add_btn.grid(row=0, column=1, sticky=tk.W)
top_frame.columnconfigure(0, weight=1)
canvas = tk.Canvas(list_frame, highlightthickness=0)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
rows_container = ttk.Frame(canvas)
canvas.create_window((0, 0), window=rows_container, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
list_frame.columnconfigure(0, weight=1)
list_frame.rowconfigure(0, weight=1)
rows_container.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
selected_tag_vars: Dict[int, tk.BooleanVar] = {}
current_tags: List[Dict] = []
def refresh_tag_list():
for child in list(rows_container.winfo_children()):
child.destroy()
selected_tag_vars.clear()
current_tags.clear()
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE')
for row in cursor.fetchall():
current_tags.append({'id': row[0], 'tag_name': row[1]})
except Exception as e:
messagebox.showerror("Error", f"Failed to load tags: {e}")
return
head = ttk.Frame(rows_container)
head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6))
ttk.Label(head, text="Delete").pack(side=tk.LEFT, padx=(0, 10))
ttk.Label(head, text="Tag name", width=30).pack(side=tk.LEFT)
ttk.Label(head, text="Edit", width=6).pack(side=tk.LEFT, padx=(10, 0))
for idx, tag in enumerate(current_tags, start=1):
row = ttk.Frame(rows_container)
row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2)
var = tk.BooleanVar(value=False)
selected_tag_vars[tag['id']] = var
ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 10))
name_label = ttk.Label(row, text=tag['tag_name'], width=30)
name_label.pack(side=tk.LEFT)
def make_edit_handler(tag_id, name_widget):
def handler():
new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_widget.cget('text'), parent=dialog)
if new_name is None:
return
new_name = new_name.strip()
if not new_name:
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id))
conn.commit()
except Exception as e:
messagebox.showerror("Error", f"Failed to rename tag: {e}")
return
refresh_tag_list()
load_existing_tags()
switch_view_mode(view_mode_var.get())
return handler
ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name_label)).pack(side=tk.LEFT, padx=(10, 0))
refresh_tag_list()
def delete_selected():
ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()]
if not ids_to_delete:
return
if not messagebox.askyesno("Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos."):
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
conn.commit()
for photo_id in list(pending_tag_changes.keys()):
pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in ids_to_delete]
if not pending_tag_changes[photo_id]:
del pending_tag_changes[photo_id]
for photo_id in list(pending_tag_removals.keys()):
pending_tag_removals[photo_id] = [tid for tid in pending_tag_removals[photo_id] if tid not in ids_to_delete]
if not pending_tag_removals[photo_id]:
del pending_tag_removals[photo_id]
refresh_tag_list()
load_existing_tags()
load_photos()
switch_view_mode(view_mode_var.get())
except Exception as e:
messagebox.showerror("Error", f"Failed to delete tags: {e}")
ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected).pack(side=tk.LEFT)
ttk.Button(bottom_frame, text="Quit", command=dialog.destroy).pack(side=tk.RIGHT)
new_tag_entry.focus_set()
ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog).grid(row=0, column=2, sticky=tk.E, padx=(10, 0))
# Content area
content_frame = ttk.Frame(main_frame)
content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
content_frame.columnconfigure(0, weight=1)
content_frame.rowconfigure(0, weight=1)
style = ttk.Style()
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0)
content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview)
content_inner = ttk.Frame(content_canvas)
content_canvas.create_window((0, 0), window=content_inner, anchor="nw")
content_canvas.configure(yscrollcommand=content_scrollbar.set)
content_inner.bind("<Configure>", lambda e: content_canvas.configure(scrollregion=content_canvas.bbox("all")))
content_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
content_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
bottom_frame = ttk.Frame(main_frame)
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0))
save_button = ttk.Button(bottom_frame, text="Save Tagging")
save_button.pack(side=tk.RIGHT, padx=10, pady=5)
def quit_with_warning():
has_pending_changes = bool(pending_tag_changes or pending_tag_removals)
if has_pending_changes:
total_additions = sum(len(tags) for tags in pending_tag_changes.values())
total_removals = sum(len(tags) for tags in pending_tag_removals.values())
changes_text = []
if total_additions > 0:
changes_text.append(f"{total_additions} tag addition(s)")
if total_removals > 0:
changes_text.append(f"{total_removals} tag removal(s)")
changes_summary = " and ".join(changes_text)
result = messagebox.askyesnocancel(
"Unsaved Changes",
f"You have unsaved changes: {changes_summary}.\n\nDo you want to save your changes before quitting?\n\nYes = Save and quit\nNo = Quit without saving\nCancel = Stay in dialog"
)
if result is True:
save_tagging_changes()
root.destroy()
elif result is False:
root.destroy()
else:
root.destroy()
ttk.Button(bottom_frame, text="Quit", command=quit_with_warning).pack(side=tk.RIGHT, padx=(0, 10), pady=5)
def on_mousewheel(event):
content_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
resize_start_x = 0
resize_start_widths: List[int] = []
current_visible_cols: List[Dict] = []
is_resizing = False
def start_resize(event, col_idx):
nonlocal resize_start_x, resize_start_widths, is_resizing
is_resizing = True
resize_start_x = event.x_root
resize_start_widths = [col['width'] for col in current_visible_cols]
root.configure(cursor="sb_h_double_arrow")
def do_resize(event, col_idx):
nonlocal resize_start_x, resize_start_widths, is_resizing
if not is_resizing or not resize_start_widths or not current_visible_cols:
return
delta_x = event.x_root - resize_start_x
if col_idx < len(current_visible_cols) and col_idx + 1 < len(current_visible_cols):
new_width_left = max(50, resize_start_widths[col_idx] + delta_x)
new_width_right = max(50, resize_start_widths[col_idx + 1] - delta_x)
current_visible_cols[col_idx]['width'] = new_width_left
current_visible_cols[col_idx + 1]['width'] = new_width_right
for i, col in enumerate(column_config['list']):
if col['key'] == current_visible_cols[col_idx]['key']:
column_config['list'][i]['width'] = new_width_left
elif col['key'] == current_visible_cols[col_idx + 1]['key']:
column_config['list'][i]['width'] = new_width_right
try:
header_frame_ref = None
row_frames = []
for widget in content_inner.winfo_children():
if isinstance(widget, ttk.Frame):
if header_frame_ref is None:
header_frame_ref = widget
else:
row_frames.append(widget)
if header_frame_ref is not None:
header_frame_ref.columnconfigure(col_idx * 2, weight=current_visible_cols[col_idx]['weight'], minsize=new_width_left)
header_frame_ref.columnconfigure((col_idx + 1) * 2, weight=current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right)
for rf in row_frames:
rf.columnconfigure(col_idx, weight=current_visible_cols[col_idx]['weight'], minsize=new_width_left)
rf.columnconfigure(col_idx + 1, weight=current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right)
root.update_idletasks()
except Exception:
pass
def stop_resize(event):
nonlocal is_resizing
is_resizing = False
root.configure(cursor="")
root.bind_all("<MouseWheel>", on_mousewheel)
def global_mouse_release(event):
if is_resizing:
stop_resize(event)
root.bind_all("<ButtonRelease-1>", global_mouse_release)
def cleanup_mousewheel():
try:
root.unbind_all("<MouseWheel>")
root.unbind_all("<ButtonRelease-1>")
except Exception:
pass
root.bind("<Destroy>", lambda e: cleanup_mousewheel())
photos_data: List[Dict] = []
column_visibility = {
'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True},
'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True},
'compact': {'filename': True, 'faces': True, 'tags': True}
}
column_config = {
'list': [
{'key': 'id', 'label': 'ID', 'width': 50, 'weight': 0},
{'key': 'filename', 'label': 'Filename', 'width': 150, 'weight': 1},
{'key': 'path', 'label': 'Path', 'width': 200, 'weight': 2},
{'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0},
{'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0},
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
],
'icons': [
{'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0},
{'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0},
{'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1},
{'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0},
{'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0},
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
],
'compact': [
{'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1},
{'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0},
{'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1}
]
}
def load_photos():
nonlocal photos_data
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added,
COUNT(f.id) as face_count,
GROUP_CONCAT(DISTINCT t.tag_name) as tags
FROM photos p
LEFT JOIN faces f ON f.photo_id = p.id
LEFT JOIN phototaglinkage ptl ON ptl.photo_id = p.id
LEFT JOIN tags t ON t.id = ptl.tag_id
GROUP BY p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added
ORDER BY p.date_taken DESC, p.filename
''')
photos_data = []
for row in cursor.fetchall():
photos_data.append({
'id': row[0],
'filename': row[1],
'path': row[2],
'processed': row[3],
'date_taken': row[4],
'date_added': row[5],
'face_count': row[6] or 0,
'tags': row[7] or ""
})
def prepare_folder_grouped_data():
from collections import defaultdict
folder_groups = defaultdict(list)
for photo in photos_data:
folder_path = os.path.dirname(photo['path'])
folder_groups[folder_path].append(photo)
sorted_folders = []
for folder_path in sorted(folder_groups.keys()):
folder_name = os.path.basename(folder_path) if folder_path else "Root"
photos_in_folder = sorted(folder_groups[folder_path], key=lambda x: x['date_taken'] or '', reverse=True)
if folder_path not in folder_states:
folder_states[folder_path] = True
sorted_folders.append({
'folder_path': folder_path,
'folder_name': folder_name,
'photos': photos_in_folder,
'photo_count': len(photos_in_folder)
})
return sorted_folders
def create_folder_header(parent, folder_info, current_row, col_count, view_mode):
folder_header_frame = ttk.Frame(parent)
folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5))
folder_header_frame.configure(relief='raised', borderwidth=1)
is_expanded = folder_states.get(folder_info['folder_path'], True)
toggle_text = "" if is_expanded else ""
toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1,
command=lambda: toggle_folder(folder_info['folder_path'], view_mode),
font=("Arial", 8), relief='flat', bd=1)
toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5)
folder_label = ttk.Label(folder_header_frame, text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos)", font=("Arial", 11, "bold"))
folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5)
return folder_header_frame
def toggle_folder(folder_path, view_mode):
folder_states[folder_path] = not folder_states.get(folder_path, True)
switch_view_mode(view_mode)
def load_existing_tags():
nonlocal existing_tags, tag_id_to_name, tag_name_to_id
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name')
existing_tags = []
tag_id_to_name = {}
tag_name_to_id = {}
for row in cursor.fetchall():
tag_id, tag_name = row
existing_tags.append(tag_name)
tag_id_to_name[tag_id] = tag_name
tag_name_to_id[tag_name] = tag_id
def create_tagging_widget(parent, photo_id, current_tags=""):
tag_var = tk.StringVar()
tagging_frame = ttk.Frame(parent)
tag_combo = ttk.Combobox(tagging_frame, textvariable=tag_var, width=12)
tag_combo['values'] = existing_tags
tag_combo.pack(side=tk.LEFT, padx=2, pady=2)
pending_tags_var = tk.StringVar()
ttk.Label(tagging_frame, textvariable=pending_tags_var, font=("Arial", 8), foreground="blue", width=20).pack(side=tk.LEFT, padx=2, pady=2)
if photo_id in pending_tag_changes:
pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo_id]]
pending_tags_var.set(", ".join(pending_tag_names))
else:
pending_tags_var.set(current_tags or "")
def add_tag():
tag_name = tag_var.get().strip()
if not tag_name:
return
if tag_name in tag_name_to_id:
tag_id = tag_name_to_id[tag_name]
else:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
tag_id = cursor.fetchone()[0]
tag_name_to_id[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()
existing_tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
pending_tag_ids = pending_tag_changes.get(photo_id, [])
all_existing_tag_ids = existing_tag_ids + pending_tag_ids
if tag_id not in all_existing_tag_ids:
if photo_id not in pending_tag_changes:
pending_tag_changes[photo_id] = []
pending_tag_changes[photo_id].append(tag_id)
pending_tags_var.set(
", ".join([tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]])
)
tag_var.set("")
tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag).pack(side=tk.LEFT, padx=2, pady=2)
def remove_tag():
if photo_id in pending_tag_changes and pending_tag_changes[photo_id]:
pending_tag_changes[photo_id].pop()
if pending_tag_changes[photo_id]:
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]]
pending_tags_var.set(", ".join(pending_tag_names))
else:
pending_tags_var.set("")
del pending_tag_changes[photo_id]
tk.Button(tagging_frame, text="-", width=2, height=1, command=remove_tag).pack(side=tk.LEFT, padx=2, pady=2)
return tagging_frame
def save_tagging_changes():
if not pending_tag_changes and not pending_tag_removals:
messagebox.showinfo("Info", "No tag changes to save.")
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for photo_id, tag_ids in pending_tag_changes.items():
for tag_id in tag_ids:
cursor.execute('INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', (photo_id, tag_id))
for photo_id, tag_ids in pending_tag_removals.items():
for tag_id in tag_ids:
cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
conn.commit()
saved_additions = len(pending_tag_changes)
saved_removals = len(pending_tag_removals)
pending_tag_changes.clear()
pending_tag_removals.clear()
load_existing_tags()
load_photos()
switch_view_mode(view_mode_var.get())
update_save_button_text()
message = f"Saved {saved_additions} tag additions"
if saved_removals > 0:
message += f" and {saved_removals} tag removals"
message += "."
messagebox.showinfo("Success", message)
except Exception as e:
messagebox.showerror("Error", f"Failed to save tags: {str(e)}")
def update_save_button_text():
total_additions = sum(len(tags) for tags in pending_tag_changes.values())
total_removals = sum(len(tags) for tags in pending_tag_removals.values())
total_changes = total_additions + total_removals
if total_changes > 0:
save_button.configure(text=f"Save Tagging ({total_changes} pending)")
else:
save_button.configure(text="Save Tagging")
save_button.configure(command=save_tagging_changes)
def clear_content():
for widget in content_inner.winfo_children():
widget.destroy()
for crop in list(temp_crops):
try:
if os.path.exists(crop):
os.remove(crop)
except Exception:
pass
temp_crops.clear()
photo_images.clear()
def show_column_context_menu(event, view_mode):
popup = tk.Toplevel(root)
popup.wm_overrideredirect(True)
popup.wm_geometry(f"+{event.x_root}+{event.y_root}")
popup.configure(bg='white', relief='flat', bd=0)
menu_frame = tk.Frame(popup, bg='white')
menu_frame.pack(padx=2, pady=2)
checkbox_vars: Dict[str, tk.BooleanVar] = {}
protected_columns = {'icons': ['thumbnail'], 'compact': ['filename'], 'list': ['filename']}
def close_popup():
try:
popup.destroy()
except Exception:
pass
def close_on_click_outside(e):
if e.widget != popup:
try:
popup.winfo_exists()
close_popup()
except tk.TclError:
pass
for col in column_config[view_mode]:
key = col['key']
label = col['label']
is_visible = column_visibility[view_mode][key]
is_protected = key in protected_columns.get(view_mode, [])
item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0)
item_frame.pack(fill=tk.X, pady=1)
var = tk.BooleanVar(value=is_visible)
checkbox_vars[key] = var
def make_toggle_command(col_key, var_ref):
def toggle_column():
if col_key in protected_columns.get(view_mode, []):
return
column_visibility[view_mode][col_key] = var_ref.get()
switch_view_mode(view_mode)
return toggle_column
if is_protected:
cb = tk.Checkbutton(item_frame, text=label, variable=var, state='disabled', bg='white', fg='gray', font=("Arial", 9), relief='flat', bd=0, highlightthickness=0)
cb.pack(side=tk.LEFT, padx=5, pady=2)
tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5))
else:
cb = tk.Checkbutton(item_frame, text=label, variable=var, command=make_toggle_command(key, var), bg='white', font=("Arial", 9), relief='flat', bd=0, highlightthickness=0)
cb.pack(side=tk.LEFT, padx=5, pady=2)
root.bind("<Button-1>", close_on_click_outside)
root.bind("<Button-3>", close_on_click_outside)
content_canvas.bind("<Button-1>", close_on_click_outside)
content_canvas.bind("<Button-3>", close_on_click_outside)
popup.focus_set()
def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags):
def handler():
popup = tk.Toplevel(root)
popup.title("Manage Photo Tags")
popup.transient(root)
popup.grab_set()
popup.geometry("500x400")
popup.resizable(True, True)
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="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W)
tag_var = tk.StringVar()
combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly')
combo.grid(row=0, 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
if tag_name in tag_name_to_id:
tag_id = tag_name_to_id[tag_name]
else:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
tag_id = cursor.fetchone()[0]
tag_name_to_id[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()
existing_tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
pending_tag_ids = pending_tag_changes.get(photo_id, [])
all_existing_tag_ids = existing_tag_ids + pending_tag_ids
if tag_id not in all_existing_tag_ids:
if photo_id not in pending_tag_changes:
pending_tag_changes[photo_id] = []
pending_tag_changes[photo_id].append(tag_id)
refresh_tag_list()
update_save_button_text()
tag_var.set("")
ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=0, column=2, padx=(0, 8))
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: Dict[str, tk.BooleanVar] = {}
def refresh_tag_list():
for widget in scrollable_frame.winfo_children():
widget.destroy()
selected_tag_vars.clear()
existing_tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
pending_tag_ids = pending_tag_changes.get(photo_id, [])
pending_removal_ids = pending_tag_removals.get(photo_id, [])
all_tag_ids = existing_tag_ids + pending_tag_ids
unique_tag_ids = list(set(all_tag_ids))
unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids]
unique_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids]
if not unique_tag_names:
ttk.Label(scrollable_frame, text="No tags linked to this photo", foreground="gray").pack(anchor=tk.W, pady=5)
return
for i, tag_id in enumerate(unique_tag_ids):
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)
ttk.Checkbutton(frame, variable=var).pack(side=tk.LEFT, padx=(0, 5))
is_pending = tag_id in pending_tag_ids
status_text = " (pending)" if is_pending else " (saved)"
status_color = "blue" if is_pending else "black"
ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT)
def remove_selected_tags():
tag_ids_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])
if not tag_ids_to_remove:
return
if photo_id in pending_tag_changes:
pending_tag_changes[photo_id] = [tid for tid in pending_tag_changes[photo_id] if tid not in tag_ids_to_remove]
if not pending_tag_changes[photo_id]:
del pending_tag_changes[photo_id]
existing_tag_ids = self.db.get_existing_tag_ids_for_photo(photo_id)
for tag_id in tag_ids_to_remove:
if tag_id in existing_tag_ids:
if photo_id not in pending_tag_removals:
pending_tag_removals[photo_id] = []
if tag_id not in pending_tag_removals[photo_id]:
pending_tag_removals[photo_id].append(tag_id)
refresh_tag_list()
update_save_button_text()
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)
refresh_tag_list()
def on_close():
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])]
pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])]
all_tags = existing_tags_list + pending_tag_names
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
current_tags = ", ".join(unique_tags) if unique_tags else "None"
label_widget.configure(text=current_tags)
popup.destroy()
popup.protocol("WM_DELETE_WINDOW", on_close)
return handler
def create_tag_buttons_frame(parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0):
tags_frame = ttk.Frame(parent)
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes.get(photo_id, [])]
pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals.get(photo_id, [])]
all_tags = existing_tags_list + pending_tag_names
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
current_display = ", ".join(unique_tags) if unique_tags else "None"
tags_text = ttk.Label(tags_frame, text=current_display)
tags_text.pack(side=tk.LEFT)
add_btn = tk.Button(tags_frame, text="🔗", width=2, command=create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags))
add_btn.pack(side=tk.LEFT, padx=(6, 0))
if use_grid:
tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W)
else:
tags_frame.pack(side=tk.LEFT, padx=5)
return tags_frame
def show_list_view():
clear_content()
nonlocal current_visible_cols
current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]]
col_count = len(current_visible_cols)
if col_count == 0:
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
return
for i, col in enumerate(current_visible_cols):
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
header_frame = ttk.Frame(content_inner)
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
for i, col in enumerate(current_visible_cols):
header_frame.columnconfigure(i * 2, weight=col['weight'], minsize=col['width'])
if i < len(current_visible_cols) - 1:
header_frame.columnconfigure(i * 2 + 1, weight=0, minsize=1)
for i, col in enumerate(current_visible_cols):
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
header_label.grid(row=0, column=i * 2, padx=5, sticky=tk.W)
header_label.bind("<Button-3>", lambda e, mode='list': show_column_context_menu(e, mode))
if i < len(current_visible_cols) - 1:
separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow")
separator_frame.grid(row=0, column=i * 2 + 1, sticky='ns', padx=0)
separator_frame.grid_propagate(False)
inner_line = tk.Frame(separator_frame, bg='darkred', width=2)
inner_line.pack(fill=tk.Y, expand=True)
separator_frame.bind("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
separator_frame.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
separator_frame.bind("<ButtonRelease-1>", stop_resize)
separator_frame.bind("<Enter>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange')))
separator_frame.bind("<Leave>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred')))
inner_line.bind("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
inner_line.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
inner_line.bind("<ButtonRelease-1>", stop_resize)
header_frame.bind("<Button-3>", lambda e, mode='list': show_column_context_menu(e, mode))
ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
folder_data = prepare_folder_grouped_data()
current_row = 2
for folder_info in folder_data:
create_folder_header(content_inner, folder_info, current_row, col_count, 'list')
current_row += 1
if folder_states.get(folder_info['folder_path'], True):
for photo in folder_info['photos']:
row_frame = ttk.Frame(content_inner)
row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1)
for i, col in enumerate(current_visible_cols):
row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
for i, col in enumerate(current_visible_cols):
key = col['key']
if key == 'id':
text = str(photo['id'])
elif key == 'filename':
text = photo['filename']
elif key == 'path':
text = photo['path']
elif key == 'processed':
text = "Yes" if photo['processed'] else "No"
elif key == 'date_taken':
text = photo['date_taken'] or "Unknown"
elif key == 'faces':
text = str(photo['face_count'])
elif key == 'tags':
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=i)
continue
ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W)
current_row += 1
def show_icon_view():
clear_content()
visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]]
col_count = len(visible_cols)
if col_count == 0:
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
return
for i, col in enumerate(visible_cols):
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
header_frame = ttk.Frame(content_inner)
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
for i, col in enumerate(visible_cols):
header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
for i, col in enumerate(visible_cols):
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
header_label.grid(row=0, column=i, padx=5, sticky=tk.W)
header_label.bind("<Button-3>", lambda e, mode='icons': show_column_context_menu(e, mode))
header_frame.bind("<Button-3>", lambda e, mode='icons': show_column_context_menu(e, mode))
ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
folder_data = prepare_folder_grouped_data()
current_row = 2
for folder_info in folder_data:
create_folder_header(content_inner, folder_info, current_row, col_count, 'icons')
current_row += 1
if folder_states.get(folder_info['folder_path'], True):
for photo in folder_info['photos']:
row_frame = ttk.Frame(content_inner)
row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2)
for i, col in enumerate(visible_cols):
row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
col_idx = 0
for col in visible_cols:
key = col['key']
if key == 'thumbnail':
thumbnail_frame = ttk.Frame(row_frame)
thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
try:
if os.path.exists(photo['path']):
img = Image.open(photo['path'])
img.thumbnail((150, 150), Image.Resampling.LANCZOS)
photo_img = ImageTk.PhotoImage(img)
photo_images.append(photo_img)
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0)
canvas.pack()
canvas.create_image(75, 75, image=photo_img)
else:
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0)
canvas.pack()
canvas.create_text(75, 75, text="🖼️", fill="gray", font=("Arial", 24))
except Exception:
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0)
canvas.pack()
canvas.create_text(75, 75, text="", fill="red", font=("Arial", 24))
else:
if key == 'id':
text = str(photo['id'])
elif key == 'filename':
text = photo['filename']
elif key == 'processed':
text = "Yes" if photo['processed'] else "No"
elif key == 'date_taken':
text = photo['date_taken'] or "Unknown"
elif key == 'faces':
text = str(photo['face_count'])
elif key == 'tags':
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
col_idx += 1
continue
ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W)
col_idx += 1
current_row += 1
def show_compact_view():
clear_content()
visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]]
col_count = len(visible_cols)
if col_count == 0:
ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20)
return
for i, col in enumerate(visible_cols):
content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width'])
header_frame = ttk.Frame(content_inner)
header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
for i, col in enumerate(visible_cols):
header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
for i, col in enumerate(visible_cols):
header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold"))
header_label.grid(row=0, column=i, padx=5, sticky=tk.W)
header_label.bind("<Button-3>", lambda e, mode='compact': show_column_context_menu(e, mode))
header_frame.bind("<Button-3>", lambda e, mode='compact': show_column_context_menu(e, mode))
ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
folder_data = prepare_folder_grouped_data()
current_row = 2
for folder_info in folder_data:
create_folder_header(content_inner, folder_info, current_row, col_count, 'compact')
current_row += 1
if folder_states.get(folder_info['folder_path'], True):
for photo in folder_info['photos']:
row_frame = ttk.Frame(content_inner)
row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1)
for i, col in enumerate(visible_cols):
row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
col_idx = 0
for col in visible_cols:
key = col['key']
if key == 'filename':
text = photo['filename']
elif key == 'faces':
text = str(photo['face_count'])
elif key == 'tags':
create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], existing_tags, use_grid=True, row=0, col=col_idx)
col_idx += 1
continue
ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W)
col_idx += 1
current_row += 1
def switch_view_mode(mode):
if mode == "list":
show_list_view()
elif mode == "icons":
show_icon_view()
elif mode == "compact":
show_compact_view()
load_existing_tags()
load_photos()
show_list_view()
root.deiconify()
root.mainloop()
return 0