diff --git a/README.md b/README.md index 763bed6..87d5e17 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,9 @@ This GUI provides a file explorer-like interface for managing photo tags with ad - 📱 **Responsive Layout** - Adapts to window size with proper scrolling - 🎨 **Modern Interface** - Clean, intuitive design with visual feedback - ⚡ **Fast Performance** - Optimized for large photo collections +- 🏷️ **Smart Tag Management** - Duplicate tag prevention with silent handling +- 🔄 **Accurate Change Tracking** - Only counts photos with actual new tags as "changed" +- 🎯 **Reliable Tag Operations** - Uses tag IDs internally for consistent, bug-free behavior **📋 Available View Modes:** @@ -397,6 +400,8 @@ The tool uses SQLite database (`data/photos.db` by default) with these tables: - **No Duplicate Tags** - Unique constraint prevents duplicate tag-photo combinations - **Optimized Queries** - Efficient indexing and query patterns for fast performance - **Data Integrity** - Proper foreign key relationships and constraints +- **Tag ID-Based Operations** - All tag operations use efficient ID-based lookups instead of string comparisons +- **Robust Tag Handling** - Eliminates string parsing issues and edge cases in tag management ## ⚙️ Configuration @@ -774,6 +779,18 @@ This is now a minimal, focused tool. Key principles: - ✅ **Unique Constraint Update** - Prevents duplicate people with same combination of all five fields - ✅ **Streamlined Data Entry** - All name fields are now simple text inputs for faster typing +### 🏷️ Tag System Improvements (NEW!) +- ✅ **Tag ID-Based Architecture** - Complete refactoring to use tag IDs internally instead of tag names +- ✅ **Eliminated String Parsing Issues** - No more problems with empty strings, whitespace, or parsing errors +- ✅ **Improved Performance** - Tag ID comparisons are faster than string comparisons +- ✅ **Better Reliability** - No case sensitivity issues or string parsing bugs +- ✅ **Database Efficiency** - Direct ID operations instead of string lookups +- ✅ **Cleaner Architecture** - Clear separation between internal logic (IDs) and display (names) +- ✅ **Duplicate Prevention** - Silent prevention of duplicate tags without warning messages +- ✅ **Accurate Change Counting** - Only photos with actual new tags are counted as "changed" +- ✅ **Robust Tag Parsing** - Handles edge cases like empty tag strings and malformed data +- ✅ **Consistent Behavior** - All tag operations use the same reliable logic throughout the application + ### 🎨 Enhanced Modify Identified Interface (NEW!) - ✅ **Complete Person Information** - Shows full names with middle names, maiden names, and birth dates - ✅ **Last Name Search** - Filter people by last name with case-insensitive search diff --git a/photo_tagger.py b/photo_tagger.py index bbc27c1..32f8947 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -94,6 +94,60 @@ class PhotoTagger: # Clear caches self._clear_caches() + def _deduplicate_tags(self, tag_list): + """Remove duplicate tags from a list while preserving order (case insensitive)""" + seen = set() + unique_tags = [] + for tag in tag_list: + if tag.lower() not in seen: + seen.add(tag.lower()) + unique_tags.append(tag) + return unique_tags + + def _parse_tags_string(self, tags_string): + """Parse a comma-separated tags string into a list, handling empty strings and whitespace""" + if not tags_string or tags_string.strip() == "": + return [] + # Split by comma and strip whitespace from each tag + tags = [tag.strip() for tag in tags_string.split(",")] + # Remove empty strings that might result from splitting + return [tag for tag in tags if tag] + + def _get_tag_id_by_name(self, tag_name, tag_name_to_id_map): + """Get tag ID by name, creating the tag if it doesn't exist""" + if tag_name in tag_name_to_id_map: + return tag_name_to_id_map[tag_name] + return None + + def _get_tag_name_by_id(self, tag_id, tag_id_to_name_map): + """Get tag name by ID""" + return tag_id_to_name_map.get(tag_id, f"Unknown Tag {tag_id}") + + def _load_tag_mappings(self): + """Load tag name to ID and ID to name mappings from database""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') + tag_id_to_name = {} + tag_name_to_id = {} + for row in cursor.fetchall(): + tag_id, tag_name = row + tag_id_to_name[tag_id] = tag_name + tag_name_to_id[tag_name] = tag_id + return tag_id_to_name, tag_name_to_id + + def _get_existing_tag_ids_for_photo(self, photo_id): + """Get list of tag IDs for a photo from database""" + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT ptl.tag_id + FROM phototaglinkage ptl + WHERE ptl.photo_id = ? + ORDER BY ptl.created_date + ''', (photo_id,)) + return [row[0] for row in cursor.fetchall()] + def _setup_window_size_saving(self, root, config_file="gui_config.json"): """Set up window size saving functionality""" import json @@ -4281,9 +4335,11 @@ class PhotoTagger: # Track folder expand/collapse states folder_states = {} # folder_path -> is_expanded - # Track pending tag changes (photo_id -> list of tag names) + # Track pending tag changes (photo_id -> list of tag IDs) pending_tag_changes = {} - existing_tags = [] # Cache of existing tags from database + existing_tags = [] # Cache of existing tag names from database (for UI display) + tag_id_to_name = {} # Cache of tag ID to name mapping + tag_name_to_id = {} # Cache of tag name to ID mapping # Hide window initially to prevent flash at corner root.withdraw() @@ -4344,6 +4400,170 @@ class PhotoTagger: 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 + def open_manage_tags_dialog(): + """Open a dialog to manage tags: list, edit, add, and delete.""" + import tkinter as tk + from tkinter import ttk, messagebox, simpledialog + + # Dialog window + dialog = tk.Toplevel(root) + dialog.title("Manage Tags") + dialog.transient(root) + dialog.grab_set() + dialog.geometry("500x500") + + # Layout frames + 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) + + # Add tag controls (top) + 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.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() + # Refresh main view to reflect new tag options + 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) + + # Scrollable tag list (center) + 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"))) + + # Selection tracking + selected_tag_vars = {} + current_tags = [] # list of dicts: {id, tag_name} + + def refresh_tag_list(): + # Clear rows + for child in list(rows_container.winfo_children()): + child.destroy() + selected_tag_vars.clear() + current_tags.clear() + # Load tags + try: + with self.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 + # Build header + head = ttk.Frame(rows_container) + head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6)) + chk_lbl = ttk.Label(head, text="Delete") + chk_lbl.pack(side=tk.LEFT, padx=(0, 10)) + name_lbl = ttk.Label(head, text="Tag name", width=30) + name_lbl.pack(side=tk.LEFT) + act_lbl = ttk.Label(head, text="Edit", width=6) + act_lbl.pack(side=tk.LEFT, padx=(10, 0)) + + # Populate rows + 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 + chk = ttk.Checkbutton(row, variable=var) + chk.pack(side=tk.LEFT, padx=(0, 10)) + name = ttk.Label(row, text=tag['tag_name'], width=30) + name.pack(side=tk.LEFT) + + def make_edit_handler(tag_id, name_label): + def handler(): + new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:", initialvalue=name_label.cget('text'), parent=dialog) + if new_name is None: + return + new_name = new_name.strip() + if not new_name: + return + try: + with self.get_db_connection() as conn: + cursor = conn.cursor() + # Ensure name is unique + 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 + # Update UI and caches + refresh_tag_list() + load_existing_tags() + switch_view_mode(view_mode_var.get()) + return handler + + edit_btn = ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name)) + edit_btn.pack(side=tk.LEFT, padx=(10, 0)) + + refresh_tag_list() + + # Bottom buttons + 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.get_db_connection() as conn: + cursor = conn.cursor() + # Remove linkages first to maintain integrity + cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) + # Delete tags + cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete) + conn.commit() + refresh_tag_list() + load_existing_tags() + switch_view_mode(view_mode_var.get()) + except Exception as e: + messagebox.showerror("Error", f"Failed to delete tags: {e}") + + delete_btn = ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected) + delete_btn.pack(side=tk.LEFT) + quit_btn = ttk.Button(bottom_frame, text="Quit", command=dialog.destroy) + quit_btn.pack(side=tk.RIGHT) + + # Keyboard focus + new_tag_entry.focus_set() + + manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=open_manage_tags_dialog) + manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0)) + # Main content area content_frame = ttk.Frame(main_frame) content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) @@ -4499,9 +4719,9 @@ class PhotoTagger: # Column visibility state column_visibility = { - 'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True, 'tagging': True}, - 'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True, 'tagging': True}, - 'compact': {'filename': True, 'faces': True, 'tags': True, 'tagging': True} + '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 order and configuration @@ -4513,8 +4733,7 @@ class PhotoTagger: {'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': 150, 'weight': 1}, - {'key': 'tagging', 'label': 'Tagging', 'width': 200, 'weight': 0} + {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} ], 'icons': [ {'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0}, @@ -4523,14 +4742,12 @@ class PhotoTagger: {'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': 150, 'weight': 1}, - {'key': 'tagging', 'label': 'Tagging', 'width': 200, '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': 150, 'weight': 1}, - {'key': 'tagging', 'label': 'Tagging', 'width': 200, 'weight': 0} + {'key': 'tags', 'label': 'Tags', 'width': 180, 'weight': 1} ] } @@ -4624,11 +4841,18 @@ class PhotoTagger: def load_existing_tags(): """Load existing tags from database""" - nonlocal existing_tags + nonlocal existing_tags, tag_id_to_name, tag_name_to_id with self.get_db_connection() as conn: cursor = conn.cursor() - cursor.execute('SELECT tag_name FROM tags ORDER BY tag_name') - existing_tags = [row[0] for row in cursor.fetchall()] + 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=""): """Create a tagging widget with dropdown and text input""" @@ -4652,7 +4876,9 @@ class PhotoTagger: # Initialize pending tags display if photo_id in pending_tag_changes: - pending_tags_var.set(", ".join(pending_tag_changes[photo_id])) + # Convert tag IDs to names for display + 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 "") @@ -4660,16 +4886,36 @@ class PhotoTagger: def add_tag(): tag_name = tag_var.get().strip() if tag_name: - # Add to pending changes - if photo_id not in pending_tag_changes: - pending_tag_changes[photo_id] = [] + # Get or create tag ID + if tag_name in tag_name_to_id: + tag_id = tag_name_to_id[tag_name] + else: + # Create new tag in database + with self.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] + # Update mappings + 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() - # Check if tag already exists (case insensitive) - tag_exists = any(tag.lower() == tag_name.lower() for tag in pending_tag_changes[photo_id]) - if not tag_exists: - pending_tag_changes[photo_id].append(tag_name) + # Check if tag already exists (compare tag IDs) before adding to pending changes + existing_tag_ids = self._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: + # Only add to pending changes if tag is actually new + if photo_id not in pending_tag_changes: + pending_tag_changes[photo_id] = [] + pending_tag_changes[photo_id].append(tag_id) # Update display - pending_tags_var.set(", ".join(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)) tag_var.set("") # Clear the input field add_button = tk.Button(tagging_frame, text="+", width=2, height=1, command=add_tag) @@ -4680,7 +4926,9 @@ class PhotoTagger: 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_tags_var.set(", ".join(pending_tag_changes[photo_id])) + # Convert tag IDs to names for display + 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] @@ -4700,21 +4948,8 @@ class PhotoTagger: with self.get_db_connection() as conn: cursor = conn.cursor() - for photo_id, tag_names in pending_tag_changes.items(): - for tag_name in tag_names: - # Insert or get tag_id (case insensitive) - cursor.execute( - 'INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', - (tag_name,) - ) - - # Get tag_id (case insensitive lookup) - cursor.execute( - 'SELECT id FROM tags WHERE LOWER(tag_name) = LOWER(?)', - (tag_name,) - ) - tag_id = cursor.fetchone()[0] - + for photo_id, tag_ids in pending_tag_changes.items(): + for tag_id in tag_ids: # Insert linkage (ignore if already exists) cursor.execute( 'INSERT OR IGNORE INTO phototaglinkage (photo_id, tag_id) VALUES (?, ?)', @@ -4723,17 +4958,29 @@ class PhotoTagger: conn.commit() + # Store count before clearing + saved_count = len(pending_tag_changes) + # Clear pending changes and reload data pending_tag_changes.clear() load_existing_tags() load_photos() switch_view_mode(view_mode_var.get()) + update_save_button_text() - messagebox.showinfo("Success", f"Saved tags for {len(pending_tag_changes)} photos.") + messagebox.showinfo("Success", f"Saved tags for {saved_count} photos.") except Exception as e: messagebox.showerror("Error", f"Failed to save tags: {str(e)}") + def update_save_button_text(): + """Update save button text to show pending changes count""" + if pending_tag_changes: + total_changes = sum(len(tags) for tags in pending_tag_changes.values()) + save_button.configure(text=f"Save Tagging ({total_changes} pending)") + else: + save_button.configure(text="Save Tagging") + # Configure the save button command now that the function is defined save_button.configure(command=save_tagging_changes) @@ -4943,11 +5190,93 @@ class PhotoTagger: elif key == 'faces': text = str(photo['face_count']) elif key == 'tags': - text = photo['tags'] or "None" - elif key == 'tagging': - # Create tagging widget - tagging_widget = create_tagging_widget(row_frame, photo['id'], photo['tags'] or "") - tagging_widget.grid(row=0, column=i, padx=5, sticky=tk.W) + # Tags cell with inline '+' to add tags + tags_frame = ttk.Frame(row_frame) + tags_frame.grid(row=0, column=i, padx=5, sticky=tk.W) + + # Show current tags + pending changes + current_display = photo['tags'] or "" + if photo['id'] in pending_tag_changes: + # Convert pending tag IDs to names for display + pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo['id']]] + existing_tags_list = self._parse_tags_string(current_display) + all_tags = existing_tags_list + pending_tag_names + current_display = ", ".join(all_tags) + if not current_display: + current_display = "None" + tags_text = ttk.Label(tags_frame, text=current_display) + tags_text.pack(side=tk.LEFT) + + def make_add_tag_handler(photo_id, label_widget, photo_tags, available_tags): + def handler(): + # Simple dropdown to choose existing tag + popup = tk.Toplevel(root) + popup.title("Add Tag") + popup.transient(root) + popup.grab_set() + popup.geometry("300x150") + popup.resizable(False, False) + + # Create main frame + main_frame = ttk.Frame(popup, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(main_frame, text="Select a tag:").pack(pady=(0, 5)) + + tag_var = tk.StringVar() + combo = ttk.Combobox(main_frame, textvariable=tag_var, values=available_tags, width=30) + combo.pack(pady=(0, 10), fill=tk.X) + combo.focus_set() + + def confirm(): + tag_name = tag_var.get().strip() + if not tag_name: + popup.destroy() + return + + # Get or create tag ID + if tag_name in tag_name_to_id: + tag_id = tag_name_to_id[tag_name] + else: + # Create new tag in database + with self.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] + # Update mappings + 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() + + # Check if tag already exists (compare tag IDs) before adding to pending changes + existing_tag_ids = self._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: + # Only add to pending changes if tag is actually new + if photo_id not in pending_tag_changes: + pending_tag_changes[photo_id] = [] + pending_tag_changes[photo_id].append(tag_id) + # Update the display immediately - combine existing and pending, removing duplicates + existing_tags_list = self._parse_tags_string(photo_tags) + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + all_tags = existing_tags_list + pending_tag_names + unique_tags = self._deduplicate_tags(all_tags) + current_tags = ", ".join(unique_tags) + label_widget.configure(text=current_tags) + update_save_button_text() + + popup.destroy() + + ttk.Button(main_frame, text="Add", command=confirm).pack(pady=(0, 5)) + return handler + + add_btn = tk.Button(tags_frame, text="+", width=2, command=make_add_tag_handler(photo['id'], tags_text, photo['tags'], existing_tags)) + add_btn.pack(side=tk.LEFT, padx=(6,0)) continue ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W) @@ -5052,11 +5381,78 @@ class PhotoTagger: elif key == 'faces': text = str(photo['face_count']) elif key == 'tags': - text = photo['tags'] or "None" - elif key == 'tagging': - # Create tagging widget - tagging_widget = create_tagging_widget(row_frame, photo['id'], photo['tags'] or "") - tagging_widget.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + # Tags cell with inline '+' to add tags + tags_frame = ttk.Frame(row_frame) + tags_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + + # Show current tags + pending changes + current_display = photo['tags'] or "None" + if photo['id'] in pending_tag_changes: + # Convert pending tag IDs to names for display + pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo['id']]] + current_display = ", ".join(pending_tag_names) + tags_text = ttk.Label(tags_frame, text=current_display) + tags_text.pack(side=tk.LEFT) + + def make_add_tag_handler(photo_id, label_widget, photo_tags, available_tags): + def handler(): + popup = tk.Toplevel(root) + popup.title("Add Tag") + popup.transient(root) + popup.grab_set() + ttk.Label(popup, text="Select a tag:").grid(row=0, column=0, padx=8, pady=8, sticky=tk.W) + tag_var = tk.StringVar() + combo = ttk.Combobox(popup, textvariable=tag_var, values=available_tags, width=24) + combo.grid(row=1, column=0, padx=8, pady=(0,8), sticky=(tk.W, tk.E)) + combo.focus_set() + + def confirm(): + tag_name = tag_var.get().strip() + if not tag_name: + popup.destroy() + return + + # Get or create tag ID + if tag_name in tag_name_to_id: + tag_id = tag_name_to_id[tag_name] + else: + # Create new tag in database + with self.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] + # Update mappings + 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() + + # Add to pending changes instead of saving immediately + if photo_id not in pending_tag_changes: + pending_tag_changes[photo_id] = [] + + # Check if tag already exists (compare tag IDs) + existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) + all_existing_tag_ids = existing_tag_ids + pending_tag_changes[photo_id] + if tag_id not in all_existing_tag_ids: + pending_tag_changes[photo_id].append(tag_id) + # Update the display immediately - combine existing and pending, removing duplicates + existing_tags_list = self._parse_tags_string(photo_tags) + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + all_tags = existing_tags_list + pending_tag_names + unique_tags = self._deduplicate_tags(all_tags) + current_tags = ", ".join(unique_tags) + label_widget.configure(text=current_tags) + + popup.destroy() + + ttk.Button(popup, text="Add", command=confirm).grid(row=2, column=0, padx=8, pady=(0,8), sticky=tk.E) + return handler + + add_btn = tk.Button(tags_frame, text="+", width=2, command=make_add_tag_handler(photo['id'], tags_text, photo['tags'], existing_tags)) + add_btn.pack(side=tk.LEFT, padx=(6,0)) col_idx += 1 continue @@ -5129,11 +5525,84 @@ class PhotoTagger: elif key == 'faces': text = str(photo['face_count']) elif key == 'tags': - text = photo['tags'] or "None" - elif key == 'tagging': - # Create tagging widget - tagging_widget = create_tagging_widget(row_frame, photo['id'], photo['tags'] or "") - tagging_widget.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + # Tags cell with inline '+' to add tags + tags_frame = ttk.Frame(row_frame) + tags_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + + # Show current tags + pending changes + current_display = photo['tags'] or "" + if photo['id'] in pending_tag_changes: + # Convert pending tag IDs to names for display + pending_tag_names = [tag_id_to_name.get(tag_id, f"Unknown {tag_id}") for tag_id in pending_tag_changes[photo['id']]] + existing_tags_list = self._parse_tags_string(current_display) + all_tags = existing_tags_list + pending_tag_names + current_display = ", ".join(all_tags) + if not current_display: + current_display = "None" + tags_text = ttk.Label(tags_frame, text=current_display) + tags_text.pack(side=tk.LEFT) + + def make_add_tag_handler(photo_id, label_widget, photo_tags, available_tags): + def handler(): + popup = tk.Toplevel(root) + popup.title("Add Tag") + popup.transient(root) + popup.grab_set() + ttk.Label(popup, text="Select a tag:").grid(row=0, column=0, padx=8, pady=8, sticky=tk.W) + tag_var = tk.StringVar() + combo = ttk.Combobox(popup, textvariable=tag_var, values=available_tags, width=24) + combo.grid(row=1, column=0, padx=8, pady=(0,8), sticky=(tk.W, tk.E)) + combo.focus_set() + + def confirm(): + tag_name = tag_var.get().strip() + if not tag_name: + popup.destroy() + return + + # Get or create tag ID + if tag_name in tag_name_to_id: + tag_id = tag_name_to_id[tag_name] + else: + # Create new tag in database + with self.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] + # Update mappings + 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() + + # Check if tag already exists (compare tag IDs) before adding to pending changes + existing_tag_ids = self._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: + # Only add to pending changes if tag is actually new + if photo_id not in pending_tag_changes: + pending_tag_changes[photo_id] = [] + pending_tag_changes[photo_id].append(tag_id) + # Update the display immediately - combine existing and pending, removing duplicates + existing_tags_list = self._parse_tags_string(photo_tags) + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + all_tags = existing_tags_list + pending_tag_names + unique_tags = self._deduplicate_tags(all_tags) + current_tags = ", ".join(unique_tags) + label_widget.configure(text=current_tags) + update_save_button_text() + + popup.destroy() + + ttk.Button(popup, text="Add", command=confirm).grid(row=2, column=0, padx=8, pady=(0,8), sticky=tk.E) + return handler + + add_btn = tk.Button(tags_frame, text="+", width=2, command=make_add_tag_handler(photo['id'], tags_text, photo['tags'], existing_tags)) + add_btn.pack(side=tk.LEFT, padx=(6,0)) col_idx += 1 continue