diff --git a/modify_identified_gui.py b/modify_identified_gui.py new file mode 100644 index 0000000..0a51cbf --- /dev/null +++ b/modify_identified_gui.py @@ -0,0 +1,1074 @@ +#!/usr/bin/env python3 +""" +Modify Identified Faces GUI implementation for PunimTag +""" + +import os +import tkinter as tk +from tkinter import ttk, messagebox +from PIL import Image, ImageTk + +from config import DEFAULT_FACE_TOLERANCE +from database import DatabaseManager +from face_processing import FaceProcessor +from gui_core import GUICore + + +class ModifyIdentifiedGUI: + """Handles the View and Modify Identified Faces GUI interface""" + + def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0): + self.db = db_manager + self.face_processor = face_processor + self.verbose = verbose + self.gui_core = GUICore() + + def modifyidentified(self) -> int: + """Open the View and Modify Identified Faces window""" + # Simple tooltip implementation + class ToolTip: + def __init__(self, widget, text): + self.widget = widget + self.text = text + self.tooltip_window = None + self.widget.bind("", self.on_enter) + self.widget.bind("", self.on_leave) + + def on_enter(self, event=None): + if self.tooltip_window or not self.text: + return + x, y, _, _ = self.widget.bbox("insert") if hasattr(self.widget, 'bbox') else (0, 0, 0, 0) + x += self.widget.winfo_rootx() + 25 + y += self.widget.winfo_rooty() + 25 + + self.tooltip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + + label = tk.Label(tw, text=self.text, justify=tk.LEFT, + background="#ffffe0", relief=tk.SOLID, borderwidth=1, + font=("tahoma", "8", "normal")) + label.pack(ipadx=1) + + def on_leave(self, event=None): + if self.tooltip_window: + self.tooltip_window.destroy() + self.tooltip_window = None + + # Create the main window + root = tk.Tk() + root.title("View and Modify Identified Faces") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + temp_crops = [] + right_panel_images = [] # Keep PhotoImage refs alive + selected_person_id = None + + # Hide window initially to prevent flash at corner + root.withdraw() + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal window_destroyed + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except Exception: + pass + temp_crops.clear() + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving + saved_size = self.gui_core.setup_window_size_saving(root, "gui_config.json") + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=2) + main_frame.rowconfigure(1, weight=1) + + # Title label + title_label = ttk.Label(main_frame, text="View and Modify Identified Faces", font=("Arial", 16, "bold")) + title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) + + # Left panel: People list + people_frame = ttk.LabelFrame(main_frame, text="People", padding="10") + people_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8)) + people_frame.columnconfigure(0, weight=1) + + # Search controls (Last Name) with label under the input (match auto-match style) + last_name_search_var = tk.StringVar() + search_frame = ttk.Frame(people_frame) + search_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 6)) + + # Entry on the left + search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20) + search_entry.grid(row=0, column=0, sticky=tk.W) + + # Buttons to the right of the entry + buttons_row = ttk.Frame(search_frame) + buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0)) + search_btn = ttk.Button(buttons_row, text="Search", width=8) + search_btn.pack(side=tk.LEFT, padx=(0, 5)) + clear_btn = ttk.Button(buttons_row, text="Clear", width=6) + clear_btn.pack(side=tk.LEFT) + + # Helper label directly under the entry + last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray") + last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0)) + + people_canvas = tk.Canvas(people_frame, bg='white') + people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview) + people_list_inner = ttk.Frame(people_canvas) + people_canvas.create_window((0, 0), window=people_list_inner, anchor="nw") + people_canvas.configure(yscrollcommand=people_scrollbar.set) + + people_list_inner.bind( + "", + lambda e: people_canvas.configure(scrollregion=people_canvas.bbox("all")) + ) + + people_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + people_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S)) + people_frame.rowconfigure(1, weight=1) + + # Right panel: Faces for selected person + faces_frame = ttk.LabelFrame(main_frame, text="Faces", padding="10") + faces_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) + faces_frame.columnconfigure(0, weight=1) + faces_frame.rowconfigure(0, weight=1) + + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + # Match auto-match UI: set gray background for left canvas and remove highlight border + try: + people_canvas.configure(bg=canvas_bg_color, highlightthickness=0) + except Exception: + pass + faces_canvas = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0) + faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=faces_canvas.yview) + faces_inner = ttk.Frame(faces_canvas) + faces_canvas.create_window((0, 0), window=faces_inner, anchor="nw") + faces_canvas.configure(yscrollcommand=faces_scrollbar.set) + + faces_inner.bind( + "", + lambda e: faces_canvas.configure(scrollregion=faces_canvas.bbox("all")) + ) + + # Track current person for responsive face grid + current_person_id = None + current_person_name = "" + resize_job = None + + # Track unmatched faces (temporary changes) + unmatched_faces = set() # All face IDs unmatched across people (for global save) + unmatched_by_person = {} # person_id -> set(face_id) for per-person undo + original_faces_data = [] # store original faces data for potential future use + + def on_faces_canvas_resize(event): + nonlocal resize_job + if current_person_id is None: + return + # Debounce re-render on resize + try: + if resize_job is not None: + root.after_cancel(resize_job) + except Exception: + pass + resize_job = root.after(150, lambda: show_person_faces(current_person_id, current_person_name)) + + faces_canvas.bind("", on_faces_canvas_resize) + + faces_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + 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, first_name, last_name} + people_filtered = None # filtered subset based on last name search + + def load_people(): + nonlocal people_data + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + 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, 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, 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 + }) + # Re-apply filter (if any) after loading + try: + apply_last_name_filter() + except Exception: + pass + + # Wire up search controls now that helper functions exist + try: + search_btn.config(command=lambda: apply_last_name_filter()) + clear_btn.config(command=lambda: clear_last_name_filter()) + search_entry.bind('', lambda e: apply_last_name_filter()) + except Exception: + pass + + def apply_last_name_filter(): + nonlocal people_filtered + query = last_name_search_var.get().strip().lower() + if query: + people_filtered = [p for p in people_data if p.get('last_name', '').lower().find(query) != -1] + else: + people_filtered = None + populate_people_list() + # Update right panel based on filtered results + source = people_filtered if people_filtered is not None else people_data + if source: + # Load faces for the first person in the list + first = source[0] + try: + # Update selection state + for child in people_list_inner.winfo_children(): + for widget in child.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10)) + # Bold the first label if present + first_row = people_list_inner.winfo_children()[0] + for widget in first_row.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10, "bold")) + break + except Exception: + pass + # Show faces for the first person + show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") + else: + # No matches: clear faces panel + clear_faces_panel() + + def clear_last_name_filter(): + nonlocal people_filtered + last_name_search_var.set("") + people_filtered = None + populate_people_list() + # After clearing, load faces for the first available person if any + if people_data: + first = people_data[0] + try: + for child in people_list_inner.winfo_children(): + for widget in child.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10)) + first_row = people_list_inner.winfo_children()[0] + for widget in first_row.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10, "bold")) + break + except Exception: + pass + show_person_faces(first['id'], first.get('full_name') or first.get('name') or "") + else: + clear_faces_panel() + + def clear_faces_panel(): + for w in faces_inner.winfo_children(): + w.destroy() + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except Exception: + pass + temp_crops.clear() + right_panel_images.clear() + + def unmatch_face(face_id: int): + """Temporarily unmatch a face from the current person""" + nonlocal unmatched_faces, unmatched_by_person + unmatched_faces.add(face_id) + # Track per-person for Undo + person_set = unmatched_by_person.get(current_person_id) + if person_set is None: + person_set = set() + unmatched_by_person[current_person_id] = person_set + person_set.add(face_id) + # Refresh the display + show_person_faces(current_person_id, current_person_name) + + def undo_changes(): + """Undo all temporary changes""" + nonlocal unmatched_faces, unmatched_by_person + if current_person_id in unmatched_by_person: + for fid in list(unmatched_by_person[current_person_id]): + unmatched_faces.discard(fid) + unmatched_by_person[current_person_id].clear() + # Refresh the display + show_person_faces(current_person_id, current_person_name) + + def save_changes(): + """Save unmatched faces to database""" + if not unmatched_faces: + return + + # Confirm with user + result = messagebox.askyesno( + "Confirm Changes", + f"Are you sure you want to unlink {len(unmatched_faces)} face(s) from '{current_person_name}'?\n\n" + "This will make these faces unidentified again." + ) + + if not result: + return + + # Update database + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + for face_id in unmatched_faces: + cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) + conn.commit() + + # Store count for message before clearing + unlinked_count = len(unmatched_faces) + + # Clear unmatched faces and refresh + unmatched_faces.clear() + original_faces_data.clear() + + # Refresh people list to update counts + load_people() + populate_people_list() + + # Refresh faces display + show_person_faces(current_person_id, current_person_name) + + messagebox.showinfo("Changes Saved", f"Successfully unlinked {unlinked_count} face(s) from '{current_person_name}'.") + + def show_person_faces(person_id: int, person_name: str): + nonlocal current_person_id, current_person_name, unmatched_faces, original_faces_data + current_person_id = person_id + current_person_name = person_name + clear_faces_panel() + + # Determine how many columns fit the available width + available_width = faces_canvas.winfo_width() + if available_width <= 1: + available_width = faces_frame.winfo_width() + tile_width = 150 # approx tile + padding + cols = max(1, available_width // tile_width) + + # Header row + header = ttk.Label(faces_inner, text=f"Faces for: {person_name}", font=("Arial", 12, "bold")) + header.grid(row=0, column=0, columnspan=cols, sticky=tk.W, pady=(0, 5)) + + # Control buttons row + button_frame = ttk.Frame(faces_inner) + button_frame.grid(row=1, column=0, columnspan=cols, sticky=tk.W, pady=(0, 10)) + + # Enable Undo only if current person has unmatched faces + current_has_unmatched = bool(unmatched_by_person.get(current_person_id)) + undo_btn = ttk.Button(button_frame, text="↶ Undo changes", + command=lambda: undo_changes(), + state="disabled" if not current_has_unmatched else "normal") + undo_btn.pack(side=tk.LEFT, padx=(0, 10)) + + # Note: Save button moved to bottom control bar + + # Query faces for this person + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT f.id, f.location, ph.path, ph.filename + FROM faces f + JOIN photos ph ON ph.id = f.photo_id + WHERE f.person_id = ? + ORDER BY f.id DESC + """, + (person_id,) + ) + rows = cursor.fetchall() + + # Filter out unmatched faces + visible_rows = [row for row in rows if row[0] not in unmatched_faces] + + if not visible_rows: + ttk.Label(faces_inner, text="No faces found." if not rows else "All faces unmatched.", foreground="gray").grid(row=2, column=0, sticky=tk.W) + return + + # Grid thumbnails with responsive column count + row_index = 2 # Start after header and buttons + col_index = 0 + for face_id, location, photo_path, filename in visible_rows: + crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) + thumb = None + if crop_path and os.path.exists(crop_path): + try: + img = Image.open(crop_path) + img.thumbnail((130, 130), Image.Resampling.LANCZOS) + photo_img = ImageTk.PhotoImage(img) + temp_crops.append(crop_path) + right_panel_images.append(photo_img) + thumb = photo_img + except Exception: + thumb = None + + tile = ttk.Frame(faces_inner, padding="5") + tile.grid(row=row_index, column=col_index, padx=5, pady=5, sticky=tk.N) + + # Create a frame for the face image with X button overlay + face_frame = ttk.Frame(tile) + face_frame.grid(row=0, column=0) + + canvas = tk.Canvas(face_frame, width=130, height=130, bg=canvas_bg_color, highlightthickness=0) + canvas.grid(row=0, column=0) + if thumb is not None: + canvas.create_image(65, 65, image=thumb) + else: + canvas.create_text(65, 65, text="🖼️", fill="gray") + + # X button to unmatch face - pin exactly to the canvas' top-right corner + x_canvas = tk.Canvas(face_frame, width=12, height=12, bg='red', + highlightthickness=0, relief="flat") + x_canvas.create_text(6, 6, text="✖", fill="white", font=("Arial", 8, "bold")) + # Click handler + x_canvas.bind("", lambda e, fid=face_id: unmatch_face(fid)) + # Hover highlight: change bg, show white outline, and hand cursor + x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2")) + x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="red", highlightthickness=0, cursor="")) + # Anchor to the canvas' top-right regardless of layout/size + try: + x_canvas.place(in_=canvas, relx=1.0, x=0, y=0, anchor='ne') + except Exception: + # Fallback to absolute coords if relative placement fails + x_canvas.place(x=118, y=0) + + ttk.Label(tile, text=f"ID {face_id}", foreground="gray").grid(row=1, column=0) + + col_index += 1 + if col_index >= cols: + col_index = 0 + row_index += 1 + + def populate_people_list(): + for w in people_list_inner.winfo_children(): + w.destroy() + source = people_filtered if people_filtered is not None else people_data + if not source: + empty_label = ttk.Label(people_list_inner, text="No people match filter", foreground="gray") + empty_label.grid(row=0, column=0, sticky=tk.W, pady=4) + return + for idx, person in enumerate(source): + row = ttk.Frame(people_list_inner) + row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=4) + # Freeze per-row values to avoid late-binding issues + row_person = person + row_idx = idx + + # Make person name clickable + def make_click_handler(p_id, p_name, p_idx): + def on_click(event): + nonlocal selected_person_id + # Reset all labels to normal font + for child in people_list_inner.winfo_children(): + for widget in child.winfo_children(): + if isinstance(widget, ttk.Label): + widget.config(font=("Arial", 10)) + # Set clicked label to bold + event.widget.config(font=("Arial", 10, "bold")) + selected_person_id = p_id + # Show faces for this person + show_person_faces(p_id, p_name) + return on_click + + # Edit (rename) button + def start_edit_person(row_frame, person_record, row_index): + for w in row_frame.winfo_children(): + w.destroy() + + # 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 larger container frame for the text boxes and labels + edit_container = ttk.Frame(row_frame) + edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # 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(first_frame, textvariable=first_var, width=15) + first_entry.pack(side=tk.TOP) + first_entry.focus_set() + + first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray") + first_label.pack(side=tk.TOP, pady=(2, 0)) + + # 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) + + 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 + 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 = 400 + 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 (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() + messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.") + return + + # Single database access - save to database + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + 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['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'] + populate_people_list() + # Reselect and refresh right panel header if needed + if selected_person_id == current_selected_id or selected_person_id is None: + # Find updated name + updated = next((p for p in people_data if p['id'] == current_selected_id), None) + if updated: + # Bold corresponding label + for child in people_list_inner.winfo_children(): + # child is row frame: contains label and button + widgets = child.winfo_children() + if not widgets: + continue + lbl = widgets[0] + if isinstance(lbl, ttk.Label) and lbl.cget('text').startswith(updated['name'] + " ("): + lbl.config(font=("Arial", 10, "bold")) + break + # Update right panel header by re-showing faces + show_person_faces(updated['id'], updated['name']) + + def cancel_edit(): + # Rebuild the row back to label + edit + for w in row_frame.winfo_children(): + w.destroy() + rebuild_row(row_frame, person_record, row_index) + + 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="✖", width=3, command=cancel_edit) + cancel_btn.pack(side=tk.LEFT, padx=(5, 0)) + + # 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) + 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.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")) + + # Build row contents with edit button + rebuild_row(row, row_person, row_idx) + + # Initial load + load_people() + populate_people_list() + + # Show first person's faces by default and mark selected + if people_data: + selected_person_id = people_data[0]['id'] + show_person_faces(people_data[0]['id'], people_data[0]['name']) + + # Control buttons + control_frame = ttk.Frame(main_frame) + control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) + + def on_quit(): + nonlocal window_destroyed + on_closing() + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + def on_save_all_changes(): + # Use global unmatched_faces set; commit all across people + nonlocal unmatched_faces + if not unmatched_faces: + messagebox.showinfo("Nothing to Save", "There are no pending changes to save.") + return + result = messagebox.askyesno( + "Confirm Save", + f"Unlink {len(unmatched_faces)} face(s) across all people?\n\nThis will make them unidentified." + ) + if not result: + return + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + for face_id in unmatched_faces: + cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,)) + conn.commit() + count = len(unmatched_faces) + unmatched_faces.clear() + # Refresh people list and right panel for current selection + load_people() + populate_people_list() + if current_person_id is not None and current_person_name: + show_person_faces(current_person_id, current_person_name) + messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).") + + save_btn_bottom = ttk.Button(control_frame, text="💾 Save changes", command=on_save_all_changes) + save_btn_bottom.pack(side=tk.RIGHT, padx=(0, 10)) + quit_btn = ttk.Button(control_frame, text="❌ Quit", command=on_quit) + quit_btn.pack(side=tk.RIGHT) + + # Show the window + try: + root.deiconify() + root.lift() + root.focus_force() + except tk.TclError: + # Window was destroyed before we could show it + return 0 + + # Main event loop + try: + root.mainloop() + except tk.TclError: + pass # Window was destroyed + + return 0 + + diff --git a/photo_tagger.py b/photo_tagger.py index 3531154..2856969 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -23,6 +23,7 @@ from search_stats import SearchStats from gui_core import GUICore from identify_gui import IdentifyGUI from auto_match_gui import AutoMatchGUI +from modify_identified_gui import ModifyIdentifiedGUI class PhotoTagger: @@ -43,6 +44,7 @@ class PhotoTagger: self.gui_core = GUICore() self.identify_gui = IdentifyGUI(self.db, self.face_processor, verbose) self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose) + self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose) # Legacy compatibility - expose some methods directly self._db_connection = None @@ -183,9 +185,7 @@ class PhotoTagger: return 0 def modifyidentified(self) -> int: - """Modify identified faces GUI""" - print("⚠️ Face modification GUI not yet implemented in refactored version") - return 0 + return self.modify_identified_gui.modifyidentified() def _setup_window_size_saving(self, root, config_file="gui_config.json"): """Set up window size saving functionality (legacy compatibility)"""