diff --git a/README.md b/README.md index 8322f4a..c5a2e70 100644 --- a/README.md +++ b/README.md @@ -220,13 +220,10 @@ This GUI provides a file explorer-like interface for managing photo tags with ad - ⚡ **Fast Loading** - Minimal data for quick browsing - 🎯 **Focused Display** - Perfect for quick tag management -**Folder View:** +Folder grouping applies across all views: - 📁 **Directory Grouping** - Photos grouped by their directory path - 🔽 **Expandable Folders** - Click folder headers to expand/collapse - 📊 **Photo Counts** - Shows number of photos in each folder -- 🎯 **File Explorer Style** - Familiar tree-like interface -- 📄 **Photo Details** - Shows filename, processed status, date taken, face count, and tags -- 🖱️ **Easy Navigation** - Click anywhere on folder header to toggle **🔧 Column Resizing:** - 🖱️ **Drag to Resize** - Click and drag red separators between columns @@ -454,7 +451,6 @@ sudo apt install -y cmake build-essential libopenblas-dev liblapack-dev libx11-d PunimTag/ ├── photo_tagger.py # Main CLI tool ├── setup.py # Setup script -├── run.sh # Convenience script (auto-activates venv) ├── requirements.txt # Python dependencies ├── README.md # This file ├── gui_config.json # GUI window size preferences (created automatically) @@ -897,16 +893,7 @@ This is now a minimal, focused tool. Key principles: # Setup (one time) python3 -m venv venv && source venv/bin/activate && python3 setup.py -# Daily usage - Option 1: Use run script (automatic venv activation) -./run.sh scan ~/Pictures --recursive -./run.sh process --limit 50 -./run.sh identify --show-faces --batch 10 -./run.sh auto-match --show-faces -./run.sh modifyidentified -./run.sh tag-manager -./run.sh stats - -# Daily usage - Option 2: Manual venv activation (GUI-ENHANCED) +# Daily usage - Manual venv activation (GUI-ENHANCED) source venv/bin/activate python3 photo_tagger.py scan ~/Pictures --recursive python3 photo_tagger.py process --limit 50 diff --git a/photo_tagger.py b/photo_tagger.py index 2856969..ec9995a 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -24,6 +24,7 @@ from gui_core import GUICore from identify_gui import IdentifyGUI from auto_match_gui import AutoMatchGUI from modify_identified_gui import ModifyIdentifiedGUI +from tag_manager_gui import TagManagerGUI class PhotoTagger: @@ -45,6 +46,7 @@ class PhotoTagger: self.identify_gui = IdentifyGUI(self.db, self.face_processor, verbose) self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose) self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose) + self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose) # Legacy compatibility - expose some methods directly self._db_connection = None @@ -181,8 +183,7 @@ class PhotoTagger: def tag_management(self) -> int: """Tag management GUI""" - print("⚠️ Tag management GUI not yet implemented in refactored version") - return 0 + return self.tag_manager_gui.tag_management() def modifyidentified(self) -> int: return self.modify_identified_gui.modifyidentified() diff --git a/tag_manager_gui.py b/tag_manager_gui.py new file mode 100644 index 0000000..679493e --- /dev/null +++ b/tag_manager_gui.py @@ -0,0 +1,991 @@ +""" +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("", 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("", 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("", on_mousewheel) + + def global_mouse_release(event): + if is_resizing: + stop_resize(event) + root.bind_all("", global_mouse_release) + + def cleanup_mousewheel(): + try: + root.unbind_all("") + root.unbind_all("") + except Exception: + pass + + root.bind("", 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("", close_on_click_outside) + root.bind("", close_on_click_outside) + content_canvas.bind("", close_on_click_outside) + content_canvas.bind("", 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("", 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("", 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("", lambda e, col_idx=i: start_resize(e, col_idx)) + separator_frame.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) + separator_frame.bind("", stop_resize) + separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) + separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred'))) + inner_line.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) + inner_line.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) + inner_line.bind("", stop_resize) + header_frame.bind("", 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("", lambda e, mode='icons': show_column_context_menu(e, mode)) + header_frame.bind("", 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("", lambda e, mode='compact': show_column_context_menu(e, mode)) + header_frame.bind("", 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 + +