diff --git a/search_gui.py b/search_gui.py index 45bbd82..0923216 100644 --- a/search_gui.py +++ b/search_gui.py @@ -20,7 +20,7 @@ class SearchGUI: SEARCH_TYPES = [ "Search photos by name", - "Search photos by date (planned)", + "Search photos by date", "Search photos by tags", "Search photos by multiple people (planned)", "Most common tags (planned)", @@ -96,6 +96,41 @@ class SearchGUI: tag_mode_combo.pack(side=tk.LEFT, padx=(6, 0)) ttk.Label(tag_mode_frame, text="(ANY = photos with any tag, ALL = photos with all tags)").pack(side=tk.LEFT, padx=(6, 0)) + # Date search inputs + date_frame = ttk.Frame(inputs) + ttk.Label(date_frame, text="From date:").pack(side=tk.LEFT) + date_from_var = tk.StringVar() + date_from_entry = ttk.Entry(date_frame, textvariable=date_from_var, width=12) + date_from_entry.pack(side=tk.LEFT, padx=(6, 0)) + + # Calendar button for date from + def open_calendar_from(): + current_date = date_from_var.get() + selected_date = self.gui_core.create_calendar_dialog(root, "Select From Date", current_date) + if selected_date is not None: + date_from_var.set(selected_date) + + date_from_btn = ttk.Button(date_frame, text="📅", width=3, command=open_calendar_from) + date_from_btn.pack(side=tk.LEFT, padx=(6, 0)) + ttk.Label(date_frame, text="(YYYY-MM-DD)").pack(side=tk.LEFT, padx=(6, 0)) + + date_to_frame = ttk.Frame(inputs) + ttk.Label(date_to_frame, text="To date:").pack(side=tk.LEFT) + date_to_var = tk.StringVar() + date_to_entry = ttk.Entry(date_to_frame, textvariable=date_to_var, width=12) + date_to_entry.pack(side=tk.LEFT, padx=(6, 0)) + + # Calendar button for date to + def open_calendar_to(): + current_date = date_to_var.get() + selected_date = self.gui_core.create_calendar_dialog(root, "Select To Date", current_date) + if selected_date is not None: + date_to_var.set(selected_date) + + date_to_btn = ttk.Button(date_to_frame, text="📅", width=3, command=open_calendar_to) + date_to_btn.pack(side=tk.LEFT, padx=(6, 0)) + ttk.Label(date_to_frame, text="(YYYY-MM-DD, optional)").pack(side=tk.LEFT, padx=(6, 0)) + # Planned inputs (stubs) planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888") @@ -104,7 +139,7 @@ class SearchGUI: results_frame.pack(fill=tk.BOTH, expand=True) ttk.Label(results_frame, text="Results:", font=("Arial", 10, "bold")).pack(anchor="w") - columns = ("select", "person", "tags", "open_dir", "open_photo", "path") + columns = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse") tree.heading("select", text="☑") tree.heading("person", text="Person", command=lambda: sort_treeview("person")) @@ -112,12 +147,14 @@ class SearchGUI: tree.heading("open_dir", text="📁") tree.heading("open_photo", text="👤") tree.heading("path", text="Photo path", command=lambda: sort_treeview("path")) + tree.heading("date_taken", text="Date Taken", command=lambda: sort_treeview("date_taken")) tree.column("select", width=50, anchor="center") tree.column("person", width=180, anchor="w") tree.column("tags", width=200, anchor="w") tree.column("open_dir", width=50, anchor="center") tree.column("open_photo", width=50, anchor="center") tree.column("path", width=400, anchor="w") + tree.column("date_taken", width=100, anchor="center") tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0)) # Buttons @@ -149,9 +186,18 @@ class SearchGUI: # Sort the items # For person, tags, and path columns, sort alphabetically + # For date_taken column, sort by date # For icon columns, maintain original order if col in ['person', 'tags', 'path']: items.sort(key=lambda x: x[0].lower(), reverse=self.sort_reverse) + elif col == 'date_taken': + # Sort by date, handling "No date" entries + def date_sort_key(item): + date_str = item[0] + if date_str == "No date": + return "9999-12-31" # Put "No date" entries at the end + return date_str + items.sort(key=date_sort_key, reverse=self.sort_reverse) else: # For icon columns, just reverse if clicking same column if self.sort_column == col and self.sort_reverse: @@ -170,6 +216,7 @@ class SearchGUI: tree.heading("person", text="Person") tree.heading("tags", text="Tags") tree.heading("path", text="Photo path") + tree.heading("date_taken", text="Date Taken") # Add sort indicator to current sort column if self.sort_column == "person": @@ -181,6 +228,9 @@ class SearchGUI: elif self.sort_column == "path": indicator = " ↓" if self.sort_reverse else " ↑" tree.heading("path", text="Photo path" + indicator) + elif self.sort_column == "date_taken": + indicator = " ↓" if self.sort_reverse else " ↑" + tree.heading("date_taken", text="Date Taken" + indicator) # Behavior def switch_inputs(*_): @@ -193,6 +243,12 @@ class SearchGUI: if choice == self.SEARCH_TYPES[0]: # Search photos by name name_frame.pack(fill=tk.X) name_entry.configure(state="normal") + tag_entry.configure(state="disabled") + tag_mode_combo.configure(state="disabled") + date_from_entry.configure(state="disabled") + date_to_entry.configure(state="disabled") + date_from_btn.configure(state="disabled") + date_to_btn.configure(state="disabled") search_btn.configure(state="normal") # Show person column for name search tree.column("person", width=180, minwidth=50, anchor="w") @@ -201,12 +257,36 @@ class SearchGUI: tree.column("open_photo", width=50, minwidth=50, anchor="center") tree.heading("open_photo", text="👤") # Restore all columns to display - tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path") + tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") + elif choice == self.SEARCH_TYPES[1]: # Search photos by date + date_frame.pack(fill=tk.X) + date_to_frame.pack(fill=tk.X, pady=(4, 0)) + name_entry.configure(state="disabled") + tag_entry.configure(state="disabled") + tag_mode_combo.configure(state="disabled") + date_from_entry.configure(state="normal") + date_to_entry.configure(state="normal") + date_from_btn.configure(state="normal") + date_to_btn.configure(state="normal") + search_btn.configure(state="normal") + # Show person column for date search since photos might have people + tree.column("person", width=180, minwidth=50, anchor="w") + tree.heading("person", text="Person", command=lambda: sort_treeview("person")) + # Restore people icon column for date search + tree.column("open_photo", width=50, minwidth=50, anchor="center") + tree.heading("open_photo", text="👤") + # Show all columns for date search + tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") elif choice == self.SEARCH_TYPES[2]: # Search photos by tags tag_frame.pack(fill=tk.X) tag_mode_frame.pack(fill=tk.X, pady=(4, 0)) + name_entry.configure(state="disabled") tag_entry.configure(state="normal") tag_mode_combo.configure(state="readonly") + date_from_entry.configure(state="disabled") + date_to_entry.configure(state="disabled") + date_from_btn.configure(state="disabled") + date_to_btn.configure(state="disabled") search_btn.configure(state="normal") # Hide person column completely for tag search tree.column("person", width=0, minwidth=0, anchor="w") @@ -215,7 +295,7 @@ class SearchGUI: tree.column("open_photo", width=50, minwidth=50, anchor="center") tree.heading("open_photo", text="👤") # Also hide the column from display - tree["displaycolumns"] = ("select", "tags", "open_dir", "open_photo", "path") + tree["displaycolumns"] = ("select", "tags", "open_dir", "open_photo", "path", "date_taken") elif choice == self.SEARCH_TYPES[6]: # Photos without faces # No input needed for this search type search_btn.configure(state="normal") @@ -226,7 +306,7 @@ class SearchGUI: tree.column("open_photo", width=0, minwidth=0, anchor="center") tree.heading("open_photo", text="") # Also hide the columns from display - tree["displaycolumns"] = ("select", "tags", "open_dir", "path") + tree["displaycolumns"] = ("select", "tags", "open_dir", "path", "date_taken") # Auto-run search for photos without faces do_search() elif choice == self.SEARCH_TYPES[7]: # Photos without tags @@ -239,7 +319,7 @@ class SearchGUI: tree.column("open_photo", width=50, minwidth=50, anchor="center") tree.heading("open_photo", text="👤") # Show all columns since we want to display person info and tags (which will be empty) - tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path") + tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") # Auto-run search for photos without tags do_search() else: @@ -247,6 +327,10 @@ class SearchGUI: name_entry.configure(state="disabled") tag_entry.configure(state="disabled") tag_mode_combo.configure(state="disabled") + date_from_entry.configure(state="disabled") + date_to_entry.configure(state="disabled") + date_from_btn.configure(state="disabled") + date_to_btn.configure(state="disabled") search_btn.configure(state="disabled") # Show person column for other search types tree.column("person", width=180, minwidth=50, anchor="w") @@ -255,7 +339,7 @@ class SearchGUI: tree.column("open_photo", width=50, minwidth=50, anchor="center") tree.heading("open_photo", text="👤") # Restore all columns to display - tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path") + tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path", "date_taken") def clear_results(): for i in tree.get_children(): @@ -270,36 +354,49 @@ class SearchGUI: update_header_display() def add_results(rows: List[tuple]): - # rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search + # rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search or List[(path, date_taken)] for date search for row in rows: if len(row) == 2: - if search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search + if search_type_var.get() == self.SEARCH_TYPES[1]: # Date search + # For date search: (path, date_taken) - show all columns + path, date_taken = row + person_name = get_person_name_for_photo(path) + photo_tags = get_photo_tags_for_display(path) + tree.insert("", tk.END, values=("☐", person_name, photo_tags, "📁", "👤", path, date_taken)) + elif search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search # For tag search: (path, tag_info) - hide person column # Show ALL tags for the photo, not just matching ones path, tag_info = row photo_tags = get_photo_tags_for_display(path) - tree.insert("", tk.END, values=("☐", "", photo_tags, "📁", "👤", path)) + date_taken = get_photo_date_taken(path) + tree.insert("", tk.END, values=("☐", "", photo_tags, "📁", "👤", path, date_taken)) elif search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces # For photos without faces: (path, tag_info) - hide person and people icon columns path, tag_info = row photo_tags = get_photo_tags_for_display(path) - tree.insert("", tk.END, values=("☐", "", photo_tags, "📁", "", path)) + date_taken = get_photo_date_taken(path) + tree.insert("", tk.END, values=("☐", "", photo_tags, "📁", "", path, date_taken)) elif search_type_var.get() == self.SEARCH_TYPES[7]: # Photos without tags # For photos without tags: (path, filename) - show all columns but tags will be empty path, filename = row person_name = get_person_name_for_photo(path) photo_tags = get_photo_tags_for_display(path) # Will be "No tags" - tree.insert("", tk.END, values=("☐", person_name, photo_tags, "📁", "👤", path)) + date_taken = get_photo_date_taken(path) + tree.insert("", tk.END, values=("☐", person_name, photo_tags, "📁", "👤", path, date_taken)) else: # For name search: (full_name, path) - show person column full_name, p = row # Get tags for this photo photo_tags = get_photo_tags_for_display(p) - tree.insert("", tk.END, values=("☐", full_name, photo_tags, "📁", "👤", p)) + date_taken = get_photo_date_taken(p) + tree.insert("", tk.END, values=("☐", full_name, photo_tags, "📁", "👤", p, date_taken)) # Sort by appropriate column by default when results are first loaded if rows and self.sort_column is None: - if search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search + if search_type_var.get() == self.SEARCH_TYPES[1]: # Date search + # Sort by date_taken column for date search + self.sort_column = "date_taken" + elif search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search # Sort by tags column for tag search self.sort_column = "tags" elif search_type_var.get() == self.SEARCH_TYPES[6]: # Photos without faces @@ -315,7 +412,16 @@ class SearchGUI: self.sort_reverse = False # Get all items and sort them directly items = [(tree.set(child, self.sort_column), child) for child in tree.get_children('')] - items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending + if self.sort_column == 'date_taken': + # Sort by date, handling "No date" entries + def date_sort_key(item): + date_str = item[0] + if date_str == "No date": + return "9999-12-31" # Put "No date" entries at the end + return date_str + items.sort(key=date_sort_key, reverse=False) # Ascending + else: + items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending # Reorder items in treeview for index, (val, child) in enumerate(items): tree.move(child, '', index) @@ -334,6 +440,47 @@ class SearchGUI: if not rows: messagebox.showinfo("Search", f"No photos found for '{query}'.", parent=root) add_results(rows) + elif choice == self.SEARCH_TYPES[1]: # Search photos by date + date_from = date_from_var.get().strip() + date_to = date_to_var.get().strip() + + # Validate date format if provided + if date_from: + try: + from datetime import datetime + datetime.strptime(date_from, '%Y-%m-%d') + except ValueError: + messagebox.showerror("Invalid Date", "From date must be in YYYY-MM-DD format.", parent=root) + return + + if date_to: + try: + from datetime import datetime + datetime.strptime(date_to, '%Y-%m-%d') + except ValueError: + messagebox.showerror("Invalid Date", "To date must be in YYYY-MM-DD format.", parent=root) + return + + # Check if at least one date is provided + if not date_from and not date_to: + messagebox.showinfo("Search", "Please enter at least one date (from date or to date).", parent=root) + return + + rows = self.search_stats.search_photos_by_date(date_from if date_from else None, + date_to if date_to else None) + if not rows: + date_range_text = "" + if date_from and date_to: + date_range_text = f" between {date_from} and {date_to}" + elif date_from: + date_range_text = f" from {date_from}" + elif date_to: + date_range_text = f" up to {date_to}" + messagebox.showinfo("Search", f"No photos found{date_range_text}.", parent=root) + else: + # Convert to the format expected by add_results: (path, date_taken) + formatted_rows = [(path, date_taken) for path, date_taken in rows] + add_results(formatted_rows) elif choice == self.SEARCH_TYPES[2]: # Search photos by tags tag_query = tag_var.get().strip() if not tag_query: @@ -574,6 +721,20 @@ class SearchGUI: else: return "No tags" + def get_photo_date_taken(photo_path): + """Get date_taken for a photo to display in the date_taken column.""" + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT date_taken FROM photos WHERE path = ?', (photo_path,)) + result = cursor.fetchone() + if result and result[0]: + return result[0] # Return the date as stored in database + else: + return "No date" # No date_taken available + except Exception: + return "No date" + def get_photo_people_tooltip(photo_path): """Get people information for a photo to display in tooltip.""" try: @@ -1085,11 +1246,14 @@ class SearchGUI: name_entry.bind("", lambda e: do_search()) # Enter key in tag field triggers search tag_entry.bind("", lambda e: do_search()) + # Enter key in date fields triggers search + date_from_entry.bind("", lambda e: do_search()) + date_to_entry.bind("", lambda e: do_search()) # Show and center root.update_idletasks() - # Widened to ensure all columns are visible by default - self.gui_core.center_window(root, 1000, 520) + # Widened to ensure all columns are visible by default, including the new date taken column + self.gui_core.center_window(root, 1200, 520) root.deiconify() root.mainloop() return 0 diff --git a/search_stats.py b/search_stats.py index 847e4a8..c3928b0 100644 --- a/search_stats.py +++ b/search_stats.py @@ -184,11 +184,68 @@ class SearchStats: 'tags_per_photo': stats['tags_per_photo'] } - def search_photos_by_date(self, date_from: str = None, date_to: str = None) -> List[Tuple]: - """Search photos by date range""" - # This would need to be implemented in the database module - # For now, return empty list - return [] + def search_photos_by_date(self, date_from: str = None, date_to: str = None) -> List[Tuple[str, str]]: + """Search photos by date range. + + Args: + date_from: Start date in YYYY-MM-DD format (inclusive) + date_to: End date in YYYY-MM-DD format (inclusive) + + Returns: + List of tuples: (photo_path, date_taken) + """ + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + # Build the query based on provided date parameters + if date_from and date_to: + # Both dates provided - search within range + query = ''' + SELECT path, date_taken + FROM photos + WHERE date_taken IS NOT NULL + AND date_taken >= ? AND date_taken <= ? + ORDER BY date_taken DESC, filename + ''' + cursor.execute(query, (date_from, date_to)) + elif date_from: + # Only start date provided - search from date onwards + query = ''' + SELECT path, date_taken + FROM photos + WHERE date_taken IS NOT NULL + AND date_taken >= ? + ORDER BY date_taken DESC, filename + ''' + cursor.execute(query, (date_from,)) + elif date_to: + # Only end date provided - search up to date + query = ''' + SELECT path, date_taken + FROM photos + WHERE date_taken IS NOT NULL + AND date_taken <= ? + ORDER BY date_taken DESC, filename + ''' + cursor.execute(query, (date_to,)) + else: + # No dates provided - return all photos with date_taken + query = ''' + SELECT path, date_taken + FROM photos + WHERE date_taken IS NOT NULL + ORDER BY date_taken DESC, filename + ''' + cursor.execute(query) + + results = cursor.fetchall() + return [(row[0], row[1]) for row in results] + + except Exception as e: + if self.verbose >= 1: + print(f"Error searching photos by date: {e}") + return [] def search_photos_by_tags(self, tags: List[str], match_all: bool = False) -> List[Tuple]: """Search photos by tags