From a8fd0568c96f45eddc36d534a078fe610f60da74 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 11 Dec 2025 13:39:36 -0500 Subject: [PATCH] feat: Enhance Search component with date taken filters and tag management This commit adds new date taken filters to the Search component, allowing users to specify a date range for photos based on when they were taken. Additionally, the tagging functionality is improved with options to filter by tags during searches, including a match mode for tag selection. The UI is updated to accommodate these features, enhancing user experience and search capabilities. Documentation has been updated to reflect these changes. --- frontend/src/pages/Search.tsx | 441 ++++++++++++++++++++++++++--- src/web/api/photos.py | 30 +- src/web/services/search_service.py | 120 ++++++++ 3 files changed, 537 insertions(+), 54 deletions(-) diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 8c2f3a7..1b3a4de 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -46,6 +46,8 @@ export default function Search() { const [matchAll, setMatchAll] = useState(false) const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') + const [dateTakenFrom, setDateTakenFrom] = useState('') + const [dateTakenTo, setDateTakenTo] = useState('') const [mediaType, setMediaType] = useState('all') // Default to 'all' // Person autocomplete @@ -136,6 +138,8 @@ export default function Search() { if (state.matchAll !== undefined) setMatchAll(state.matchAll) if (state.dateFrom) setDateFrom(state.dateFrom) if (state.dateTo) setDateTo(state.dateTo) + if (state.dateTakenFrom) setDateTakenFrom(state.dateTakenFrom) + if (state.dateTakenTo) setDateTakenTo(state.dateTakenTo) if (state.mediaType) setMediaType(state.mediaType) if (state.selectedPeople) setSelectedPeople(state.selectedPeople) if (state.inputValue) setInputValue(state.inputValue) @@ -195,6 +199,8 @@ export default function Search() { matchAll, dateFrom, dateTo, + dateTakenFrom, + dateTakenTo, mediaType, selectedPeople, inputValue, @@ -211,7 +217,7 @@ export default function Search() { } catch (error) { console.error('Error saving search state:', error) } - }, [searchType, selectedTags, matchAll, dateFrom, dateTo, mediaType, selectedPeople, inputValue, tagsExpanded, filtersExpanded, configExpanded, sortColumn, sortDir, page, results, total]) + }, [searchType, selectedTags, matchAll, dateFrom, dateTo, dateTakenFrom, dateTakenTo, mediaType, selectedPeople, inputValue, tagsExpanded, filtersExpanded, configExpanded, sortColumn, sortDir, page, results, total]) const performSearch = async (pageNum: number = page, showValidationErrors: boolean = true, searchTypeOverride?: SearchType) => { setLoading(true) @@ -254,6 +260,11 @@ export default function Search() { return } params.person_name = allNames.join(', ') + // Add tags filter if provided + if (selectedTags.length > 0) { + params.tag_names = selectedTags.join(', ') + params.match_all = matchAll + } } else if (currentSearchType === 'date') { if (!dateFrom && !dateTo) { if (showValidationErrors) { @@ -264,7 +275,20 @@ export default function Search() { } params.date_from = dateFrom || undefined params.date_to = dateTo || undefined - } else if (currentSearchType === 'tags') { + // Add tags filter if provided + if (selectedTags.length > 0) { + params.tag_names = selectedTags.join(', ') + params.match_all = matchAll + } + } else { + // For non-date search types, apply date taken filter if provided + if (dateTakenFrom || dateTakenTo) { + params.date_from = dateTakenFrom || undefined + params.date_to = dateTakenTo || undefined + } + } + + if (currentSearchType === 'tags') { if (selectedTags.length === 0) { if (showValidationErrors) { alert('Please select at least one tag to search for.') @@ -369,8 +393,8 @@ export default function Search() { if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed' || searchType === 'favorites') { handleSearch() } - // Clear selected tags when switching away from tag search - if (searchType !== 'tags') { + // Clear selected tags when switching away from tag/name/date search types + if (searchType !== 'tags' && searchType !== 'name' && searchType !== 'date') { setSelectedTags([]) } // Clear person autocomplete when switching away from name search @@ -808,10 +832,28 @@ export default function Search() { if (allNames.length > 0) { baseParams.person_name = allNames.join(', ') } + // Add tags filter if provided + if (selectedTags.length > 0) { + baseParams.tag_names = selectedTags.join(', ') + baseParams.match_all = matchAll + } } else if (searchType === 'date') { baseParams.date_from = dateFrom || undefined baseParams.date_to = dateTo || undefined - } else if (searchType === 'tags') { + // Add tags filter if provided + if (selectedTags.length > 0) { + baseParams.tag_names = selectedTags.join(', ') + baseParams.match_all = matchAll + } + } else { + // For non-date search types, apply date taken filter if provided + if (dateTakenFrom || dateTakenTo) { + baseParams.date_from = dateTakenFrom || undefined + baseParams.date_to = dateTakenTo || undefined + } + } + + if (searchType === 'tags') { baseParams.tag_names = selectedTags.join(', ') baseParams.match_all = matchAll } @@ -994,34 +1036,301 @@ export default function Search() {

