diff --git a/photo_tagger.py b/photo_tagger.py index 09aec32..ff6c6e2 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -2592,12 +2592,40 @@ class PhotoTagger: with self.get_db_connection() as conn: cursor = conn.cursor() - # Pre-fetch all person names + # Pre-fetch all person names and details person_ids = list(matches_by_matched.keys()) if person_ids: placeholders = ','.join('?' * len(person_ids)) - 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()} + cursor.execute(f'SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people WHERE id IN ({placeholders})', person_ids) + data_cache['person_details'] = {} + for row in cursor.fetchall(): + person_id = row[0] + first_name = row[1] or '' + last_name = row[2] or '' + middle_name = row[3] or '' + maiden_name = row[4] or '' + date_of_birth = row[5] or '' + + # Create full name display + name_parts = [] + if first_name: + name_parts.append(first_name) + if middle_name: + name_parts.append(middle_name) + if last_name: + name_parts.append(last_name) + if maiden_name: + name_parts.append(f"({maiden_name})") + + full_name = ' '.join(name_parts) + data_cache['person_details'][person_id] = { + 'full_name': full_name, + 'first_name': first_name, + 'last_name': last_name, + 'middle_name': middle_name, + 'maiden_name': maiden_name, + 'date_of_birth': date_of_birth + } # Pre-fetch all photo paths (both matched and unidentified) all_photo_ids = set() @@ -2612,7 +2640,7 @@ class PhotoTagger: cursor.execute(f'SELECT id, path FROM photos WHERE id IN ({placeholders})', photo_ids_list) data_cache['photo_paths'] = {row[0]: row[1] for row in cursor.fetchall()} - print(f"āœ… Pre-fetched {len(data_cache.get('person_names', {}))} person names and {len(data_cache.get('photo_paths', {}))} photo paths") + print(f"āœ… Pre-fetched {len(data_cache.get('person_details', {}))} person details and {len(data_cache.get('photo_paths', {}))} photo paths") identified_count = 0 @@ -2772,7 +2800,8 @@ class PhotoTagger: ) # Use cached person name instead of database query - person_name = data_cache['person_names'].get(match['person_id'], "Unknown") + person_details = data_cache['person_details'].get(match['person_id'], {}) + person_name = person_details.get('full_name', "Unknown") # Track this face as identified for this person identified_faces_per_person[matched_id].add(match['unidentified_id']) @@ -2874,7 +2903,8 @@ class PhotoTagger: if matches_for_current_person: person_id = matches_for_current_person[0]['person_id'] # Use cached person name instead of database query - person_name = data_cache['person_names'].get(person_id, "Unknown") + person_details = data_cache['person_details'].get(person_id, {}) + person_name = person_details.get('full_name', "Unknown") save_btn.config(text=f"šŸ’¾ Save changes for {person_name}") else: save_btn.config(text="šŸ’¾ Save Changes") @@ -2934,11 +2964,22 @@ class PhotoTagger: first_match = matches_for_this_person[0] # Use cached data instead of database queries - person_name = data_cache['person_names'].get(first_match['person_id'], "Unknown") + person_details = data_cache['person_details'].get(first_match['person_id'], {}) + person_name = person_details.get('full_name', "Unknown") + date_of_birth = person_details.get('date_of_birth', '') matched_photo_path = data_cache['photo_paths'].get(first_match['matched_photo_id'], None) + # Create detailed person info display + person_info_lines = [f"šŸ‘¤ Person: {person_name}"] + if date_of_birth: + person_info_lines.append(f"šŸ“… Born: {date_of_birth}") + person_info_lines.extend([ + f"šŸ“ Photo: {first_match['matched_filename']}", + f"šŸ“ Face location: {first_match['matched_location']}" + ]) + # Update matched person info - matched_info_label.config(text=f"šŸ‘¤ Person: {person_name}\nšŸ“ Photo: {first_match['matched_filename']}\nšŸ“ Face location: {first_match['matched_location']}") + matched_info_label.config(text="\n".join(person_info_lines)) # Display matched person face matched_canvas.delete("all") @@ -3244,30 +3285,43 @@ class PhotoTagger: cursor = conn.cursor() cursor.execute( """ - SELECT p.id, p.first_name, p.last_name, COUNT(f.id) as face_count + SELECT p.id, p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth, COUNT(f.id) as face_count FROM people p JOIN faces f ON f.person_id = p.id - GROUP BY p.id, p.last_name, p.first_name + GROUP BY p.id, p.last_name, p.first_name, p.middle_name, p.maiden_name, p.date_of_birth HAVING face_count > 0 ORDER BY p.last_name, p.first_name COLLATE NOCASE """ ) 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" + for (pid, first_name, last_name, middle_name, maiden_name, date_of_birth, count) in cursor.fetchall(): + # Create full name display with all available information + name_parts = [] + if first_name: + name_parts.append(first_name) + if middle_name: + name_parts.append(middle_name) + if last_name: + name_parts.append(last_name) + if maiden_name: + name_parts.append(f"({maiden_name})") + + full_name = ' '.join(name_parts) if name_parts else "Unknown" + + # Create detailed display with date of birth if available + display_name = full_name + if date_of_birth: + display_name += f" - Born: {date_of_birth}" people_data.append({ 'id': pid, 'name': display_name, + 'full_name': full_name, 'first_name': first_name or "", 'last_name': last_name or "", + 'middle_name': middle_name or "", + 'maiden_name': maiden_name or "", + 'date_of_birth': date_of_birth or "", 'count': count }) @@ -3475,43 +3529,324 @@ class PhotoTagger: # Use pre-loaded data instead of database query cur_first = person_record.get('first_name', '') cur_last = person_record.get('last_name', '') + cur_middle = person_record.get('middle_name', '') + cur_maiden = person_record.get('maiden_name', '') + cur_dob = person_record.get('date_of_birth', '') - # Create a container frame for the text boxes and labels + # Create a larger container frame for the text boxes and labels edit_container = ttk.Frame(row_frame) - edit_container.pack(side=tk.LEFT) + edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True) - # 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) + # Create a grid layout for better organization + # First name field with label + first_frame = ttk.Frame(edit_container) + first_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) 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 = ttk.Entry(first_frame, textvariable=first_var, width=15) + first_entry.pack(side=tk.TOP) 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)) + first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray") + first_label.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) + # Last name field with label + last_frame = ttk.Frame(edit_container) + last_frame.grid(row=0, column=1, padx=(0, 10), pady=(0, 5), sticky=tk.W) - first_label = ttk.Label(help_frame, text="First", font=("Arial", 8), foreground="gray") - first_label.pack(side=tk.LEFT, padx=(5, 0)) + last_var = tk.StringVar(value=cur_last) + last_entry = ttk.Entry(last_frame, textvariable=last_var, width=15) + last_entry.pack(side=tk.TOP) + + last_label = ttk.Label(last_frame, text="Last Name", font=("Arial", 8), foreground="gray") + last_label.pack(side=tk.TOP, pady=(2, 0)) + + # Middle name field with label + middle_frame = ttk.Frame(edit_container) + middle_frame.grid(row=0, column=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + middle_var = tk.StringVar(value=cur_middle) + middle_entry = ttk.Entry(middle_frame, textvariable=middle_var, width=15) + middle_entry.pack(side=tk.TOP) + + middle_label = ttk.Label(middle_frame, text="Middle Name", font=("Arial", 8), foreground="gray") + middle_label.pack(side=tk.TOP, pady=(2, 0)) + + # Maiden name field with label + maiden_frame = ttk.Frame(edit_container) + maiden_frame.grid(row=1, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + maiden_var = tk.StringVar(value=cur_maiden) + maiden_entry = ttk.Entry(maiden_frame, textvariable=maiden_var, width=15) + maiden_entry.pack(side=tk.TOP) + + maiden_label = ttk.Label(maiden_frame, text="Maiden Name", font=("Arial", 8), foreground="gray") + maiden_label.pack(side=tk.TOP, pady=(2, 0)) + + # Date of birth field with label and calendar button + dob_frame = ttk.Frame(edit_container) + dob_frame.grid(row=1, column=1, columnspan=2, padx=(0, 10), pady=(0, 5), sticky=tk.W) + + # Create a frame for the date picker + date_picker_frame = ttk.Frame(dob_frame) + date_picker_frame.pack(side=tk.TOP) + + dob_var = tk.StringVar(value=cur_dob) + dob_entry = ttk.Entry(date_picker_frame, textvariable=dob_var, width=20, state='readonly') + dob_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Calendar button + calendar_btn = ttk.Button(date_picker_frame, text="šŸ“…", width=3, command=lambda: open_calendar()) + calendar_btn.pack(side=tk.RIGHT, padx=(5, 0)) + + dob_label = ttk.Label(dob_frame, text="Date of Birth", font=("Arial", 8), foreground="gray") + dob_label.pack(side=tk.TOP, pady=(2, 0)) + + def open_calendar(): + """Open a visual calendar dialog to select date of birth""" + from datetime import datetime, date, timedelta + import calendar + + # Create calendar window + calendar_window = tk.Toplevel(root) + calendar_window.title("Select Date of Birth") + calendar_window.resizable(False, False) + calendar_window.transient(root) + calendar_window.grab_set() + + # Calculate center position before showing the window + window_width = 350 + window_height = 400 + screen_width = calendar_window.winfo_screenwidth() + screen_height = calendar_window.winfo_screenheight() + x = (screen_width // 2) - (window_width // 2) + y = (screen_height // 2) - (window_height // 2) + + # Set geometry with center position before showing + calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}") + + # Calendar variables + current_date = datetime.now() + + # Check if there's already a date selected + existing_date_str = dob_var.get().strip() + if existing_date_str: + try: + existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date() + display_year = existing_date.year + display_month = existing_date.month + selected_date = existing_date + except ValueError: + # If existing date is invalid, use default + display_year = current_date.year - 25 + display_month = 1 + selected_date = None + else: + # Default to 25 years ago + display_year = current_date.year - 25 + display_month = 1 + selected_date = None + + # Month names + month_names = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + + # Configure custom styles for better visual highlighting + style = ttk.Style() + + # Selected date style - bright blue background with white text + style.configure("Selected.TButton", + background="#0078d4", + foreground="white", + font=("Arial", 9, "bold"), + relief="raised", + borderwidth=2) + style.map("Selected.TButton", + background=[("active", "#106ebe")], + relief=[("pressed", "sunken")]) + + # Today's date style - orange background + style.configure("Today.TButton", + background="#ff8c00", + foreground="white", + font=("Arial", 9, "bold"), + relief="raised", + borderwidth=1) + style.map("Today.TButton", + background=[("active", "#e67e00")], + relief=[("pressed", "sunken")]) + + # Calendar-specific normal button style (don't affect global TButton) + style.configure("Calendar.TButton", + font=("Arial", 9), + relief="flat") + style.map("Calendar.TButton", + background=[("active", "#e1e1e1")], + relief=[("pressed", "sunken")]) + + # Main frame + main_cal_frame = ttk.Frame(calendar_window, padding="10") + main_cal_frame.pack(fill=tk.BOTH, expand=True) + + # Header frame with navigation + header_frame = ttk.Frame(main_cal_frame) + header_frame.pack(fill=tk.X, pady=(0, 10)) + + # Month/Year display and navigation + nav_frame = ttk.Frame(header_frame) + nav_frame.pack() + + def update_calendar(): + """Update the calendar display""" + # Clear existing calendar + for widget in calendar_frame.winfo_children(): + widget.destroy() + + # Update header + month_year_label.config(text=f"{month_names[display_month-1]} {display_year}") + + # Get calendar data + cal = calendar.monthcalendar(display_year, display_month) + + # Day headers + day_headers = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + for i, day in enumerate(day_headers): + label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold")) + label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew") + + # Calendar days + for week_num, week in enumerate(cal): + for day_num, day in enumerate(week): + if day == 0: + # Empty cell + label = ttk.Label(calendar_frame, text="") + label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") + else: + # Day button + def make_day_handler(day_value): + def select_day(): + nonlocal selected_date + selected_date = date(display_year, display_month, day_value) + # Reset all buttons to normal calendar style + for widget in calendar_frame.winfo_children(): + if isinstance(widget, ttk.Button): + widget.config(style="Calendar.TButton") + # Highlight selected day with prominent style + for widget in calendar_frame.winfo_children(): + if isinstance(widget, ttk.Button) and widget.cget("text") == str(day_value): + widget.config(style="Selected.TButton") + return select_day + + day_btn = ttk.Button(calendar_frame, text=str(day), + command=make_day_handler(day), + width=3, style="Calendar.TButton") + day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew") + + # Check if this day should be highlighted + is_today = (display_year == current_date.year and + display_month == current_date.month and + day == current_date.day) + is_selected = (selected_date and + selected_date.year == display_year and + selected_date.month == display_month and + selected_date.day == day) + + if is_selected: + day_btn.config(style="Selected.TButton") + elif is_today: + day_btn.config(style="Today.TButton") + + # Navigation functions + def prev_year(): + nonlocal display_year + display_year = max(1900, display_year - 1) + update_calendar() + + def next_year(): + nonlocal display_year + display_year = min(current_date.year, display_year + 1) + update_calendar() + + def prev_month(): + nonlocal display_month, display_year + if display_month > 1: + display_month -= 1 + else: + display_month = 12 + display_year = max(1900, display_year - 1) + update_calendar() + + def next_month(): + nonlocal display_month, display_year + if display_month < 12: + display_month += 1 + else: + display_month = 1 + display_year = min(current_date.year, display_year + 1) + update_calendar() + + # Navigation buttons + prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year) + prev_year_btn.pack(side=tk.LEFT, padx=(0, 2)) + + prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month) + prev_month_btn.pack(side=tk.LEFT, padx=(0, 5)) + + month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold")) + month_year_label.pack(side=tk.LEFT, padx=5) + + next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month) + next_month_btn.pack(side=tk.LEFT, padx=(5, 2)) + + next_year_btn = ttk.Button(nav_frame, text=">>", width=3, command=next_year) + next_year_btn.pack(side=tk.LEFT) + + # Calendar grid frame + calendar_frame = ttk.Frame(main_cal_frame) + calendar_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # Configure grid weights + for i in range(7): + calendar_frame.columnconfigure(i, weight=1) + for i in range(7): + calendar_frame.rowconfigure(i, weight=1) + + # Buttons frame + buttons_frame = ttk.Frame(main_cal_frame) + buttons_frame.pack(fill=tk.X) + + def select_date(): + """Select the date and close calendar""" + if selected_date: + date_str = selected_date.strftime('%Y-%m-%d') + dob_var.set(date_str) + calendar_window.destroy() + else: + messagebox.showwarning("No Date Selected", "Please select a date from the calendar.") + + def cancel_selection(): + """Cancel date selection""" + calendar_window.destroy() + + # Buttons + ttk.Button(buttons_frame, text="Select", command=select_date).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(buttons_frame, text="Cancel", command=cancel_selection).pack(side=tk.LEFT) + + # Initialize calendar + update_calendar() def save_rename(): new_first = first_var.get().strip() new_last = last_var.get().strip() + new_middle = middle_var.get().strip() + new_maiden = maiden_var.get().strip() + new_dob = dob_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 - # Check for duplicates in local data first + # Check for duplicates in local data first (based on first and last name only) 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() @@ -3521,13 +3856,37 @@ class PhotoTagger: # Single database access - save to database with self.get_db_connection() as conn: cursor = conn.cursor() - cursor.execute('UPDATE people SET first_name = ?, last_name = ? WHERE id = ?', (new_first, new_last, person_record['id'])) + cursor.execute('UPDATE people SET first_name = ?, last_name = ?, middle_name = ?, maiden_name = ?, date_of_birth = ? WHERE id = ?', + (new_first, new_last, new_middle, new_maiden, new_dob, 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" + person_record['middle_name'] = new_middle + person_record['maiden_name'] = new_maiden + person_record['date_of_birth'] = new_dob + + # Recreate the full display name with all available information + name_parts = [] + if new_first: + name_parts.append(new_first) + if new_middle: + name_parts.append(new_middle) + if new_last: + name_parts.append(new_last) + if new_maiden: + name_parts.append(f"({new_maiden})") + + full_name = ' '.join(name_parts) if name_parts else "Unknown" + + # Create detailed display with date of birth if available + display_name = full_name + if new_dob: + display_name += f" - Born: {new_dob}" + + person_record['name'] = display_name + person_record['full_name'] = full_name # Refresh list current_selected_id = person_record['id'] @@ -3556,16 +3915,64 @@ class PhotoTagger: w.destroy() rebuild_row(row_frame, person_record, row_index) - save_btn = ttk.Button(row_frame, text="šŸ’¾ Save", command=save_rename) + save_btn = ttk.Button(row_frame, text="šŸ’¾", width=3, command=save_rename) save_btn.pack(side=tk.LEFT, padx=(5, 0)) - cancel_btn = ttk.Button(row_frame, text="āœ– Cancel", command=cancel_edit) + cancel_btn = ttk.Button(row_frame, text="āœ–", width=3, command=cancel_edit) cancel_btn.pack(side=tk.LEFT, padx=(5, 0)) - # Keyboard shortcuts - first_entry.bind('', lambda e: save_rename()) - last_entry.bind('', lambda e: save_rename()) + # Configure custom disabled button style for better visibility + style = ttk.Style() + style.configure("Disabled.TButton", + background="#d3d3d3", # Light gray background + foreground="#808080", # Dark gray text + relief="flat", + borderwidth=1) + + def validate_save_button(): + """Enable/disable save button based on required fields""" + first_val = first_var.get().strip() + last_val = last_var.get().strip() + dob_val = dob_var.get().strip() + + # Enable save button only if both name fields and date of birth are provided + has_first = bool(first_val) + has_last = bool(last_val) + has_dob = bool(dob_val) + + if has_first and has_last and has_dob: + save_btn.config(state="normal") + # Reset to normal styling when enabled + save_btn.config(style="TButton") + else: + save_btn.config(state="disabled") + # Apply custom disabled styling for better visibility + save_btn.config(style="Disabled.TButton") + + # Set up validation callbacks for all input fields + first_var.trace('w', lambda *args: validate_save_button()) + last_var.trace('w', lambda *args: validate_save_button()) + middle_var.trace('w', lambda *args: validate_save_button()) + maiden_var.trace('w', lambda *args: validate_save_button()) + dob_var.trace('w', lambda *args: validate_save_button()) + + # Initial validation + validate_save_button() + + # Keyboard shortcuts (only work when save button is enabled) + def try_save(): + if save_btn.cget('state') == 'normal': + save_rename() + + first_entry.bind('', lambda e: try_save()) + last_entry.bind('', lambda e: try_save()) + middle_entry.bind('', lambda e: try_save()) + maiden_entry.bind('', lambda e: try_save()) + dob_entry.bind('', lambda e: try_save()) first_entry.bind('', lambda e: cancel_edit()) last_entry.bind('', lambda e: cancel_edit()) + middle_entry.bind('', lambda e: cancel_edit()) + maiden_entry.bind('', lambda e: cancel_edit()) + dob_entry.bind('', lambda e: cancel_edit()) def rebuild_row(row_frame, p, i): # Edit button (on the left)