diff --git a/dashboard_gui.py b/dashboard_gui.py index acda0b2..a0a9e9f 100644 --- a/dashboard_gui.py +++ b/dashboard_gui.py @@ -14,6 +14,7 @@ from gui_core import GUICore from identify_panel import IdentifyPanel from modify_panel import ModifyPanel from auto_match_panel import AutoMatchPanel +from tag_manager_panel import TagManagerPanel from search_stats import SearchStats from database import DatabaseManager from tag_management import TagManager @@ -1330,6 +1331,7 @@ from gui_core import GUICore from identify_panel import IdentifyPanel from modify_panel import ModifyPanel from auto_match_panel import AutoMatchPanel +from tag_manager_panel import TagManagerPanel from search_stats import SearchStats from database import DatabaseManager from tag_management import TagManager @@ -1446,7 +1448,7 @@ class DashboardGUI: buttons_frame, text=text, command=lambda p=panel_name: self.show_panel(p), - width=12 # Fixed width for consistent layout + width=16 # Fixed width for consistent layout ) btn.grid(row=0, column=i, padx=3, sticky=tk.W) @@ -1508,6 +1510,9 @@ class DashboardGUI: # Deactivate modify panel if it's active if hasattr(self, 'modify_panel') and self.modify_panel and self.current_panel == "modify": self.modify_panel.deactivate() + # Deactivate tag manager panel if it's active + if hasattr(self, 'tag_manager_panel') and self.tag_manager_panel and self.current_panel == "tags": + self.tag_manager_panel.deactivate() # Show new panel - expand both horizontally and vertically self.panels[panel_name].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=15, pady=15) @@ -1520,6 +1525,8 @@ class DashboardGUI: self.auto_match_panel.activate() elif panel_name == "modify" and hasattr(self, 'modify_panel') and self.modify_panel: self.modify_panel.activate() + elif panel_name == "tags" and hasattr(self, 'tag_manager_panel') and self.tag_manager_panel: + self.tag_manager_panel.activate() # Update status self.status_label.config(text=f"Viewing: {panel_name.replace('_', ' ').title()}") @@ -1556,6 +1563,10 @@ class DashboardGUI: # Update identify panel layout if it's active if self.current_panel == "identify": self.identify_panel.update_layout() + if hasattr(self, 'tag_manager_panel') and self.tag_manager_panel: + # Update tag manager panel layout if it's active + if self.current_panel == "tags": + self.tag_manager_panel.update_layout() def _create_home_panel(self) -> ttk.Frame: """Create the home/welcome panel""" @@ -1868,25 +1879,45 @@ class DashboardGUI: return panel def _create_tags_panel(self) -> ttk.Frame: - """Create the tags panel (placeholder)""" + """Create the tags panel with full functionality""" panel = ttk.Frame(self.content_frame) # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - # Remove weight=1 from row to prevent empty space expansion + # Configure rows: title (row 0) fixed, tag manager content (row 1) should expand + panel.rowconfigure(0, weight=0) + panel.rowconfigure(1, weight=1) + # Title with larger font for full screen title_label = tk.Label(panel, text="🏷️ Tag Manager", font=("Arial", 24, "bold")) title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", padding="20") - placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) - placeholder_frame.columnconfigure(0, weight=1) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = "Tag management functionality will be integrated here." - placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14)) - placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N)) + # Create the tag manager panel if we have the required dependencies + if self.db_manager and self.tag_manager and self.face_processor: + self.tag_manager_panel = TagManagerPanel(panel, self.db_manager, self.gui_core, self.tag_manager, self.face_processor) + tag_manager_frame = self.tag_manager_panel.create_panel() + tag_manager_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + else: + # Fallback placeholder if dependencies are not available + placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20") + placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) + placeholder_frame.columnconfigure(0, weight=1) + + placeholder_text = ( + "Tag manager panel requires database, tag manager, and face processor to be configured.\n\n" + "This will contain the full tag management interface\n" + "currently available in the separate Tag Manager window.\n\n" + "Features will include:\n" + "• Photo explorer with folder grouping\n" + "• Tag management and bulk operations\n" + "• Multiple view modes (list, icons, compact)\n" + "• Tag creation, editing, and deletion\n" + "• Bulk tag linking to folders\n" + "• Photo preview and people identification" + ) + + placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT) + placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) return panel diff --git a/tag_manager_panel.py b/tag_manager_panel.py new file mode 100644 index 0000000..334d08c --- /dev/null +++ b/tag_manager_panel.py @@ -0,0 +1,1358 @@ +#!/usr/bin/env python3 +""" +Integrated Tag Manager Panel for PunimTag Dashboard +Embeds the full tag manager GUI functionality into the dashboard frame +""" + +import os +import tkinter as tk +from tkinter import ttk, messagebox, simpledialog +from PIL import Image, ImageTk +from typing import List, Dict, Tuple, Optional +import sys +import subprocess + +from database import DatabaseManager +from gui_core import GUICore +from tag_management import TagManager +from face_processing import FaceProcessor + + +class TagManagerPanel: + """Integrated tag manager panel that embeds the full tag manager GUI functionality into the dashboard""" + + def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager, + gui_core: GUICore, tag_manager: TagManager, face_processor: FaceProcessor, verbose: int = 0): + """Initialize the tag manager panel""" + self.parent_frame = parent_frame + self.db = db_manager + self.gui_core = gui_core + self.tag_manager = tag_manager + self.face_processor = face_processor + self.verbose = verbose + + # Panel state + self.is_active = False + self.temp_crops: List[str] = [] + self.photo_images: List[ImageTk.PhotoImage] = [] # Keep PhotoImage refs alive + self.folder_states: Dict[str, bool] = {} + + # Track pending tag changes/removals using tag IDs + self.pending_tag_changes: Dict[int, List[int]] = {} + self.pending_tag_removals: Dict[int, List[int]] = {} + # Track linkage type for pending additions: 0=single, 1=bulk + self.pending_tag_linkage_type: Dict[int, Dict[int, int]] = {} + + # Tag data + self.existing_tags: List[str] = [] + self.tag_id_to_name: Dict[int, str] = {} + self.tag_name_to_id: Dict[str, int] = {} + + # Photo data + self.photos_data: List[Dict] = [] + self.people_names_cache: Dict[int, List[str]] = {} # {photo_id: [list of people names]} + + # View configuration + self.view_mode_var = tk.StringVar(value="list") + self.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} + } + + self.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} + ] + } + + # GUI components + self.components = {} + self.main_frame = None + + def create_panel(self) -> ttk.Frame: + """Create the tag manager panel with all GUI components""" + self.main_frame = ttk.Frame(self.parent_frame) + + # Configure grid weights for full screen responsiveness + self.main_frame.columnconfigure(0, weight=1) + self.main_frame.rowconfigure(0, weight=0) # Header - fixed height + self.main_frame.rowconfigure(1, weight=1) # Content area - expandable + self.main_frame.rowconfigure(2, weight=0) # Bottom buttons - fixed height + + # Create all GUI components + self._create_header() + self._create_content_area() + self._create_bottom_buttons() + + # Load initial data + self._load_existing_tags() + self._load_photos() + self._switch_view_mode("list") + + return self.main_frame + + def _create_header(self): + """Create the header with title and view controls""" + header_frame = ttk.Frame(self.main_frame) + header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + header_frame.columnconfigure(1, weight=1) + + # Title + 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) + ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="List", variable=self.view_mode_var, value="list", + command=lambda: self._switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="Icons", variable=self.view_mode_var, value="icons", + command=lambda: self._switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="Compact", variable=self.view_mode_var, value="compact", + command=lambda: self._switch_view_mode("compact")).pack(side=tk.LEFT) + + # Manage Tags button + manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=self._open_manage_tags_dialog) + manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0)) + + self.components['header_frame'] = header_frame + self.components['view_frame'] = view_frame + self.components['manage_tags_btn'] = manage_tags_btn + + def _create_content_area(self): + """Create the main content area with scrollable canvas""" + content_frame = ttk.Frame(self.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) + + # Get canvas background color + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + + # Create scrollable content area + self.components['content_canvas'] = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0) + self.components['content_scrollbar'] = ttk.Scrollbar(content_frame, orient="vertical", + command=self.components['content_canvas'].yview) + self.components['content_inner'] = ttk.Frame(self.components['content_canvas']) + + self.components['content_canvas'].create_window((0, 0), window=self.components['content_inner'], anchor="nw") + self.components['content_canvas'].configure(yscrollcommand=self.components['content_scrollbar'].set) + self.components['content_inner'].bind("", + lambda e: self.components['content_canvas'].configure( + scrollregion=self.components['content_canvas'].bbox("all"))) + + self.components['content_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + self.components['content_scrollbar'].grid(row=0, column=1, sticky=(tk.N, tk.S)) + + # Bind mousewheel scrolling + self._bind_mousewheel_scrolling() + + self.components['content_frame'] = content_frame + + def _create_bottom_buttons(self): + """Create the bottom button area""" + bottom_frame = ttk.Frame(self.main_frame) + bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) + + # Save button + self.components['save_button'] = ttk.Button(bottom_frame, text="Save Tagging", + command=self._save_tagging_changes) + self.components['save_button'].pack(side=tk.RIGHT, padx=10, pady=5) + + # Quit button (for standalone mode - not used in dashboard) + self.components['quit_button'] = ttk.Button(bottom_frame, text="Quit", + command=self._quit_with_warning) + self.components['quit_button'].pack(side=tk.RIGHT, padx=(0, 10), pady=5) + + self.components['bottom_frame'] = bottom_frame + + def _bind_mousewheel_scrolling(self): + """Bind mousewheel scrolling to the content canvas""" + def on_mousewheel(event): + self.components['content_canvas'].yview_scroll(int(-1 * (event.delta / 120)), "units") + + # Bind to the main frame and all its children + self.main_frame.bind_all("", on_mousewheel) + + # Store reference for cleanup + self._mousewheel_handler = on_mousewheel + + def _unbind_mousewheel_scrolling(self): + """Unbind mousewheel scrolling""" + try: + self.main_frame.unbind_all("") + except Exception: + pass + + def _load_existing_tags(self): + """Load existing tags from database""" + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') + self.existing_tags = [] + self.tag_id_to_name = {} + self.tag_name_to_id = {} + for row in cursor.fetchall(): + tag_id, tag_name = row + self.existing_tags.append(tag_name) + self.tag_id_to_name[tag_id] = tag_name + self.tag_name_to_id[tag_name] = tag_id + + def _load_photos(self): + """Load photos data from database""" + 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, + (SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count, + (SELECT GROUP_CONCAT(DISTINCT t.tag_name) + FROM phototaglinkage ptl + JOIN tags t ON t.id = ptl.tag_id + WHERE ptl.photo_id = p.id) as tags + FROM photos p + ORDER BY p.date_taken DESC, p.filename + ''') + self.photos_data = [] + for row in cursor.fetchall(): + self.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 "" + }) + + # Cache people names for each photo + self.people_names_cache.clear() + cursor.execute(''' + SELECT f.photo_id, pe.first_name, pe.last_name + FROM faces f + JOIN people pe ON pe.id = f.person_id + WHERE f.person_id IS NOT NULL + ORDER BY pe.last_name, pe.first_name + ''') + cache_rows = cursor.fetchall() + for row in cache_rows: + photo_id, first_name, last_name = row + if photo_id not in self.people_names_cache: + self.people_names_cache[photo_id] = [] + # Format as "Last, First" or just "First" if no last name + if last_name and first_name: + name = f"{last_name}, {first_name}" + else: + name = first_name or last_name or "Unknown" + if name not in self.people_names_cache[photo_id]: + self.people_names_cache[photo_id].append(name) + + def _get_saved_tag_types_for_photo(self, photo_id: int) -> Dict[int, int]: + """Get saved linkage types for a photo {tag_id: type_int}""" + types: Dict[int, int] = {} + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,)) + for row in cursor.fetchall(): + try: + types[row[0]] = int(row[1]) if row[1] is not None else 0 + except Exception: + types[row[0]] = 0 + except Exception: + pass + return types + + def _get_people_names_for_photo(self, photo_id: int) -> str: + """Get people names for a photo as a formatted string for tooltip""" + people_names = self.people_names_cache.get(photo_id, []) + if not people_names: + return "No people identified" + + # Remove commas from names (convert "Last, First" to "Last First") + formatted_names = [] + for name in people_names: + if ', ' in name: + # Convert "Last, First" to "Last First" + formatted_name = name.replace(', ', ' ') + else: + formatted_name = name + formatted_names.append(formatted_name) + + if len(formatted_names) <= 5: + return f"People: {', '.join(formatted_names)}" + else: + return f"People: {', '.join(formatted_names[:5])}... (+{len(formatted_names)-5} more)" + + def _open_photo(self, photo_path: str): + """Open a photo in a preview window""" + if not os.path.exists(photo_path): + try: + messagebox.showerror("File not found", f"Photo does not exist:\n{photo_path}") + except Exception: + pass + return + try: + # Open in an in-app preview window sized reasonably compared to the main GUI + img = Image.open(photo_path) + screen_w = self.main_frame.winfo_screenwidth() + screen_h = self.main_frame.winfo_screenheight() + max_w = int(min(1000, screen_w * 0.6)) + max_h = int(min(800, screen_h * 0.6)) + preview = tk.Toplevel(self.main_frame) + preview.title(os.path.basename(photo_path)) + preview.transient(self.main_frame) + # Resize image to fit nicely while keeping aspect ratio + img_copy = img.copy() + img_copy.thumbnail((max_w, max_h), Image.Resampling.LANCZOS) + photo_img = ImageTk.PhotoImage(img_copy) + self.photo_images.append(photo_img) + pad = 12 + w, h = photo_img.width(), photo_img.height() + # Center the window roughly relative to screen + x = int((screen_w - (w + pad)) / 2) + y = int((screen_h - (h + pad)) / 2) + preview.geometry(f"{w + pad}x{h + pad}+{max(x,0)}+{max(y,0)}") + canvas = tk.Canvas(preview, width=w, height=h, highlightthickness=0) + canvas.pack(padx=pad//2, pady=pad//2) + canvas.create_image(w // 2, h // 2, image=photo_img) + preview.focus_set() + except Exception: + # Fallback to system default opener if preview fails for any reason + try: + if sys.platform.startswith('linux'): + subprocess.Popen(['xdg-open', photo_path]) + elif sys.platform == 'darwin': + subprocess.Popen(['open', photo_path]) + elif os.name == 'nt': + os.startfile(photo_path) # type: ignore[attr-defined] + else: + Image.open(photo_path).show() + except Exception as e: + try: + messagebox.showerror("Error", f"Failed to open photo:\n{e}") + except Exception: + pass + + def _open_manage_tags_dialog(self): + """Open the manage tags dialog""" + dialog = tk.Toplevel(self.main_frame) + dialog.title("Manage Tags") + dialog.transient(self.main_frame) + 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: + # Use database method for case-insensitive tag creation + tag_id = self.db.add_tag(tag_name) + if tag_id: + new_tag_var.set("") + refresh_tag_list() + self._load_existing_tags() + self._switch_view_mode(self.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() + self._load_existing_tags() + self._switch_view_mode(self.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(self.pending_tag_changes.keys()): + self.pending_tag_changes[photo_id] = [tid for tid in self.pending_tag_changes[photo_id] if tid not in ids_to_delete] + if not self.pending_tag_changes[photo_id]: + del self.pending_tag_changes[photo_id] + for photo_id in list(self.pending_tag_removals.keys()): + self.pending_tag_removals[photo_id] = [tid for tid in self.pending_tag_removals[photo_id] if tid not in ids_to_delete] + if not self.pending_tag_removals[photo_id]: + del self.pending_tag_removals[photo_id] + + refresh_tag_list() + self._load_existing_tags() + self._load_photos() + self._switch_view_mode(self.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="Close", command=dialog.destroy).pack(side=tk.RIGHT) + new_tag_entry.focus_set() + + def _save_tagging_changes(self): + """Save pending tag changes to database""" + if not self.pending_tag_changes and not self.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 self.pending_tag_changes.items(): + for tag_id in tag_ids: + lt = 0 + try: + lt = self.pending_tag_linkage_type.get(photo_id, {}).get(tag_id, 0) + except Exception: + lt = 0 + cursor.execute(''' + INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) + VALUES (?, ?, ?) + ON CONFLICT(photo_id, tag_id) DO UPDATE SET linkage_type=excluded.linkage_type, created_date=CURRENT_TIMESTAMP + ''', (photo_id, tag_id, lt)) + for photo_id, tag_ids in self.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(self.pending_tag_changes) + saved_removals = len(self.pending_tag_removals) + self.pending_tag_changes.clear() + self.pending_tag_removals.clear() + self.pending_tag_linkage_type.clear() + self._load_existing_tags() + self._load_photos() + self._switch_view_mode(self.view_mode_var.get()) + self._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(self): + """Update the save button text to show pending changes""" + total_additions = sum(len(tags) for tags in self.pending_tag_changes.values()) + total_removals = sum(len(tags) for tags in self.pending_tag_removals.values()) + total_changes = total_additions + total_removals + if total_changes > 0: + self.components['save_button'].configure(text=f"Save Tagging ({total_changes} pending)") + else: + self.components['save_button'].configure(text="Save Tagging") + + def _quit_with_warning(self): + """Quit with warning about unsaved changes (for standalone mode)""" + has_pending_changes = bool(self.pending_tag_changes or self.pending_tag_removals) + if has_pending_changes: + total_additions = sum(len(tags) for tags in self.pending_tag_changes.values()) + total_removals = sum(len(tags) for tags in self.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: + self._save_tagging_changes() + # In dashboard mode, we don't actually quit the window + elif result is False: + # In dashboard mode, we don't actually quit the window + pass + # In dashboard mode, we don't actually quit the window + + def _switch_view_mode(self, mode: str): + """Switch between different view modes""" + if mode == "list": + self._show_list_view() + elif mode == "icons": + self._show_icon_view() + elif mode == "compact": + self._show_compact_view() + + def _clear_content(self): + """Clear the content area""" + for widget in self.components['content_inner'].winfo_children(): + widget.destroy() + for crop in list(self.temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except Exception: + pass + self.temp_crops.clear() + self.photo_images.clear() + + def _prepare_folder_grouped_data(self): + """Prepare data grouped by folders""" + from collections import defaultdict + folder_groups = defaultdict(list) + for photo in self.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 self.folder_states: + # Collapse folders by default on first load + self.folder_states[folder_path] = False + 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(self, parent, folder_info, current_row, col_count, view_mode): + """Create a folder header with expand/collapse and bulk operations""" + 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) + + def open_bulk_link_dialog(): + # Bulk tagging dialog: add selected tag to all photos in this folder (pending changes only) + popup = tk.Toplevel(self.main_frame) + popup.title("Bulk Link Tags to Folder") + popup.transient(self.main_frame) + popup.grab_set() + popup.geometry("520x420") + + top_frame = ttk.Frame(popup, padding="8") + top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) + status_frame = ttk.Frame(popup, padding="8") + status_frame.grid(row=1, column=0, sticky=(tk.W, tk.E)) + bottom_frame = ttk.Frame(popup, padding="8") + # Place bottom frame below the list to keep actions at the bottom + bottom_frame.grid(row=4, column=0, sticky=(tk.W, tk.E)) + popup.columnconfigure(0, weight=1) + + ttk.Label(top_frame, text=f"Folder: {folder_info['folder_name']} ({folder_info['photo_count']} photos)").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6)) + ttk.Label(top_frame, text="Add tag to all photos:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W) + + bulk_tag_var = tk.StringVar() + combo = ttk.Combobox(top_frame, textvariable=bulk_tag_var, values=self.existing_tags, width=30, state='readonly') + combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) + combo.focus_set() + + result_var = tk.StringVar(value="") + ttk.Label(status_frame, textvariable=result_var, foreground="gray").grid(row=0, column=0, sticky=tk.W) + + def add_bulk_tag(): + tag_name = bulk_tag_var.get().strip() + if not tag_name: + return + # Resolve or create tag id + # Case-insensitive tag lookup + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in self.tag_name_to_id: + tag_id = self.tag_name_to_id[normalized_tag_name] + else: + # Create new tag using database method + tag_id = self.db.add_tag(tag_name) + if tag_id: + self.tag_name_to_id[normalized_tag_name] = tag_id + self.tag_id_to_name[tag_id] = tag_name + if tag_name not in self.existing_tags: + self.existing_tags.append(tag_name) + self.existing_tags.sort() + else: + return # Failed to create tag + + affected = 0 + for photo in folder_info.get('photos', []): + photo_id = photo['id'] + saved_types = self._get_saved_tag_types_for_photo(photo_id) + existing_tag_ids = list(saved_types.keys()) + pending_tag_ids = self.pending_tag_changes.get(photo_id, []) + # Case 1: not present anywhere → add as pending bulk + if tag_id not in existing_tag_ids and tag_id not in pending_tag_ids: + if photo_id not in self.pending_tag_changes: + self.pending_tag_changes[photo_id] = [] + self.pending_tag_changes[photo_id].append(tag_id) + if photo_id not in self.pending_tag_linkage_type: + self.pending_tag_linkage_type[photo_id] = {} + self.pending_tag_linkage_type[photo_id][tag_id] = 1 + affected += 1 + # Case 2: already pending as single → upgrade pending type to bulk + elif tag_id in pending_tag_ids: + if photo_id not in self.pending_tag_linkage_type: + self.pending_tag_linkage_type[photo_id] = {} + prev_type = self.pending_tag_linkage_type[photo_id].get(tag_id) + if prev_type != 1: + self.pending_tag_linkage_type[photo_id][tag_id] = 1 + affected += 1 + # Case 3: saved as single → schedule an upgrade by adding to pending and setting type to bulk + elif saved_types.get(tag_id) == 0: + if photo_id not in self.pending_tag_changes: + self.pending_tag_changes[photo_id] = [] + if tag_id not in self.pending_tag_changes[photo_id]: + self.pending_tag_changes[photo_id].append(tag_id) + if photo_id not in self.pending_tag_linkage_type: + self.pending_tag_linkage_type[photo_id] = {} + self.pending_tag_linkage_type[photo_id][tag_id] = 1 + affected += 1 + # Case 4: saved as bulk → nothing to do + + self._update_save_button_text() + # Refresh main view to reflect updated pending tags in each row + self._switch_view_mode(view_mode) + result_var.set(f"Added pending tag to {affected} photo(s)") + bulk_tag_var.set("") + # Refresh the bulk list to immediately reflect pending adds + try: + refresh_bulk_tag_list() + except Exception: + pass + + ttk.Button(top_frame, text="Add", command=add_bulk_tag).grid(row=1, column=2) + + # Section: Remove bulk-linked tags across this folder + ttk.Separator(status_frame, orient='horizontal').grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(8, 6)) + + list_frame = ttk.Frame(popup, padding="8") + list_frame.grid(row=3, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) + popup.rowconfigure(3, weight=1) + list_canvas = tk.Canvas(list_frame, highlightthickness=0) + list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=list_canvas.yview) + list_inner = ttk.Frame(list_canvas) + list_canvas.create_window((0, 0), window=list_inner, anchor="nw") + list_canvas.configure(yscrollcommand=list_scroll.set) + list_canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) + list_scroll.grid(row=0, column=1, sticky=(tk.N, tk.S)) + list_frame.columnconfigure(0, weight=1) + list_frame.rowconfigure(0, weight=1) + list_inner.bind("", lambda e: list_canvas.configure(scrollregion=list_canvas.bbox("all"))) + + bulk_tag_vars: Dict[int, tk.BooleanVar] = {} + + def refresh_bulk_tag_list(): + for child in list(list_inner.winfo_children()): + child.destroy() + bulk_tag_vars.clear() + # Aggregate bulk-linked tags across all photos in this folder + tag_id_counts: Dict[int, int] = {} + pending_add_counts: Dict[int, int] = {} + for photo in folder_info.get('photos', []): + saved_types = self._get_saved_tag_types_for_photo(photo['id']) + for tid, ltype in saved_types.items(): + if ltype == 1: + tag_id_counts[tid] = tag_id_counts.get(tid, 0) + 1 + # Count pending additions (bulk type) for this photo + for tid in self.pending_tag_changes.get(photo['id'], []): + if self.pending_tag_linkage_type.get(photo['id'], {}).get(tid) == 1: + pending_add_counts[tid] = pending_add_counts.get(tid, 0) + 1 + # Include tags that exist only in pending adds + all_tag_ids = set(tag_id_counts.keys()) | set(pending_add_counts.keys()) + if not all_tag_ids: + ttk.Label(list_inner, text="No bulk-linked tags in this folder", foreground="gray").pack(anchor=tk.W, pady=5) + return + for tid in sorted(all_tag_ids, key=lambda x: self.tag_id_to_name.get(x, "")): + row = ttk.Frame(list_inner) + row.pack(fill=tk.X, pady=1) + var = tk.BooleanVar(value=False) + bulk_tag_vars[tid] = var + ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 6)) + pend_suffix = " (pending)" if pending_add_counts.get(tid, 0) > 0 else "" + ttk.Label(row, text=f"{self.tag_id_to_name.get(tid, 'Unknown')}{pend_suffix}").pack(side=tk.LEFT) + + def remove_selected_bulk_tags(): + selected_tids = [tid for tid, v in bulk_tag_vars.items() if v.get()] + if not selected_tids: + return + affected = 0 + for photo in folder_info.get('photos', []): + photo_id = photo['id'] + saved_types = self._get_saved_tag_types_for_photo(photo_id) + for tid in selected_tids: + # If it's pending add (bulk), cancel the pending change + if tid in self.pending_tag_changes.get(photo_id, []) and self.pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1: + self.pending_tag_changes[photo_id] = [x for x in self.pending_tag_changes[photo_id] if x != tid] + if not self.pending_tag_changes[photo_id]: + del self.pending_tag_changes[photo_id] + if photo_id in self.pending_tag_linkage_type and tid in self.pending_tag_linkage_type[photo_id]: + del self.pending_tag_linkage_type[photo_id][tid] + if not self.pending_tag_linkage_type[photo_id]: + del self.pending_tag_linkage_type[photo_id] + affected += 1 + # Else if it's a saved bulk linkage, mark for removal + elif saved_types.get(tid) == 1: + if photo_id not in self.pending_tag_removals: + self.pending_tag_removals[photo_id] = [] + if tid not in self.pending_tag_removals[photo_id]: + self.pending_tag_removals[photo_id].append(tid) + affected += 1 + self._update_save_button_text() + self._switch_view_mode(view_mode) + result_var.set(f"Marked bulk tag removals affecting {affected} linkage(s)") + refresh_bulk_tag_list() + + ttk.Button(bottom_frame, text="Remove selected bulk tags", command=remove_selected_bulk_tags).pack(side=tk.LEFT) + ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT) + refresh_bulk_tag_list() + + is_expanded = self.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: self._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) + + # Bulk linkage icon (applies selected tag to all photos in this folder) + bulk_link_btn = tk.Button(folder_header_frame, text="🔗", width=2, command=open_bulk_link_dialog) + bulk_link_btn.pack(side=tk.LEFT, padx=(6, 6)) + + # Compute and show bulk tags for this folder + def compute_folder_bulk_tags() -> str: + bulk_tag_ids = set() + # Gather saved bulk tags + for photo in folder_info.get('photos', []): + photo_id = photo['id'] + saved_types = self._get_saved_tag_types_for_photo(photo_id) + for tid, ltype in saved_types.items(): + if ltype == 1: + bulk_tag_ids.add(tid) + # Include pending bulk adds; exclude pending removals + for photo in folder_info.get('photos', []): + photo_id = photo['id'] + for tid in self.pending_tag_changes.get(photo_id, []): + if self.pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1: + if tid not in self.pending_tag_removals.get(photo_id, []): + bulk_tag_ids.add(tid) + # Exclude any saved bulk tags marked for removal + for tid in self.pending_tag_removals.get(photo_id, []): + if tid in bulk_tag_ids: + bulk_tag_ids.discard(tid) + names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in sorted(bulk_tag_ids, key=lambda x: self.tag_id_to_name.get(x, ""))] + return ", ".join(names) if names else "None" + + # Append bulk tags to the folder label so it's clearly visible + try: + tags_str = compute_folder_bulk_tags() + if tags_str and tags_str != "None": + folder_label.configure(text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos) — Tags: {tags_str}") + else: + folder_label.configure(text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos)") + except Exception: + pass + + return folder_header_frame + + def _toggle_folder(self, folder_path, view_mode): + """Toggle folder expand/collapse state""" + self.folder_states[folder_path] = not self.folder_states.get(folder_path, True) + self._switch_view_mode(view_mode) + + def _create_tag_buttons_frame(self, parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0): + """Create tag management buttons for a photo""" + tags_frame = ttk.Frame(parent) + existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) + pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])] + pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.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=self._create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags, 0)) + 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 _create_add_tag_handler(self, photo_id, label_widget, photo_tags, available_tags, allowed_delete_type: int = 0): + """Create a tag management dialog handler for a photo""" + def handler(): + popup = tk.Toplevel(self.main_frame) + popup.title("Manage Photo Tags") + popup.transient(self.main_frame) + 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 _update_tags_label_in_main_list(): + # Recompute combined display for the main list label (saved + pending minus removals) + existing_tags_list = self.tag_manager.parse_tags_string(photo_tags) + pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])] + pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_removals.get(photo_id, [])] + all_tags = existing_tags_list + pending_tag_names + unique_tags = self.tag_manager.deduplicate_tags(all_tags) + # Remove tags marked for removal from display + 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" + try: + label_widget.configure(text=current_tags) + except Exception: + pass + + def add_selected_tag(): + tag_name = tag_var.get().strip() + if not tag_name: + return + # Case-insensitive tag lookup + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in self.tag_name_to_id: + tag_id = self.tag_name_to_id[normalized_tag_name] + else: + # Create new tag using database method + tag_id = self.db.add_tag(tag_name) + if tag_id: + self.tag_name_to_id[normalized_tag_name] = tag_id + self.tag_id_to_name[tag_id] = tag_name + if tag_name not in self.existing_tags: + self.existing_tags.append(tag_name) + self.existing_tags.sort() + else: + return # Failed to create tag + saved_types = self._get_saved_tag_types_for_photo(photo_id) + existing_tag_ids = list(saved_types.keys()) + pending_tag_ids = self.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 self.pending_tag_changes: + self.pending_tag_changes[photo_id] = [] + self.pending_tag_changes[photo_id].append(tag_id) + # mark pending type as single (0) + if photo_id not in self.pending_tag_linkage_type: + self.pending_tag_linkage_type[photo_id] = {} + self.pending_tag_linkage_type[photo_id][tag_id] = 0 + refresh_tag_list() + self._update_save_button_text() + _update_tags_label_in_main_list() + 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() + saved_types = self._get_saved_tag_types_for_photo(photo_id) + existing_tag_ids = list(saved_types.keys()) + pending_tag_ids = self.pending_tag_changes.get(photo_id, []) + pending_removal_ids = self.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 = [self.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 = self.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) + is_pending = tag_id in pending_tag_ids + saved_type = saved_types.get(tag_id) + pending_type = self.pending_tag_linkage_type.get(photo_id, {}).get(tag_id) + # In single-photo dialog, only allow selecting pending if pending type is single (0) + is_pending_single = is_pending and pending_type == 0 + can_select = is_pending_single or (saved_type == allowed_delete_type) + cb = ttk.Checkbutton(frame, variable=var) + if not can_select: + try: + cb.state(["disabled"]) # disable selection for disallowed types + except Exception: + pass + cb.pack(side=tk.LEFT, padx=(0, 5)) + type_label = 'single' if saved_type == 0 else ('bulk' if saved_type == 1 else '?') + pending_label = "pending bulk" if (is_pending and pending_type == 1) else "pending" + status_text = f" ({pending_label})" if is_pending else f" (saved {type_label})" + status_color = "blue" if is_pending else ("black" if can_select else "gray") + ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT) + + def remove_selected_tags(): + tag_ids_to_remove = [] + for tag_name, var in selected_tag_vars.items(): + if var.get() and tag_name in self.tag_name_to_id: + tag_ids_to_remove.append(self.tag_name_to_id[tag_name]) + if not tag_ids_to_remove: + return + if photo_id in self.pending_tag_changes: + self.pending_tag_changes[photo_id] = [tid for tid in self.pending_tag_changes[photo_id] if tid not in tag_ids_to_remove] + if not self.pending_tag_changes[photo_id]: + del self.pending_tag_changes[photo_id] + saved_types = self._get_saved_tag_types_for_photo(photo_id) + for tag_id in tag_ids_to_remove: + if saved_types.get(tag_id) == allowed_delete_type: + if photo_id not in self.pending_tag_removals: + self.pending_tag_removals[photo_id] = [] + if tag_id not in self.pending_tag_removals[photo_id]: + self.pending_tag_removals[photo_id].append(tag_id) + refresh_tag_list() + self._update_save_button_text() + _update_tags_label_in_main_list() + + 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 = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])] + pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.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 _show_list_view(self): + """Show the list view""" + self._clear_content() + visible_cols = [col for col in self.column_config['list'] if self.column_visibility['list'][col['key']]] + col_count = len(visible_cols) + if col_count == 0: + ttk.Label(self.components['content_inner'], text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure columns + for i, col in enumerate(visible_cols): + self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header + header_frame = ttk.Frame(self.components['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) + + ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Create folder grouped data and display + folder_data = self._prepare_folder_grouped_data() + current_row = 2 + for folder_info in folder_data: + self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'list') + current_row += 1 + if self.folder_states.get(folder_info['folder_path'], True): + for photo in folder_info['photos']: + row_frame = ttk.Frame(self.components['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']) + + for i, col in enumerate(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': + self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=i) + continue + + # Render text wrapped to header width + if key == 'filename': + lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", + wraplength=col['width'], anchor='w', justify='left') + lbl.grid(row=0, column=i, padx=5, sticky=tk.W) + lbl.bind("", lambda e, p=photo['path']: self._open_photo(p)) + elif key == 'faces' and photo['face_count'] > 0: + lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", + wraplength=col['width'], anchor='w', justify='left') + lbl.grid(row=0, column=i, padx=5, sticky=tk.W) + # Show people names in tooltip on hover + try: + people_tooltip_text = self._get_people_names_for_photo(photo['id']) + self._create_tooltip(lbl, people_tooltip_text) + except Exception: + pass + else: + ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=i, padx=5, sticky=tk.W) + current_row += 1 + + def _show_icon_view(self): + """Show the icon view""" + self._clear_content() + visible_cols = [col for col in self.column_config['icons'] if self.column_visibility['icons'][col['key']]] + col_count = len(visible_cols) + if col_count == 0: + ttk.Label(self.components['content_inner'], text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure columns + for i, col in enumerate(visible_cols): + self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header + header_frame = ttk.Frame(self.components['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) + + ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Create folder grouped data and display + folder_data = self._prepare_folder_grouped_data() + current_row = 2 + for folder_info in folder_data: + self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'icons') + current_row += 1 + if self.folder_states.get(folder_info['folder_path'], True): + for photo in folder_info['photos']: + row_frame = ttk.Frame(self.components['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) + self.photo_images.append(photo_img) + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', highlightthickness=0) + canvas.pack() + canvas.create_image(75, 75, image=photo_img) + else: + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', 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='lightgray', 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': + self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=col_idx) + col_idx += 1 + continue + + # Render text wrapped to header width + if key == 'filename': + lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", + wraplength=col['width'], anchor='w', justify='left') + lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + lbl.bind("", lambda e, p=photo['path']: self._open_photo(p)) + elif key == 'faces' and photo['face_count'] > 0: + lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", + wraplength=col['width'], anchor='w', justify='left') + lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + # Show people names in tooltip on hover + try: + people_tooltip_text = self._get_people_names_for_photo(photo['id']) + self._create_tooltip(lbl, people_tooltip_text) + except Exception: + pass + else: + ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W) + col_idx += 1 + current_row += 1 + + def _show_compact_view(self): + """Show the compact view""" + self._clear_content() + visible_cols = [col for col in self.column_config['compact'] if self.column_visibility['compact'][col['key']]] + col_count = len(visible_cols) + if col_count == 0: + ttk.Label(self.components['content_inner'], text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure columns + for i, col in enumerate(visible_cols): + self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header + header_frame = ttk.Frame(self.components['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) + + ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Create folder grouped data and display + folder_data = self._prepare_folder_grouped_data() + current_row = 2 + for folder_info in folder_data: + self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'compact') + current_row += 1 + if self.folder_states.get(folder_info['folder_path'], True): + for photo in folder_info['photos']: + row_frame = ttk.Frame(self.components['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': + self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=col_idx) + col_idx += 1 + continue + + # Render text wrapped to header width + if key == 'filename': + lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", + wraplength=col['width'], anchor='w', justify='left') + lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + lbl.bind("", lambda e, p=photo['path']: self._open_photo(p)) + elif key == 'faces' and photo['face_count'] > 0: + lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", + wraplength=col['width'], anchor='w', justify='left') + lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + # Show people names in tooltip on hover + try: + people_tooltip_text = self._get_people_names_for_photo(photo['id']) + self._create_tooltip(lbl, people_tooltip_text) + except Exception: + pass + else: + ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W) + col_idx += 1 + current_row += 1 + + def _create_tooltip(self, widget, text: str): + """Create a simple tooltip for a widget""" + def show_tooltip(event): + tooltip = tk.Toplevel() + tooltip.wm_overrideredirect(True) + tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}") + label = ttk.Label(tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1) + label.pack() + widget.tooltip = tooltip + + def hide_tooltip(event): + if hasattr(widget, 'tooltip'): + widget.tooltip.destroy() + del widget.tooltip + + widget.bind("", show_tooltip) + widget.bind("", hide_tooltip) + + def activate(self): + """Activate the panel""" + self.is_active = True + # Rebind mousewheel scrolling when activated + self._bind_mousewheel_scrolling() + + def deactivate(self): + """Deactivate the panel""" + if self.is_active: + self._cleanup() + self.is_active = False + + def _cleanup(self): + """Clean up resources""" + # Unbind mousewheel scrolling + self._unbind_mousewheel_scrolling() + + # Clean up temp crops + for crop in list(self.temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except Exception: + pass + self.temp_crops.clear() + + # Clear photo images + self.photo_images.clear() + + # Clear state + self.pending_tag_changes.clear() + self.pending_tag_removals.clear() + self.pending_tag_linkage_type.clear() + self.folder_states.clear() + + def update_layout(self): + """Update panel layout for responsiveness""" + if hasattr(self, 'components') and 'content_canvas' in self.components: + # Update content canvas scroll region + canvas = self.components['content_canvas'] + canvas.update_idletasks() + canvas.configure(scrollregion=canvas.bbox("all"))