Select people from the dropdown or type names manually. Separate multiple names with commas.

+ + {/* Tags filter for name search */} +
+
+

Filter by tags (optional)

+ +
+ {tagsExpanded && ( +
+
+ + {/* Selected tags display */} + {selectedTags.length > 0 && ( +
+
+ {selectedTags.map(tag => ( + + {tag} + + + ))} +
+ +
+ )} + {/* Tag input and dropdown */} +
+ { + setTagSearchInput(e.target.value) + setShowTagDropdown(true) + }} + onFocus={() => setShowTagDropdown(true)} + onBlur={(e) => { + setTimeout(() => { + if (!tagDropdownRef.current?.contains(e.relatedTarget as Node)) { + setShowTagDropdown(false) + } + }, 200) + }} + placeholder="Type to search and select tags..." + className="w-full border rounded px-3 py-2 text-sm" + /> + {showTagDropdown && ( +
e.preventDefault()} + > + {(() => { + const filtered = availableTags.filter(tag => + tag.tag_name.toLowerCase().includes(tagSearchInput.toLowerCase()) && + !selectedTags.includes(tag.tag_name) + ) + return filtered.length > 0 ? ( + filtered.map(tag => ( +
{ + if (!selectedTags.includes(tag.tag_name)) { + setSelectedTags([...selectedTags, tag.tag_name]) + } + setTagSearchInput('') + setTimeout(() => { + setShowTagDropdown(true) + tagInputRef.current?.focus() + }, 0) + }} + className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm" + > + {tag.tag_name} +
+ )) + ) : ( +
+ {tagSearchInput.trim() ? 'No matching tags found' : 'Start typing to search for tags'} +
+ ) + })()} +
+ )} +
+
+
+ + +
+
+ )} +
)} {searchType === 'date' && ( -
-
- - setDateFrom(e.target.value)} - className="w-48 border rounded px-3 py-2" - /> +
+
+
+ + setDateFrom(e.target.value)} + className="w-48 border rounded px-3 py-2" + /> +
+
+ + setDateTo(e.target.value)} + className="w-48 border rounded px-3 py-2" + /> +
-
- - setDateTo(e.target.value)} - className="w-48 border rounded px-3 py-2" - /> + {/* Tags filter for date search */} +
+
+

Filter by tags (optional)

