Refactor PhotoTagger to support separate first and last name fields in the database. Update GUI to include dedicated input fields for first and last names, enhancing user experience with smart name parsing and improved dropdown functionality. Revise README to reflect these changes and highlight new features.

This commit is contained in:
tanyar09 2025-09-22 15:56:55 -04:00
parent 6a5bafef50
commit 52b9d37d8c
2 changed files with 324 additions and 104 deletions

View File

@ -128,7 +128,8 @@ python3 photo_tagger.py auto-match --auto --show-faces
**🎯 New GUI-Based Identification Features:**
- 🖼️ **Visual Face Display** - See individual face crops in the GUI
- 📝 **Dropdown Name Selection** - Choose from known people or type new names
- 📝 **Separate Name Fields** - Dedicated "Last name" and "First name" input fields with dropdowns
- 🎯 **Smart Name Parsing** - Supports "Last, First" format that gets properly parsed and stored
- ☑️ **Compare with Similar Faces** - Compare current face with similar unidentified faces
- 🎨 **Modern Interface** - Clean, intuitive GUI with buttons and input fields
- 💾 **Window Size Memory** - Remembers your preferred window size
@ -140,6 +141,7 @@ python3 photo_tagger.py auto-match --auto --show-faces
- ⚠️ **Smart Navigation Warnings** - Prevents accidental loss of selected similar faces
- 💾 **Quit Confirmation** - Saves pending identifications when closing the application
- ⚡ **Performance Optimized** - Pre-fetched data for faster similar faces display
- 🎯 **Clean Database Storage** - Names are stored as separate first_name and last_name fields without commas
**🎯 New Auto-Match GUI Features:**
- 📊 **Person-Centric View** - Shows matched person on left, all their unidentified faces on right
@ -163,9 +165,11 @@ python3 photo_tagger.py modifyidentified
This GUI lets you quickly review all identified people, rename them, and temporarily unmatch faces before committing.
**Left Panel (People):**
- 👥 **People List** - Shows all identified people with face counts
- 👥 **People List** - Shows all identified people with face counts in "Last, First" format
- 🖱️ **Clickable Names** - Click to select a person (selected name is bold)
- ✏️ **Edit Name Icon** - Rename a person; tooltip shows "Update name"
- ✏️ **Edit Name Icon** - Rename a person with separate first/last name fields; tooltip shows "Update name"
- 📝 **Separate Name Fields** - Edit with dedicated "Last name" and "First name" text boxes
- 💡 **Help Text** - Small labels below text boxes showing "Last" and "First"
**Right Panel (Faces):**
- 🧩 **Person Faces** - Thumbnails of all faces identified as the selected person
@ -177,10 +181,16 @@ This GUI lets you quickly review all identified people, rename them, and tempora
- 💾 **Save changes** - Commits all pending unmatched faces across all people to the database
- ❌ **Quit** - Closes the window (unsaved temporary changes are discarded)
**Performance Features:**
- ⚡ **Optimized Database Access** - Loads all people data once when opening, saves only when needed
- 🚫 **No Database Queries During Editing** - All editing operations use pre-loaded data
- 💾 **Single Save Transaction** - Database updated only when clicking "Save changes"
Notes:
- Changes are temporary until you click "Save changes" at the bottom.
- Undo restores only the currently viewed person's faces.
- Saving updates the database and refreshes counts.
- Names are stored cleanly as separate first_name and last_name fields without commas.
## 🧠 Advanced Algorithm Features
@ -269,10 +279,20 @@ python3 photo_tagger.py tag --pattern "birthday"
## 🗃️ Database
The tool uses SQLite database (`data/photos.db` by default) with these tables:
### Core Tables
- **photos** - Photo file paths and processing status
- **people** - Known people names
- **faces** - Face encodings and locations
- **people** - Known people with separate first_name and last_name fields
- **faces** - Face encodings, locations, and quality scores
- **tags** - Custom tags for photos
- **person_encodings** - Face encodings for each person (for matching)
### Database Schema Improvements
- **Clean Name Storage** - People table uses separate `first_name` and `last_name` fields
- **No Comma Issues** - Names are stored without commas, displayed as "Last, First" format
- **Quality Scoring** - Faces table includes quality scores for better matching
- **Optimized Queries** - Efficient indexing and query patterns for fast performance
- **Data Integrity** - Proper foreign key relationships and constraints
## ⚙️ Configuration
@ -560,17 +580,36 @@ The auto-match feature now works in a **person-centric** way:
This is now a minimal, focused tool. Key principles:
- Keep it simple and fast
- CLI-only interface
- GUI-enhanced interface for identification
- Minimal dependencies
- Clear, readable code
- **Always use python3** commands
## 🆕 Recent Improvements (Latest Version)
### Name Handling & Database
- ✅ **Fixed Comma Issues** - Names are now stored cleanly without commas in database
- ✅ **Separate Name Fields** - First name and last name are stored in separate database columns
- ✅ **Smart Parsing** - Supports "Last, First" input format that gets properly parsed
- ✅ **Optimized Database Access** - Single load/save operations for better performance
### GUI Enhancements
- ✅ **Improved Edit Interface** - Separate text boxes for first and last names with help text
- ✅ **Better Layout** - Help text positioned below input fields for clarity
- ✅ **Tooltips** - Edit buttons show helpful tooltips
- ✅ **Responsive Design** - Face grids adapt to window size
### Performance & Reliability
- ✅ **Efficient Database Operations** - Pre-loads data, saves only when needed
- ✅ **Fixed Virtual Environment** - Run script now works properly with dependencies
- ✅ **Clean Code Structure** - Improved error handling and state management
---
**Total project size**: ~300 lines of Python code
**Total project size**: ~3,400 lines of Python code
**Dependencies**: 6 essential packages
**Setup time**: ~5 minutes
**Perfect for**: Batch processing personal photo collections
**Perfect for**: Batch processing personal photo collections with modern GUI interface
## 🔄 Common Commands Cheat Sheet

