diff --git a/backend/app/database/images.py b/backend/app/database/images.py index ec9541a56..fbf1a7b81 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -1,6 +1,6 @@ # Standard library imports import sqlite3 -from typing import Any, List, Mapping, Tuple, TypedDict, Union +from typing import Any, List, Mapping, Tuple, TypedDict, Union, Optional # App-specific imports from app.config.settings import ( @@ -396,6 +396,109 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: conn.close() +def db_search_images(query: str, tagged: Optional[bool] = None) -> List[dict]: + """ + Search images by tags, metadata, or filename. + + Args: + query: Search term to match against tags, metadata, or path + tagged: Optional filter for tagged status + + Returns: + List of dictionaries containing matching image data + """ + conn = _connect() + cursor = conn.cursor() + + try: + search_pattern = f"%{query}%" + + # FIXED QUERY — removed face_clusters (they do NOT exist in DB) + base_query = """ + SELECT DISTINCT + i.id, + i.path, + i.folder_id, + i.thumbnailPath, + i.metadata, + i.isTagged, + i.isFavourite, + m.name as tag_name, + md.location as location_name + FROM images i + LEFT JOIN image_classes ic ON i.id = ic.image_id + LEFT JOIN mappings m ON ic.class_id = m.class_id + LEFT JOIN metadata md ON i.id = md.image_id + WHERE ( + m.name LIKE ? OR + md.location LIKE ? OR + i.path LIKE ? + ) + """ + + params = [search_pattern, search_pattern, search_pattern] + + # Optional filter + if tagged is not None: + base_query += " AND i.isTagged = ?" + params.append(tagged) + + base_query += " ORDER BY i.path, m.name" + + cursor.execute(base_query, params) + results = cursor.fetchall() + + # Group results into image format + images_dict = {} + from app.utils.images import image_util_parse_metadata + + for ( + image_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + is_favourite, + tag_name, + location_name, + ) in results: + + if image_id not in images_dict: + metadata_dict = image_util_parse_metadata(metadata) + + images_dict[image_id] = { + "id": image_id, + "path": path, + "folder_id": str(folder_id), + "thumbnailPath": thumbnail_path, + "metadata": metadata_dict, + "isTagged": bool(is_tagged), + "isFavourite": bool(is_favourite), + "tags": [], + } + + if tag_name and tag_name not in images_dict[image_id]["tags"]: + images_dict[image_id]["tags"].append(tag_name) + + # Convert dict → list + images = list(images_dict.values()) + + for img in images: + if not img["tags"]: + img["tags"] = None + + images.sort(key=lambda x: x["path"]) + + return images + + except Exception as e: + logger.error(f"Error searching images: {e}") + return [] + finally: + conn.close() + + def db_toggle_image_favourite_status(image_id: str) -> bool: conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index 2e40cd825..d8cab9fc9 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from app.database.images import db_toggle_image_favourite_status from app.logging.setup_logging import get_logger +from app.database.images import db_search_images # Initialize logger logger = get_logger(__name__) @@ -88,6 +89,71 @@ def get_all_images( ) +@router.get( + "/search", + response_model=GetAllImagesResponse, + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +def search_images( + query: str = Query(..., min_length=1, description="Search query string"), + tagged: Optional[bool] = Query(None, description="Filter by tagged status"), +): + """ + Search images by: + - AI tags (YOLO detected classes) + - Metadata (location, path, etc.) + - Filename (image path) + + Note: + Face cluster search is not supported because the current database schema + does not include face_clusters. + """ + try: + if not query or not query.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse( + success=False, + error="Validation Error", + message="Search query cannot be empty", + ).model_dump(), + ) + + images = db_search_images(query.strip(), tagged=tagged) + + image_data = [ + ImageData( + id=image["id"], + path=image["path"], + folder_id=image["folder_id"], + thumbnailPath=image["thumbnailPath"], + metadata=image_util_parse_metadata(image["metadata"]), + isTagged=image["isTagged"], + isFavourite=image.get("isFavourite", False), + tags=image["tags"], + ) + for image in images + ] + + return GetAllImagesResponse( + success=True, + message=f"Found {len(image_data)} images matching '{query}'", + data=image_data, + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to search images: {str(e)}", + ).model_dump(), + ) + + # adding add to favourite and remove from favourite routes diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index a29e7c4f1..bf9cd9c41 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -887,6 +887,90 @@ } } }, + "/images/search": { + "get": { + "tags": [ + "Images" + ], + "summary": "Search Images", + "description": "Search images by:\n- AI tags (YOLO detected classes)\n- Metadata (location, path, etc.)\n- Filename (image path)\n\nNote:\nFace cluster search is not supported because the current database schema\ndoes not include face_clusters.", + "operationId": "search_images_images_search_get", + "parameters": [ + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "description": "Search query string", + "title": "Query" + }, + "description": "Search query string" + }, + { + "name": "tagged", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Filter by tagged status", + "title": "Tagged" + }, + "description": "Filter by tagged status" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllImagesResponse" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/images/toggle-favourite": { "post": { "tags": [ diff --git a/frontend/src/api/api-functions/images.ts b/frontend/src/api/api-functions/images.ts index dda3b21ce..132a4a0a9 100644 --- a/frontend/src/api/api-functions/images.ts +++ b/frontend/src/api/api-functions/images.ts @@ -12,3 +12,13 @@ export const fetchAllImages = async ( ); return response.data; }; + + +export const searchImages = async (query: string, tagged?: boolean): Promise => { + const params = new URLSearchParams({ query }); + if (tagged !== undefined) { + params.append('tagged', tagged.toString()); + } + const response = await apiClient.get(`/images/search?${params.toString()}`); + return response.data; +}; diff --git a/frontend/src/components/Dialog/FaceSearchDialog.tsx b/frontend/src/components/Dialog/FaceSearchDialog.tsx index 6adab5794..d5d574e08 100644 --- a/frontend/src/components/Dialog/FaceSearchDialog.tsx +++ b/frontend/src/components/Dialog/FaceSearchDialog.tsx @@ -11,7 +11,7 @@ import { } from '@/components/ui/dialog'; import { useDispatch } from 'react-redux'; import { useFile } from '@/hooks/selectFile'; -import { startSearch, clearSearch } from '@/features/searchSlice'; +import { startFaceSearch, clearSearch } from '@/features/searchSlice'; import type { Image } from '@/types/Media'; import { hideLoader, showLoader } from '@/features/loaderSlice'; import { usePictoMutation } from '@/hooks/useQueryExtension'; @@ -83,7 +83,7 @@ export function FaceSearchDialog() { const filePath = await pickSingleFile(); if (filePath) { setIsDialogOpen(false); - dispatch(startSearch(filePath)); + dispatch(startFaceSearch(filePath)); dispatch(showLoader('Searching faces...')); getSearchImages(filePath); } diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index c565b7f6d..b8c2138d0 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -1,9 +1,11 @@ +import { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { startTextSearch, clearSearch } from '@/features/searchSlice'; + import { Input } from '@/components/ui/input'; import { ThemeSelector } from '@/components/ThemeToggle'; -import { Search } from 'lucide-react'; -import { useDispatch, useSelector } from 'react-redux'; +import { Search} from 'lucide-react'; import { selectAvatar, selectName } from '@/features/onboardingSelectors'; -import { clearSearch } from '@/features/searchSlice'; import { convertFileSrc } from '@tauri-apps/api/core'; import { FaceSearchDialog } from '@/components/Dialog/FaceSearchDialog'; @@ -12,12 +14,31 @@ export function Navbar() { const userAvatar = useSelector(selectAvatar); const searchState = useSelector((state: any) => state.search); - const isSearchActive = searchState.active; + const isSearchActive = searchState.active && searchState.type === 'text'; const queryImage = searchState.queryImage; const dispatch = useDispatch(); + const [searchInput, setSearchInput] = useState(''); + + // Debounce search + useEffect(() => { + const timer = setTimeout(() => { + if (searchInput.trim().length > 0) { + dispatch(startTextSearch(searchInput.trim())); + } else if (searchInput.trim().length === 0 && isSearchActive) { + dispatch(clearSearch()); + } + }, 500); + + return () => clearTimeout(timer); + + // use searchState + }, [searchInput, dispatch, searchState.active, searchState.type]); + + return (
+ {/* Logo */}
@@ -29,46 +50,44 @@ export function Navbar() { {/* Search Bar */}
- {/* Query Image */} + + {/* Query Image Preview */} {queryImage && (
Query - {isSearchActive && ( - - )}
)} - {/* Input */} + {/* Search Input */} setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchInput(''); + dispatch(clearSearch()); + } + }} /> - {/* FaceSearch Dialog */} - + {/* Face Search */} + {/* Search Icon */} diff --git a/frontend/src/components/WebCam/WebCamComponent.tsx b/frontend/src/components/WebCam/WebCamComponent.tsx index 08b23158b..edc72b4b8 100644 --- a/frontend/src/components/WebCam/WebCamComponent.tsx +++ b/frontend/src/components/WebCam/WebCamComponent.tsx @@ -11,7 +11,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useDispatch } from 'react-redux'; -import { startSearch, clearSearch } from '@/features/searchSlice'; +import { startFaceSearch, clearSearch } from '@/features/searchSlice'; import type { Image } from '@/types/Media'; import { usePictoMutation } from '@/hooks/useQueryExtension'; import { fetchSearchedFacesBase64 } from '@/api/api-functions'; @@ -79,7 +79,7 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) { const handleSearchCapturedImage = () => { onClose(); if (capturedImageUrl) { - dispatch(startSearch(capturedImageUrl)); + dispatch(startFaceSearch(capturedImageUrl)); getSearchImagesBase64.mutate(capturedImageUrl); } else { dispatch( diff --git a/frontend/src/features/searchSlice.ts b/frontend/src/features/searchSlice.ts index 9786277c1..d306aa475 100644 --- a/frontend/src/features/searchSlice.ts +++ b/frontend/src/features/searchSlice.ts @@ -1,12 +1,18 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +type SearchType = 'text' | 'face' | null; + interface SearchState { active: boolean; - queryImage?: string; + type: SearchType; + query?: string; // Text search query + queryImage?: string; // Face search image path } const initialState: SearchState = { active: false, + type: null, + query: undefined, queryImage: undefined, }; @@ -14,16 +20,26 @@ const searchSlice = createSlice({ name: 'search', initialState, reducers: { - startSearch(state, action: PayloadAction) { + startTextSearch(state, action: PayloadAction) { + state.active = true; + state.type = 'text'; + state.query = action.payload; + state.queryImage = undefined; + }, + startFaceSearch(state, action: PayloadAction) { state.active = true; + state.type = 'face'; state.queryImage = action.payload; + state.query = undefined; }, clearSearch(state) { state.active = false; + state.type = null; + state.query = undefined; state.queryImage = undefined; }, }, }); -export const { startSearch, clearSearch } = searchSlice.actions; -export default searchSlice.reducer; +export const { startTextSearch, startFaceSearch, clearSearch } = searchSlice.actions; +export default searchSlice.reducer; \ No newline at end of file diff --git a/frontend/src/hooks/useImageSearch.ts b/frontend/src/hooks/useImageSearch.ts new file mode 100644 index 000000000..352d878c8 --- /dev/null +++ b/frontend/src/hooks/useImageSearch.ts @@ -0,0 +1,10 @@ +import { usePictoQuery } from './useQueryExtension'; +import { searchImages } from '@/api/api-functions/images'; + +export const useImageSearch = (query: string, enabled: boolean = true) => { + return usePictoQuery({ + queryKey: ['images', 'search', query], + queryFn: () => searchImages(query), + enabled: enabled && query.length > 0, + }); +}; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 83c9e5c83..89f87f710 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -14,41 +14,81 @@ import { RootState } from '@/app/store'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +// TEXT SEARCH +import { useImageSearch } from '@/hooks/useImageSearch'; + export const Home = () => { const dispatch = useDispatch(); const images = useSelector(selectImages); + const scrollableRef = useRef(null); const [monthMarkers, setMonthMarkers] = useState([]); + + // GLOBAL SEARCH STATE const searchState = useSelector((state: RootState) => state.search); - const isSearchActive = searchState.active; + const isTextSearchActive = searchState.active && searchState.type === "text"; + const isFaceSearchActive = searchState.active && searchState.type === "face"; + const searchQuery = searchState.query || ""; + // NORMAL FETCH — disabled during search const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ - queryKey: ['images'], + queryKey: ["images"], queryFn: () => fetchAllImages(), - enabled: !isSearchActive, + enabled: !searchState.active, }); + // TEXT SEARCH FETCH + const { + data: searchData, + isLoading: searchLoading, + isSuccess: searchSuccess, + } = useImageSearch(searchQuery, isTextSearchActive); + + // LOADING MERGE + const finalLoading = isTextSearchActive ? searchLoading : isLoading; + + // FEEDBACK useMutationFeedback( - { isPending: isLoading, isSuccess, isError, error }, { - loadingMessage: 'Loading images', - showSuccess: false, - errorTitle: 'Error', - errorMessage: 'Failed to load images. Please try again later.', + isPending: finalLoading, + isSuccess: isTextSearchActive ? searchSuccess : isSuccess, + isError, + error, }, + { + loadingMessage: "Loading images", + showSuccess: false, + errorTitle: "Error", + errorMessage: "Failed to load images. Please try again later.", + } ); + // UPDATE IMAGES BASED ON STATE useEffect(() => { - if (!isSearchActive && isSuccess) { - const images = data?.data as Image[]; + // Text search active + if (isTextSearchActive && searchSuccess) { + const images = (searchData?.data || []) as Image[]; + if (!Array.isArray(images)) { + console.error("Invalid search data format"); + return; + } + dispatch(setImages(images)); + return; + } + + // No search → normal image fetch + if (!searchState.active && isSuccess) { + const images = (data?.data || []) as Image[]; dispatch(setImages(images)); } - }, [data, isSuccess, dispatch, isSearchActive]); + }, [dispatch, searchData, data]); - const title = - isSearchActive && images.length > 0 - ? `Face Search Results (${images.length} found)` - : 'Image Gallery'; + // TITLE + const title = isTextSearchActive + ? `Search Results for "${searchQuery}" (${images.length} found)` + : isFaceSearchActive && images.length > 0 + ? `Face Search Results (${images.length} found)` + : "Image Gallery"; return (