+ +
+ {tagsExpanded && ( +
+
+ + {/* Selected tags display */} + {selectedTags.length > 0 && ( +
+
+ {selectedTags.map(tag => ( + + {tag} + + + ))} +
+ +
+ )} + {/* Tag input and dropdown */} +
+ { + setTagSearchInput(e.target.value) + setShowTagDropdown(true) + }} + onFocus={() => setShowTagDropdown(true)} + onBlur={(e) => { + setTimeout(() => { + if (!tagDropdownRef.current?.contains(e.relatedTarget as Node)) { + setShowTagDropdown(false) + } + }, 200) + }} + placeholder="Type to search and select tags..." + className="w-full border rounded px-3 py-2 text-sm" + /> + {showTagDropdown && ( +
e.preventDefault()} + > + {(() => { + const filtered = availableTags.filter(tag => + tag.tag_name.toLowerCase().includes(tagSearchInput.toLowerCase()) && + !selectedTags.includes(tag.tag_name) + ) + return filtered.length > 0 ? ( + filtered.map(tag => ( +
{ + if (!selectedTags.includes(tag.tag_name)) { + setSelectedTags([...selectedTags, tag.tag_name]) + } + setTagSearchInput('') + setTimeout(() => { + setShowTagDropdown(true) + tagInputRef.current?.focus() + }, 0) + }} + className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm" + > + {tag.tag_name} +
+ )) + ) : ( +
+ {tagSearchInput.trim() ? 'No matching tags found' : 'Start typing to search for tags'} +
+ ) + })()} +
+ )} +
+
+
+ + +
+
+ )}
)} @@ -1179,20 +1488,68 @@ export default function Search() {
{filtersExpanded && (
-
- - +
+
+ + +
+ {searchType !== 'date' && ( + <> +
+ + setDateTakenFrom(e.target.value)} + className="w-48 border rounded px-3 py-2" + /> +
+
+ + setDateTakenTo(e.target.value)} + className="w-48 border rounded px-3 py-2" + /> +
+ {(dateTakenFrom || dateTakenTo) && ( +
+ + +
+ )} + + )}
)} diff --git a/src/web/api/photos.py b/src/web/api/photos.py index c206497..1409939 100644 --- a/src/web/api/photos.py +++ b/src/web/api/photos.py @@ -99,6 +99,15 @@ def search_photos( ).first() return favorite is not None + # Parse date filters for use as additional filters (when not using date search type) + df = date.fromisoformat(date_from) if date_from else None + dt = date.fromisoformat(date_to) if date_to else None + + # Parse tag filters for use as additional filters + tag_list = None + if tag_names: + tag_list = [t.strip() for t in tag_names.split(",") if t.strip()] + if search_type == "name": if not person_name: raise HTTPException( @@ -106,7 +115,7 @@ def search_photos( detail="person_name is required for name search", ) results, total = search_photos_by_name( - db, person_name, folder_path, media_type, page, page_size + db, person_name, folder_path, media_type, df, dt, tag_list, match_all, page, page_size ) for photo, full_name in results: tags = get_photo_tags(db, photo.id) @@ -134,9 +143,7 @@ def search_photos( status_code=status.HTTP_400_BAD_REQUEST, detail="At least one of date_from or date_to is required", ) - df = date.fromisoformat(date_from) if date_from else None - dt = date.fromisoformat(date_to) if date_to else None - results, total = search_photos_by_date(db, df, dt, folder_path, media_type, page, page_size) + results, total = search_photos_by_date(db, df, dt, folder_path, media_type, tag_list, match_all, page, page_size) for photo in results: tags = get_photo_tags(db, photo.id) face_count = get_photo_face_count(db, photo.id) @@ -164,14 +171,13 @@ def search_photos( status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required for tag search", ) - tag_list = [t.strip() for t in tag_names.split(",") if t.strip()] - if not tag_list: + if not tag_list or len(tag_list) == 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="At least one tag name is required", ) results, total = search_photos_by_tags( - db, tag_list, match_all, folder_path, media_type, page, page_size + db, tag_list, match_all, folder_path, media_type, df, dt, page, page_size ) for photo in results: tags = get_photo_tags(db, photo.id) @@ -195,7 +201,7 @@ def search_photos( ) ) elif search_type == "no_faces": - results, total = get_photos_without_faces(db, folder_path, media_type, page, page_size) + results, total = get_photos_without_faces(db, folder_path, media_type, df, dt, page, page_size) for photo in results: tags = get_photo_tags(db, photo.id) # Convert datetime to date for date_added @@ -216,7 +222,7 @@ def search_photos( ) ) elif search_type == "no_tags": - results, total = get_photos_without_tags(db, folder_path, media_type, page, page_size) + results, total = get_photos_without_tags(db, folder_path, media_type, df, dt, page, page_size) for photo in results: face_count = get_photo_face_count(db, photo.id) person_name_val = get_photo_person(db, photo.id) @@ -238,7 +244,7 @@ def search_photos( ) ) elif search_type == "processed": - results, total = get_processed_photos(db, folder_path, media_type, page, page_size) + results, total = get_processed_photos(db, folder_path, media_type, df, dt, page, page_size) for photo in results: tags = get_photo_tags(db, photo.id) face_count = get_photo_face_count(db, photo.id) @@ -261,7 +267,7 @@ def search_photos( ) ) elif search_type == "unprocessed": - results, total = get_unprocessed_photos(db, folder_path, media_type, page, page_size) + results, total = get_unprocessed_photos(db, folder_path, media_type, df, dt, page, page_size) for photo in results: tags = get_photo_tags(db, photo.id) face_count = get_photo_face_count(db, photo.id) @@ -289,7 +295,7 @@ def search_photos( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required for favorites search", ) - results, total = get_favorite_photos(db, username, folder_path, media_type, page, page_size) + results, total = get_favorite_photos(db, username, folder_path, media_type, df, dt, page, page_size) for photo in results: tags = get_photo_tags(db, photo.id) face_count = get_photo_face_count(db, photo.id) diff --git a/src/web/services/search_service.py b/src/web/services/search_service.py index d262cb0..a9e77f2 100644 --- a/src/web/services/search_service.py +++ b/src/web/services/search_service.py @@ -16,6 +16,10 @@ def search_photos_by_name( person_name: str, folder_path: Optional[str] = None, media_type: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + tag_names: Optional[List[str]] = None, + match_all: bool = False, page: int = 1, page_size: int = 50, ) -> Tuple[List[Tuple[Photo, str]], int]: @@ -81,6 +85,42 @@ def search_photos_by_name( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply date taken filter if provided + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + + # Apply tag filter if provided + if tag_names: + # Find tag IDs (case-insensitive) + tag_ids = ( + db.query(Tag.id) + .filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names])) + .all() + ) + tag_ids = [tid[0] for tid in tag_ids] + + if tag_ids: + if match_all: + # Photos that have ALL specified tags + query = ( + query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id) + .filter(PhotoTagLinkage.tag_id.in_(tag_ids)) + .group_by(Photo.id, Person.id) + .having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids)) + ) + else: + # Photos that have ANY of the specified tags + tagged_photo_ids_subquery = ( + db.query(PhotoTagLinkage.photo_id) + .filter(PhotoTagLinkage.tag_id.in_(tag_ids)) + ) + query = query.filter(Photo.id.in_(tagged_photo_ids_subquery)) + else: + # No matching tags found - return empty result + return [], 0 + # Total count total = query.count() @@ -102,6 +142,8 @@ def search_photos_by_date( date_to: Optional[date] = None, folder_path: Optional[str] = None, media_type: Optional[str] = None, + tag_names: Optional[List[str]] = None, + match_all: bool = False, page: int = 1, page_size: int = 50, ) -> Tuple[List[Photo], int]: @@ -129,6 +171,36 @@ def search_photos_by_date( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply tag filter if provided + if tag_names: + # Find tag IDs (case-insensitive) + tag_ids = ( + db.query(Tag.id) + .filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names])) + .all() + ) + tag_ids = [tid[0] for tid in tag_ids] + + if tag_ids: + if match_all: + # Photos that have ALL specified tags + query = ( + query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id) + .filter(PhotoTagLinkage.tag_id.in_(tag_ids)) + .group_by(Photo.id) + .having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids)) + ) + else: + # Photos that have ANY of the specified tags + tagged_photo_ids_subquery = ( + db.query(PhotoTagLinkage.photo_id) + .filter(PhotoTagLinkage.tag_id.in_(tag_ids)) + ) + query = query.filter(Photo.id.in_(tagged_photo_ids_subquery)) + else: + # No matching tags found - return empty result + return [], 0 + # Total count total = query.count() @@ -144,6 +216,8 @@ def search_photos_by_tags( match_all: bool = False, folder_path: Optional[str] = None, media_type: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, page: int = 1, page_size: int = 50, ) -> Tuple[List[Photo], int]: @@ -196,6 +270,12 @@ def search_photos_by_tags( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply date taken filter if provided + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + # Total count total = query.count() @@ -209,6 +289,8 @@ def get_photos_without_faces( db: Session, folder_path: Optional[str] = None, media_type: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, page: int = 1, page_size: int = 50, ) -> Tuple[List[Photo], int]: @@ -234,6 +316,12 @@ def get_photos_without_faces( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply date taken filter if provided + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + # Total count total = query.count() @@ -247,6 +335,8 @@ def get_photos_without_tags( db: Session, folder_path: Optional[str] = None, media_type: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, page: int = 1, page_size: int = 50, ) -> Tuple[List[Photo], int]: @@ -270,6 +360,12 @@ def get_photos_without_tags( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply date taken filter if provided + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + # Total count total = query.count() @@ -312,6 +408,8 @@ def get_processed_photos( db: Session, folder_path: Optional[str] = None, media_type: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, page: int = 1, page_size: int = 50, ) -> Tuple[List[Photo], int]: @@ -331,6 +429,12 @@ def get_processed_photos( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply date taken filter if provided + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + # Total count total = query.count() @@ -345,6 +449,8 @@ def get_favorite_photos( username: str, folder_path: Optional[str] = None, media_type: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, page: int = 1, page_size: int = 50, ) -> Tuple[List[Photo], int]: @@ -368,6 +474,12 @@ def get_favorite_photos( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply date taken filter if provided + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + total = query.count() # Order by favorite date (most recent first), then date_taken @@ -387,6 +499,8 @@ def get_unprocessed_photos( db: Session, folder_path: Optional[str] = None, media_type: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, page: int = 1, page_size: int = 50, ) -> Tuple[List[Photo], int]: @@ -406,6 +520,12 @@ def get_unprocessed_photos( if media_type and media_type.lower() != "all": query = query.filter(Photo.media_type == media_type.lower()) + # Apply date taken filter if provided + if date_from: + query = query.filter(Photo.date_taken >= date_from) + if date_to: + query = query.filter(Photo.date_taken <= date_to) + # Total count total = query.count()