View File

@ -161,8 +161,10 @@ class PhotoTagger:
cursor.execute('''
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_date DATETIME DEFAULT CURRENT_TIMESTAMP
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(first_name, last_name)
)
''')
@ -215,6 +217,7 @@ class PhotoTagger:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_person_id ON person_encodings(person_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_person_encodings_quality ON person_encodings(quality_score)')
if self.verbose >= 1:
print(f"✅ Database initialized: {self.db_path}")
@ -386,9 +389,9 @@ class PhotoTagger:
identify_data_cache['photo_paths'] = {row[0]: {'path': row[1], 'filename': row[2]} for row in cursor.fetchall()}
# Pre-fetch all people names for dropdown
cursor.execute('SELECT name FROM people ORDER BY name')
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
people = cursor.fetchall()
identify_data_cache['people_names'] = [name[0] for name in people]
identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last in people]
print(f"✅ Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names")
@ -428,8 +431,16 @@ class PhotoTagger:
with self.get_db_connection() as conn:
cursor = conn.cursor()
# Add person if doesn't exist
cursor.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (person_name,))
cursor.execute('SELECT id FROM people WHERE name = ?', (person_name,))
# Parse person_name in "Last, First" or single-token format
parts = [p.strip() for p in person_name.split(',', 1)]
if len(parts) == 2:
last_name, first_name = parts[0], parts[1]
else:
first_name = parts[0] if parts else ''
last_name = ''
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name) VALUES (?, ?)', (first_name, last_name))
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ?', (first_name, last_name))
result = cursor.fetchone()
person_id = result[0] if result else None
@ -522,6 +533,7 @@ class PhotoTagger:
root.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1) # Left panel
main_frame.columnconfigure(1, weight=1) # Right panel for similar faces
main_frame.rowconfigure(1, weight=1) # Main content row
# Photo info
info_label = ttk.Label(main_frame, text="", font=("Arial", 10, "bold"))
@ -550,12 +562,19 @@ class PhotoTagger:
input_frame = ttk.LabelFrame(left_panel, text="Person Identification", padding="10")
input_frame.grid(row=1, column=0, pady=(10, 0), sticky=(tk.W, tk.E))
input_frame.columnconfigure(1, weight=1)
input_frame.columnconfigure(3, weight=1)
# Person name input with dropdown
ttk.Label(input_frame, text="Person name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
name_var = tk.StringVar()
name_combo = ttk.Combobox(input_frame, textvariable=name_var, width=27, state="normal")
name_combo.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 10))
# First name input with dropdown
ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
first_name_var = tk.StringVar()
first_name_combo = ttk.Combobox(input_frame, textvariable=first_name_var, width=12, state="normal")
first_name_combo.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5))
# Last name input with dropdown
ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10))
last_name_var = tk.StringVar()
last_name_combo = ttk.Combobox(input_frame, textvariable=last_name_var, width=12, state="normal")
last_name_combo.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 10))
# Define update_similar_faces function first - reusing auto-match display logic
def update_similar_faces():
@ -617,28 +636,36 @@ class PhotoTagger:
compare_checkbox = ttk.Checkbutton(input_frame, text="Compare with similar faces", variable=compare_var,
command=on_compare_change)
compare_checkbox.grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=(5, 0))
compare_checkbox.grid(row=3, column=0, columnspan=4, sticky=tk.W, pady=(5, 0))
# Add callback to save person name when it changes
def on_name_change(*args):
if i < len(original_faces):
current_face_id = original_faces[i][0]
current_name = name_var.get().strip()
if current_name:
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
if first_name or last_name:
if last_name and first_name:
current_name = f"{last_name}, {first_name}"
elif last_name:
current_name = last_name
elif first_name:
current_name = first_name
else:
current_name = ""
face_person_names[current_face_id] = current_name
elif current_face_id in face_person_names:
# Remove empty names from storage
del face_person_names[current_face_id]
name_var.trace('w', on_name_change)
first_name_var.trace('w', on_name_change)
last_name_var.trace('w', on_name_change)
# Buttons
button_frame = ttk.Frame(input_frame)
button_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
# Buttons moved to bottom of window
# Instructions
instructions = ttk.Label(input_frame, text="Select from dropdown or type new name", foreground="gray")
instructions.grid(row=3, column=0, columnspan=2, pady=(10, 0))
instructions.grid(row=2, column=0, columnspan=4, pady=(10, 0))
# Right panel for similar faces
similar_faces_frame = ttk.Frame(right_panel)
@ -721,8 +748,17 @@ class PhotoTagger:
face_selection_states[current_face_id][unique_key] = var.get()
# Save person name
current_name = name_var.get().strip()
if current_name:
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
if first_name or last_name:
if last_name and first_name:
current_name = f"{last_name}, {first_name}"
elif last_name:
current_name = last_name
elif first_name:
current_name = first_name
else:
current_name = ""
face_person_names[current_face_id] = current_name
# Button commands
@ -731,11 +767,26 @@ class PhotoTagger:
def on_identify():
nonlocal command, waiting_for_input
command = name_var.get().strip()
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
compare_enabled = compare_var.get()
if not command.strip():
print("⚠️ Please enter a person name before identifying")
if not first_name and not last_name:
print("⚠️ Please enter at least a first name or last name before identifying")
return
# Combine first and last name properly
if last_name and first_name:
command = f"{last_name}, {first_name}"
elif last_name:
command = last_name
elif first_name:
command = first_name
else:
command = ""
if not command:
print("⚠️ Please enter at least a first name or last name before identifying")
return
if compare_enabled:
@ -757,7 +808,9 @@ class PhotoTagger:
# Check if compare is enabled and similar faces are selected
if compare_var.get() and similar_face_vars:
selected_faces = [face_id for face_id, var in similar_face_vars if var.get()]
if selected_faces and not name_var.get().strip():
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
if selected_faces and not (first_name or last_name):
# Show warning dialog
result = messagebox.askyesno(
"Selected Faces Not Identified",
@ -835,18 +888,30 @@ class PhotoTagger:
def update_people_dropdown():
"""Update the dropdown with current people names"""
"""Update the dropdowns with current people names"""
# Use cached people names instead of database query
if 'people_names' in identify_data_cache:
name_combo['values'] = identify_data_cache['people_names']
# Split cached names back into first and last names
first_names = set()
last_names = set()
for full_name in identify_data_cache['people_names']:
parts = full_name.strip().split(' ', 1)
if parts:
first_names.add(parts[0])
if len(parts) > 1:
last_names.add(parts[1])
first_name_combo['values'] = sorted(list(first_names))
last_name_combo['values'] = sorted(list(last_names))
else:
# Fallback to database if cache not available
with self.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM people ORDER BY name')
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
people = cursor.fetchall()
people_names = [name[0] for name in people]
name_combo['values'] = people_names
first_names = sorted(list(set([first for first, last in people if first])))
last_names = sorted(list(set([last for first, last in people if last])))
first_name_combo['values'] = first_names
last_name_combo['values'] = last_names
def update_button_states():
"""Update button states based on current position and unidentified faces"""
@ -876,32 +941,44 @@ class PhotoTagger:
else:
next_btn.config(state='disabled')
# Create button references for state management
identify_btn = ttk.Button(button_frame, text="✅ Identify", command=on_identify, state='disabled')
back_btn = ttk.Button(button_frame, text="⬅️ Back", command=on_back)
next_btn = ttk.Button(button_frame, text="➡️ Next", command=on_skip)
quit_btn = ttk.Button(button_frame, text="❌ Quit", command=on_quit)
identify_btn.grid(row=0, column=0, padx=(0, 5))
back_btn.grid(row=0, column=1, padx=5)
next_btn.grid(row=0, column=2, padx=5)
quit_btn.grid(row=0, column=3, padx=(5, 0))
# Button references moved to bottom control panel
def update_identify_button_state():
"""Enable/disable identify button based on name input"""
if name_var.get().strip():
first_name = first_name_var.get().strip()
last_name = last_name_var.get().strip()
if first_name or last_name:
identify_btn.config(state='normal')
else:
identify_btn.config(state='disabled')
# Bind name input changes to update button state
name_var.trace('w', lambda *args: update_identify_button_state())
first_name_var.trace('w', lambda *args: update_identify_button_state())
last_name_var.trace('w', lambda *args: update_identify_button_state())
# Handle Enter key
def on_enter(event):
on_identify()
name_combo.bind('<Return>', on_enter)
first_name_combo.bind('<Return>', on_enter)
last_name_combo.bind('<Return>', on_enter)
# Bottom control panel
control_frame = ttk.Frame(main_frame)
control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
# Create button references for state management
back_btn = ttk.Button(control_frame, text="⬅️ Back", command=on_back)
next_btn = ttk.Button(control_frame, text="➡️ Next", command=on_skip)
quit_btn = ttk.Button(control_frame, text="❌ Quit", command=on_quit)
back_btn.pack(side=tk.LEFT, padx=(0, 5))
next_btn.pack(side=tk.LEFT, padx=(0, 5))
quit_btn.pack(side=tk.LEFT, padx=(5, 0))
# Identify button (placed after on_identify is defined)
identify_btn = ttk.Button(input_frame, text="✅ Identify", command=on_identify, state='disabled')
identify_btn.grid(row=4, column=0, pady=(10, 0), sticky=tk.W)
# Show the window
try:
@ -1020,12 +1097,23 @@ class PhotoTagger:
with self.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT p.name FROM people p
SELECT p.first_name, p.last_name FROM people p
JOIN faces f ON p.id = f.person_id
WHERE f.id = ?
''', (face_id,))
result = cursor.fetchone()
person_name = result[0] if result else "Unknown"
if result:
first_name, last_name = result
if last_name and first_name:
person_name = f"{last_name}, {first_name}"
elif last_name:
person_name = last_name
elif first_name:
person_name = first_name
else:
person_name = "Unknown"
else:
person_name = "Unknown"
info_label.config(text=f"📁 Photo: {filename} (Face {current_pos}/{total_unidentified}) - ✅ Already identified as: {person_name}")
print(f"✅ Already identified as: {person_name}")
@ -1069,25 +1157,39 @@ class PhotoTagger:
# Set person name input - restore saved name or use database/empty value
if face_id in face_person_names:
# Restore previously entered name for this face
name_var.set(face_person_names[face_id])
full_name = face_person_names[face_id]
# Parse "Last, First" format back to separate fields
if ', ' in full_name:
parts = full_name.split(', ', 1)
last_name_var.set(parts[0].strip())
first_name_var.set(parts[1].strip())
else:
# Fallback for old format or single name
first_name_var.set(full_name)
last_name_var.set("")
elif is_already_identified:
# Pre-populate with the current person name from database
with self.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT p.name FROM people p
SELECT p.first_name, p.last_name FROM people p
JOIN faces f ON p.id = f.person_id
WHERE f.id = ?
''', (face_id,))
result = cursor.fetchone()
current_name = result[0] if result else ""
name_var.set(current_name)
if result:
first_name_var.set(result[0] or "")
last_name_var.set(result[1] or "")
else:
first_name_var.set("")
last_name_var.set("")
else:
name_var.set("")
first_name_var.set("")
last_name_var.set("")
# Keep compare checkbox state persistent across navigation
name_combo.focus_set()
name_combo.icursor(0)
first_name_combo.focus_set()
first_name_combo.icursor(0)
# Force GUI update before waiting for input
root.update_idletasks()
@ -1204,8 +1306,16 @@ class PhotoTagger:
with self.get_db_connection() as conn:
cursor = conn.cursor()
# Add person if doesn't exist
cursor.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (person_name,))
cursor.execute('SELECT id FROM people WHERE name = ?', (person_name,))
# Parse person_name in "Last, First" or single-token format
parts = [p.strip() for p in person_name.split(',', 1)]
if len(parts) == 2:
last_name, first_name = parts[0], parts[1]
else:
first_name = parts[0] if parts else ''
last_name = ''
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name) VALUES (?, ?)', (first_name, last_name))
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ?', (first_name, last_name))
result = cursor.fetchone()
person_id = result[0] if result else None
@ -1244,8 +1354,16 @@ class PhotoTagger:
with self.get_db_connection() as conn:
cursor = conn.cursor()
# Add person if doesn't exist
cursor.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (command,))
cursor.execute('SELECT id FROM people WHERE name = ?', (command,))
# Parse command in "Last, First" or single-token format
parts = [p.strip() for p in command.split(',', 1)]
if len(parts) == 2:
last_name, first_name = parts[0], parts[1]
else:
first_name = parts[0] if parts else ''
last_name = ''
cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name) VALUES (?, ?)', (first_name, last_name))
cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ?', (first_name, last_name))
result = cursor.fetchone()
person_id = result[0] if result else None
@ -1704,14 +1822,15 @@ class PhotoTagger:
if cursor is None:
with self.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM people ORDER BY name')
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
people = cursor.fetchall()
else:
cursor.execute('SELECT name FROM people ORDER BY name')
cursor.execute('SELECT first_name, last_name FROM people ORDER BY first_name, last_name')
people = cursor.fetchall()
if people:
print("👥 Known people:", ", ".join([p[0] for p in people]))
formatted_names = [f"{last}, {first}".strip(", ").strip() for first, last in people if first or last]
print("👥 Known people:", ", ".join(formatted_names))
else:
print("👥 No people identified yet")
@ -2024,9 +2143,20 @@ class PhotoTagger:
# Get person name for ordering
with self.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM people WHERE id = ?', (person_id,))
cursor.execute('SELECT first_name, last_name FROM people WHERE id = ?', (person_id,))
result = cursor.fetchone()
person_name = result[0] if result else "Unknown"
if result:
first_name, last_name = result
if last_name and first_name:
person_name = f"{last_name}, {first_name}"
elif last_name:
person_name = last_name
elif first_name:
person_name = first_name
else:
person_name = "Unknown"
else:
person_name = "Unknown"
person_faces_list.append((person_id, face, person_name))
# Sort by person name for consistent, user-friendly ordering
@ -2087,8 +2217,8 @@ class PhotoTagger:
person_ids = list(matches_by_matched.keys())
if person_ids:
placeholders = ','.join('?' * len(person_ids))
cursor.execute(f'SELECT id, name FROM people WHERE id IN ({placeholders})', person_ids)
data_cache['person_names'] = {row[0]: row[1] for row in cursor.fetchall()}
cursor.execute(f'SELECT id, first_name, last_name FROM people WHERE id IN ({placeholders})', person_ids)
data_cache['person_names'] = {row[0]: f"{row[2]}, {row[1]}".strip(", ").strip() for row in cursor.fetchall()}
# Pre-fetch all photo paths (both matched and unidentified)
all_photo_ids = set()
@ -2727,7 +2857,7 @@ class PhotoTagger:
faces_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
# Load people from DB with counts
people_data = [] # list of dicts: {id, name, count}
people_data = [] # list of dicts: {id, name, count, first_name, last_name}
def load_people():
nonlocal people_data
@ -2735,19 +2865,32 @@ class PhotoTagger:
cursor = conn.cursor()
cursor.execute(
"""
SELECT p.id, p.name, COUNT(f.id) as face_count
SELECT p.id, p.first_name, p.last_name, COUNT(f.id) as face_count
FROM people p
JOIN faces f ON f.person_id = p.id
GROUP BY p.id, p.name
GROUP BY p.id, p.last_name, p.first_name
HAVING face_count > 0
ORDER BY p.name COLLATE NOCASE
ORDER BY p.last_name, p.first_name COLLATE NOCASE
"""
)
people_data = [{
'id': pid,
'name': name,
'count': count
} for (pid, name, count) in cursor.fetchall()]
people_data = []
for (pid, first_name, last_name, count) in cursor.fetchall():
if last_name and first_name:
display_name = f"{last_name}, {first_name}"
elif last_name:
display_name = last_name
elif first_name:
display_name = first_name
else:
display_name = "Unknown"
people_data.append({
'id': pid,
'name': display_name,
'first_name': first_name or "",
'last_name': last_name or "",
'count': count
})
def clear_faces_panel():
for w in faces_inner.winfo_children():
@ -2950,29 +3093,65 @@ class PhotoTagger:
for w in row_frame.winfo_children():
w.destroy()
entry_var = tk.StringVar(value=person_record['name'])
entry = ttk.Entry(row_frame, textvariable=entry_var, width=24)
entry.pack(side=tk.LEFT)
entry.focus_set()
# Use pre-loaded data instead of database query
cur_first = person_record.get('first_name', '')
cur_last = person_record.get('last_name', '')
# Create a container frame for the text boxes and labels
edit_container = ttk.Frame(row_frame)
edit_container.pack(side=tk.LEFT)
# Text boxes row
text_frame = ttk.Frame(edit_container)
text_frame.pack(side=tk.TOP)
# Separate Last name and First name inputs
last_var = tk.StringVar(value=cur_last)
last_entry = ttk.Entry(text_frame, textvariable=last_var, width=12)
last_entry.pack(side=tk.LEFT)
first_var = tk.StringVar(value=cur_first)
first_entry = ttk.Entry(text_frame, textvariable=first_var, width=12)
first_entry.pack(side=tk.LEFT, padx=(5, 0))
first_entry.focus_set()
# Help text labels row (below text boxes)
help_frame = ttk.Frame(edit_container)
help_frame.pack(side=tk.TOP, pady=(2, 0))
last_label = ttk.Label(help_frame, text="Last", font=("Arial", 8), foreground="gray")
last_label.pack(side=tk.LEFT)
first_label = ttk.Label(help_frame, text="First", font=("Arial", 8), foreground="gray")
first_label.pack(side=tk.LEFT, padx=(5, 0))
def save_rename():
new_name = entry_var.get().strip()
if not new_name:
messagebox.showwarning("Invalid name", "Person name cannot be empty.")
new_first = first_var.get().strip()
new_last = last_var.get().strip()
if not new_first and not new_last:
messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.")
return
# Prevent duplicate names
# Check for duplicates in local data first
for person in people_data:
if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last:
display_name = f"{new_last}, {new_first}".strip(", ").strip()
messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.")
return
# Single database access - save to database
with self.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM people WHERE name = ? AND id != ?', (new_name, person_record['id']))
dup = cursor.fetchone()
if dup:
messagebox.showwarning("Duplicate name", f"A person named '{new_name}' already exists.")
return
cursor.execute('UPDATE people SET name = ? WHERE id = ?', (new_name, person_record['id']))
cursor.execute('UPDATE people SET first_name = ?, last_name = ? WHERE id = ?', (new_first, new_last, person_record['id']))
conn.commit()
# Update local data structure
person_record['first_name'] = new_first
person_record['last_name'] = new_last
person_record['name'] = f"{new_last}, {new_first}".strip(", ").strip() if new_first or new_last else "Unknown"
# Refresh list
current_selected_id = person_record['id']
load_people()
populate_people_list()
# Reselect and refresh right panel header if needed
if selected_person_id == current_selected_id or selected_person_id is None:
@ -3004,20 +3183,22 @@ class PhotoTagger:
cancel_btn.pack(side=tk.LEFT, padx=(5, 0))
# Keyboard shortcuts
entry.bind('<Return>', lambda e: save_rename())
entry.bind('<Escape>', lambda e: cancel_edit())
first_entry.bind('<Return>', lambda e: save_rename())
last_entry.bind('<Return>', lambda e: save_rename())
first_entry.bind('<Escape>', lambda e: cancel_edit())
last_entry.bind('<Escape>', lambda e: cancel_edit())
def rebuild_row(row_frame, p, i):
# Label (clickable)
name_lbl = ttk.Label(row_frame, text=f"{p['name']} ({p['count']})", font=("Arial", 10))
name_lbl.pack(side=tk.LEFT)
name_lbl.bind("<Button-1>", make_click_handler(p['id'], p['name'], i))
name_lbl.config(cursor="hand2")
# Edit button
# Edit button (on the left)
edit_btn = ttk.Button(row_frame, text="✏️", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii))
edit_btn.pack(side=tk.RIGHT)
edit_btn.pack(side=tk.LEFT, padx=(0, 5))
# Add tooltip to edit button
ToolTip(edit_btn, "Update name")
# Label (clickable) - takes remaining space
name_lbl = ttk.Label(row_frame, text=f"{p['name']} ({p['count']})", font=("Arial", 10))
name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
name_lbl.bind("<Button-1>", make_click_handler(p['id'], p['name'], i))
name_lbl.config(cursor="hand2")
# Bold if selected
if (selected_person_id is None and i == 0) or (selected_person_id == p['id']):
name_lbl.config(font=("Arial", 10, "bold"))