Implement comprehensive tag management features in PhotoTagger

Add functionality for deduplicating tags, parsing tag strings, and managing tag IDs within the PhotoTagger GUI. Introduce a tag management dialog for adding, editing, and deleting tags, ensuring a user-friendly interface for tag operations. Update the internal logic to utilize tag IDs for improved performance and reliability, while enhancing the README to reflect these significant changes in tag handling and management.
This commit is contained in:
tanyar09 2025-10-02 12:24:15 -04:00
parent 7f89c2a825
commit 4602c252e8
2 changed files with 541 additions and 55 deletions

View File

@ -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

View File

@ -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("<Configure>", 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