diff --git a/README.md b/README.md index 0d9f376..dc6c5d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/photo_tagger.py b/photo_tagger.py index 61a1f9e..cfb707c 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -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('', on_enter) + first_name_combo.bind('', on_enter) + last_name_combo.bind('', 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('', lambda e: save_rename()) - entry.bind('', lambda e: cancel_edit()) + first_entry.bind('', lambda e: save_rename()) + last_entry.bind('', lambda e: save_rename()) + first_entry.bind('', lambda e: cancel_edit()) + last_entry.bind('', 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("", 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("", 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"))