diff --git a/photo_tagger.py b/photo_tagger.py index cfb707c..84a19e6 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -163,8 +163,9 @@ class PhotoTagger: id INTEGER PRIMARY KEY AUTOINCREMENT, first_name TEXT NOT NULL, last_name TEXT NOT NULL, + date_of_birth DATE, created_date DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(first_name, last_name) + UNIQUE(first_name, last_name, date_of_birth) ) ''') @@ -389,9 +390,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 first_name, last_name FROM people ORDER BY first_name, last_name') + cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') people = cursor.fetchall() - identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last in people] + identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob 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") @@ -425,8 +426,16 @@ class PhotoTagger: nonlocal identified_count saved_count = 0 - for face_id, person_name in face_person_names.items(): - if person_name.strip(): + for face_id, person_data in face_person_names.items(): + # Handle both old format (string) and new format (dict) + if isinstance(person_data, dict): + person_name = person_data.get('name', '').strip() + date_of_birth = person_data.get('date_of_birth', '').strip() + else: + person_name = person_data.strip() if person_data else '' + date_of_birth = '' + + if person_name: try: with self.get_db_connection() as conn: cursor = conn.cursor() @@ -439,8 +448,8 @@ class PhotoTagger: 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)) + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, date_of_birth) VALUES (?, ?, ?)', (first_name, last_name, date_of_birth)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND date_of_birth = ?', (first_name, last_name, date_of_birth)) result = cursor.fetchone() person_id = result[0] if result else None @@ -477,11 +486,27 @@ class PhotoTagger: if not validate_navigation(): return # Cancel close - # Check if there are pending identifications (faces with names but not yet saved) - pending_identifications = { - k: v for k, v in face_person_names.items() - if v.strip() and (k not in face_status or face_status[k] != 'identified') - } + # Check if there are pending identifications (faces with complete data but not yet saved) + pending_identifications = {} + for k, v in face_person_names.items(): + if k not in face_status or face_status[k] != 'identified': + # Handle both old format (string) and new format (dict) + if isinstance(v, dict): + person_name = v.get('name', '').strip() + date_of_birth = v.get('date_of_birth', '').strip() + # Check if name has both first and last name + if person_name and date_of_birth: + # Parse name to check for both first and last name + if ',' in person_name: + last_name, first_name = person_name.split(',', 1) + if last_name.strip() and first_name.strip(): + pending_identifications[k] = v + else: + # Single name format - not complete + pass + else: + # Old string format - not complete (missing date of birth) + pass if pending_identifications: # Ask user if they want to save pending identifications @@ -563,6 +588,7 @@ class PhotoTagger: 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) + input_frame.columnconfigure(5, weight=1) # First name input with dropdown ttk.Label(input_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, padx=(0, 10)) @@ -574,7 +600,256 @@ class PhotoTagger: 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)) + last_name_combo.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) + + # Date of birth input with calendar chooser + ttk.Label(input_frame, text="Date of birth:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) + date_of_birth_var = tk.StringVar() + + # Create a frame for the date picker + date_frame = ttk.Frame(input_frame) + date_frame.grid(row=0, column=5, sticky=(tk.W, tk.E), padx=(0, 10)) + + # Date display entry (read-only) + date_of_birth_entry = ttk.Entry(date_frame, textvariable=date_of_birth_var, width=12, state='readonly') + date_of_birth_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Calendar button + calendar_btn = ttk.Button(date_frame, text="📅", width=3, command=lambda: open_calendar()) + calendar_btn.pack(side=tk.RIGHT, padx=(5, 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 = date_of_birth_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') + date_of_birth_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() # Define update_similar_faces function first - reusing auto-match display logic def update_similar_faces(): @@ -747,9 +1022,11 @@ class PhotoTagger: unique_key = f"{current_face_id}_{similar_face_id}" face_selection_states[current_face_id][unique_key] = var.get() - # Save person name + # Save person name and date of birth first_name = first_name_var.get().strip() last_name = last_name_var.get().strip() + date_of_birth = date_of_birth_var.get().strip() + if first_name or last_name: if last_name and first_name: current_name = f"{last_name}, {first_name}" @@ -759,7 +1036,11 @@ class PhotoTagger: current_name = first_name else: current_name = "" - face_person_names[current_face_id] = current_name + # Store both name and date of birth + face_person_names[current_face_id] = { + 'name': current_name, + 'date_of_birth': date_of_birth + } # Button commands command = None @@ -769,10 +1050,27 @@ class PhotoTagger: nonlocal command, waiting_for_input first_name = first_name_var.get().strip() last_name = last_name_var.get().strip() + date_of_birth = date_of_birth_var.get().strip() compare_enabled = compare_var.get() - if not first_name and not last_name: - print("⚠️ Please enter at least a first name or last name before identifying") + if not first_name: + print("⚠️ Please enter a first name before identifying") + return + + if not last_name: + print("⚠️ Please enter a last name before identifying") + return + + if not date_of_birth: + print("⚠️ Please select a date of birth before identifying") + return + + # Validate date format (YYYY-MM-DD) - should always be valid from calendar + try: + from datetime import datetime + datetime.strptime(date_of_birth, '%Y-%m-%d') + except ValueError: + print("⚠️ Invalid date format. Please use the calendar to select a date.") return # Combine first and last name properly @@ -843,12 +1141,27 @@ class PhotoTagger: if not validate_navigation(): return # Cancel quit - # Check if there are pending identifications (faces with names but not yet saved) - pending_identifications = { - k: v for k, v in face_person_names.items() - if v.strip() and (k not in face_status or face_status[k] != 'identified') - } - + # Check if there are pending identifications (faces with complete data but not yet saved) + pending_identifications = {} + for k, v in face_person_names.items(): + if k not in face_status or face_status[k] != 'identified': + # Handle both old format (string) and new format (dict) + if isinstance(v, dict): + person_name = v.get('name', '').strip() + date_of_birth = v.get('date_of_birth', '').strip() + # Check if name has both first and last name + if person_name and date_of_birth: + # Parse name to check for both first and last name + if ',' in person_name: + last_name, first_name = person_name.split(',', 1) + if last_name.strip() and first_name.strip(): + pending_identifications[k] = v + else: + # Single name format - not complete + pass + else: + # Old string format - not complete (missing date of birth) + pass if pending_identifications: # Ask user if they want to save pending identifications @@ -944,10 +1257,11 @@ class PhotoTagger: # Button references moved to bottom control panel def update_identify_button_state(): - """Enable/disable identify button based on name input""" + """Enable/disable identify button based on first name, last name, and date of birth""" first_name = first_name_var.get().strip() last_name = last_name_var.get().strip() - if first_name or last_name: + date_of_birth = date_of_birth_var.get().strip() + if first_name and last_name and date_of_birth: identify_btn.config(state='normal') else: identify_btn.config(state='disabled') @@ -955,6 +1269,7 @@ class PhotoTagger: # Bind name input changes to update button state first_name_var.trace('w', lambda *args: update_identify_button_state()) last_name_var.trace('w', lambda *args: update_identify_button_state()) + date_of_birth_var.trace('w', lambda *args: update_identify_button_state()) # Handle Enter key def on_enter(event): @@ -1186,6 +1501,7 @@ class PhotoTagger: else: first_name_var.set("") last_name_var.set("") + date_of_birth_var.set("") # Keep compare checkbox state persistent across navigation first_name_combo.focus_set() @@ -1263,6 +1579,9 @@ class PhotoTagger: print("⚠️ No more unidentified faces - Next button disabled") continue + # Clear date of birth field when moving to next face + date_of_birth_var.set("") + update_button_states() update_similar_faces() continue @@ -1285,6 +1604,37 @@ class PhotoTagger: print("⚠️ No more unidentified faces - Back button disabled") continue + # Repopulate fields with saved data when going back + current_face_id = original_faces[i][0] + if current_face_id in face_person_names: + person_data = face_person_names[current_face_id] + if isinstance(person_data, dict): + person_name = person_data.get('name', '').strip() + date_of_birth = person_data.get('date_of_birth', '').strip() + + # Parse "Last, First" format back to separate fields + if ', ' in person_name: + parts = person_name.split(', ', 1) + last_name_var.set(parts[0].strip()) + first_name_var.set(parts[1].strip()) + else: + # Fallback for single name + first_name_var.set(person_name) + last_name_var.set("") + + # Repopulate date of birth + date_of_birth_var.set(date_of_birth) + else: + # Old format - clear fields + first_name_var.set("") + last_name_var.set("") + date_of_birth_var.set("") + else: + # No saved data - clear fields + first_name_var.set("") + last_name_var.set("") + date_of_birth_var.set("") + update_button_states() update_similar_faces() continue @@ -1314,8 +1664,8 @@ class PhotoTagger: 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)) + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, date_of_birth) VALUES (?, ?, ?)', (first_name, last_name, None)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND date_of_birth IS NULL', (first_name, last_name)) result = cursor.fetchone() person_id = result[0] if result else None @@ -1362,8 +1712,8 @@ class PhotoTagger: 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)) + cursor.execute('INSERT OR IGNORE INTO people (first_name, last_name, date_of_birth) VALUES (?, ?, ?)', (first_name, last_name, None)) + cursor.execute('SELECT id FROM people WHERE first_name = ? AND last_name = ? AND date_of_birth IS NULL', (first_name, last_name)) result = cursor.fetchone() person_id = result[0] if result else None