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:
parent
7f89c2a825
commit
4602c252e8
17
README.md
17
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
|
||||
|
||||
579
photo_tagger.py
579
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("<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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user