diff --git a/README.md b/README.md index 87d5e17..830c2a9 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,11 @@ This GUI provides a file explorer-like interface for managing photo tags with ad - 🏷️ **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 +- 🔗 **Enhanced Tag Linking** - Linkage icon (🔗) for intuitive tag management +- 📋 **Comprehensive Tag Dialog** - Manage tags dialog similar to Manage Tags interface +- ✅ **Pending Tag System** - Add and remove tags with pending changes until saved +- 🎯 **Visual Status Indicators** - Clear distinction between saved and pending tags +- 🗑️ **Smart Tag Removal** - Remove both pending and saved tags with proper tracking **📋 Available View Modes:** @@ -245,6 +250,18 @@ This GUI provides a file explorer-like interface for managing photo tags with ad - 🖼️ **Photo Details** - Expanded folders show all photos with their metadata - 🔄 **Persistent State** - Folder expansion state is maintained while browsing +**🏷️ Enhanced Tag Management System:** +- 🔗 **Linkage Icon** - Click the 🔗 button next to tags to open the tag management dialog +- 📋 **Comprehensive Dialog** - Similar interface to Manage Tags with dropdown selection and tag listing +- ✅ **Pending Changes** - Add and remove tags with changes tracked until "Save Tagging" is clicked +- 🎯 **Visual Status** - Tags show "(pending)" in blue or "(saved)" in black for clear status indication +- 🗑️ **Smart Removal** - Remove both pending and saved tags with proper database tracking +- 📊 **Batch Operations** - Select multiple tags for removal with checkboxes +- 🔄 **Real-time Updates** - Tag display updates immediately when changes are made +- 💾 **Save System** - All tag changes (additions and removals) saved atomically when "Save Tagging" is clicked +- 🎯 **ID-Based Architecture** - Uses tag IDs internally for efficient, reliable operations +- ⚡ **Performance Optimized** - Fast tag operations with minimal database queries + **Left Panel (People):** - 🔍 **Last Name Search** - Search box to filter people by last name (case-insensitive) - 🔎 **Search Button** - Apply filter to show only matching people @@ -791,6 +808,21 @@ This is now a minimal, focused tool. Key principles: - ✅ **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 Tag Management Interface (LATEST!) +- ✅ **Linkage Icon** - Replaced "+" button with intuitive 🔗 linkage icon for tag management +- ✅ **Comprehensive Tag Dialog** - Redesigned tag management dialog similar to Manage Tags interface +- ✅ **Dropdown Tag Selection** - Select from existing tags or create new ones via dropdown +- ✅ **Pending Tag System** - Add and remove tags with changes tracked until explicitly saved +- ✅ **Visual Status Indicators** - Clear distinction between saved tags (black) and pending tags (blue) +- ✅ **Smart Tag Removal** - Remove both pending and saved tags with proper database tracking +- ✅ **Batch Tag Operations** - Select multiple tags for removal with checkboxes +- ✅ **Real-time UI Updates** - Tag display updates immediately when changes are made +- ✅ **Atomic Save Operations** - All tag changes (additions and removals) saved in single transaction +- ✅ **Efficient ID-Based Operations** - Uses tag IDs internally for fast, reliable tag management +- ✅ **Scrollable Tag Lists** - Handle photos with many tags in scrollable interface +- ✅ **Immediate Visual Feedback** - Removed tags disappear from UI immediately +- ✅ **Database Integrity** - Proper cleanup of pending changes when tags are deleted + ### 🎨 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 f150095..b4108e6 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -4337,6 +4337,8 @@ class PhotoTagger: # Track pending tag changes (photo_id -> list of tag IDs) pending_tag_changes = {} + # Track pending tag removals (photo_id -> list of tag IDs to remove) + pending_tag_removals = {} 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 @@ -4554,6 +4556,12 @@ class PhotoTagger: if not pending_tag_changes[photo_id]: del pending_tag_changes[photo_id] + # Clean up pending tag removals for deleted tags + 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() # Refresh photo data to reflect deleted tags @@ -4948,7 +4956,7 @@ class PhotoTagger: def save_tagging_changes(): """Save all pending tag changes to database""" - if not pending_tag_changes: + if not pending_tag_changes and not pending_tag_removals: messagebox.showinfo("Info", "No tag changes to save.") return @@ -4956,6 +4964,7 @@ class PhotoTagger: with self.get_db_connection() as conn: cursor = conn.cursor() + # Handle tag additions for photo_id, tag_ids in pending_tag_changes.items(): for tag_id in tag_ids: # Insert linkage (ignore if already exists) @@ -4964,27 +4973,45 @@ class PhotoTagger: (photo_id, tag_id) ) + # Handle tag removals + for photo_id, tag_ids in pending_tag_removals.items(): + for tag_id in tag_ids: + # Remove linkage + cursor.execute( + 'DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', + (photo_id, tag_id) + ) + conn.commit() - # Store count before clearing - saved_count = len(pending_tag_changes) + # Store counts before clearing + saved_additions = len(pending_tag_changes) + saved_removals = len(pending_tag_removals) # Clear pending changes and reload data pending_tag_changes.clear() + pending_tag_removals.clear() load_existing_tags() load_photos() switch_view_mode(view_mode_var.get()) update_save_button_text() - messagebox.showinfo("Success", f"Saved tags for {saved_count} photos.") + 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(): """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()) + 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") @@ -5104,29 +5131,35 @@ class PhotoTagger: def create_add_tag_handler(photo_id, label_widget, photo_tags, available_tags): """Create a handler function for adding tags to a photo""" def handler(): - # Create popup window for tag selection + # Create popup window for tag management popup = tk.Toplevel(root) - popup.title("Add Tag") + popup.title("Manage Photo Tags") popup.transient(root) popup.grab_set() - popup.geometry("300x150") - popup.resizable(False, False) + popup.geometry("500x400") + popup.resizable(True, True) - # Create main frame - main_frame = ttk.Frame(popup, padding="10") - main_frame.pack(fill=tk.BOTH, expand=True) + # Layout frames + 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)) - ttk.Label(main_frame, text="Select a tag:").pack(pady=(0, 5)) + popup.columnconfigure(0, weight=1) + popup.rowconfigure(1, weight=1) + # Top frame - dropdown to select tag to add + 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(main_frame, textvariable=tag_var, values=available_tags, width=30) - combo.pack(pady=(0, 10), fill=tk.X) + combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30) + combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E)) combo.focus_set() - def confirm(): + def add_selected_tag(): tag_name = tag_var.get().strip() if not tag_name: - popup.destroy() return # Get or create tag ID @@ -5156,18 +5189,152 @@ class PhotoTagger: 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) + refresh_tag_list() update_save_button_text() + tag_var.set("") # Clear the dropdown + + add_btn = ttk.Button(top_frame, text="Add", command=add_selected_tag) + add_btn.grid(row=0, column=2, padx=(0, 8)) + + # List frame - show all linked tags (existing + pending) with checkboxes + ttk.Label(list_frame, text="Linked tags (check to remove):", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=(0, 5)) + + # Create scrollable frame for tags + 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") + + # Variables to track selected tags for removal + selected_tag_vars = {} + + def refresh_tag_list(): + # Clear existing widgets + for widget in scrollable_frame.winfo_children(): + widget.destroy() + selected_tag_vars.clear() + + # Get existing tags for this photo + existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) + + # Get pending tags for this photo + pending_tag_ids = pending_tag_changes.get(photo_id, []) + + # Get pending removals for this photo + pending_removal_ids = pending_tag_removals.get(photo_id, []) + + # Combine and deduplicate tag IDs, but exclude tags marked for removal + all_tag_ids = existing_tag_ids + pending_tag_ids + unique_tag_ids = list(set(all_tag_ids)) # Remove duplicates + # Remove tags that are marked for removal + unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids] + + # Convert to names for display + 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 + + # Create checkboxes for each tag + 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 + + # Determine if this is a pending tag + is_pending = tag_id in pending_tag_ids + status_text = " (pending)" if is_pending else " (saved)" + status_color = "blue" if is_pending else "black" + + frame = ttk.Frame(scrollable_frame) + frame.pack(fill=tk.X, pady=1) + + checkbox = ttk.Checkbutton(frame, variable=var) + checkbox.pack(side=tk.LEFT, padx=(0, 5)) + + label = ttk.Label(frame, text=tag_name + status_text, foreground=status_color) + label.pack(side=tk.LEFT) + + def remove_selected_tags(): + # Get tag IDs to remove (convert names to IDs) + 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 + + # Remove from pending changes (using IDs) + 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] + + # Track removals for saved tags (using IDs) + existing_tag_ids = self._get_existing_tag_ids_for_photo(photo_id) + for tag_id in tag_ids_to_remove: + if tag_id in existing_tag_ids: + # This is a saved tag, add to pending removals + 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() + + # Bottom frame - buttons + remove_btn = ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags) + remove_btn.pack(side=tk.LEFT, padx=(0, 8)) + + close_btn = ttk.Button(bottom_frame, text="Close", command=popup.destroy) + close_btn.pack(side=tk.RIGHT) + + # Initial load + refresh_tag_list() + + # Update main display when dialog closes + def on_close(): + # Update the main display + existing_tags_list = self._parse_tags_string(photo_tags) + + # Get pending tag IDs and convert to names + pending_tag_names = [] + if photo_id in pending_tag_changes: + pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + + # Get pending removal IDs and convert to names + pending_removal_names = [] + if photo_id in pending_tag_removals: + pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] + + all_tags = existing_tags_list + pending_tag_names + unique_tags = self._deduplicate_tags(all_tags) + # Remove tags marked for removal + 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() - ttk.Button(main_frame, text="Add", command=confirm).pack(pady=(0, 5)) + popup.protocol("WM_DELETE_WINDOW", on_close) + return handler @@ -5177,19 +5344,28 @@ class PhotoTagger: # Display current tags existing_tags_list = self._parse_tags_string(photo_tags) + + # Get pending tag names pending_tag_names = [] if photo_id in pending_tag_changes: pending_tag_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_changes[photo_id]] + # Get pending removal names + pending_removal_names = [] + if photo_id in pending_tag_removals: + pending_removal_names = [tag_id_to_name.get(tid, f"Unknown {tid}") for tid in pending_tag_removals[photo_id]] + all_tags = existing_tags_list + pending_tag_names unique_tags = self._deduplicate_tags(all_tags) + # Remove tags marked for removal + 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 button - add_btn = tk.Button(tags_frame, text="+", width=2, + # Add button with linkage icon + 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))