From dbe8c78605361d0e1097a8703fa88fdc18eb3fa9 Mon Sep 17 00:00:00 2001 From: Isha Upadhyay Date: Thu, 27 Nov 2025 19:24:39 +0530 Subject: [PATCH 1/2] Added text-based search feature --- backend/app/database/images.py | 105 +++++++++++++++++- backend/app/routes/images.py | 66 +++++++++++ docs/backend/backend_python/openapi.json | 84 ++++++++++++++ frontend/src/api/api-functions/images.ts | 10 ++ .../components/Dialog/FaceSearchDialog.tsx | 4 +- .../components/Navigation/Navbar/Navbar.tsx | 66 +++++++++-- .../src/components/WebCam/WebCamComponent.tsx | 4 +- frontend/src/features/searchSlice.ts | 24 +++- frontend/src/hooks/useImageSearch.ts | 10 ++ frontend/src/pages/Home/Home.tsx | 53 +++++++-- 10 files changed, 400 insertions(+), 26 deletions(-) create mode 100644 frontend/src/hooks/useImageSearch.ts 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..39cb738f8 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -1,21 +1,40 @@ +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'; +import { Button } from '@/components/ui/button'; export function Navbar() { const userName = useSelector(selectName); 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 (500ms) + 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); + }, [searchInput, dispatch, isSearchActive]); + return (
{/* Logo */} @@ -29,12 +48,13 @@ export function Navbar() { {/* Search Bar */}
- {/* Query Image */} + + {/* Query Image (face search preview) */} {queryImage && (
{isSearchActive && (
)} - {/* Input */} + {/* TEXT SEARCH INPUT */} setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchInput(''); + dispatch(clearSearch()); + } + }} /> - {/* FaceSearch Dialog */} + {/* CLEAR BUTTON (X) */} + {isSearchActive && ( + + )} + {/* FACE SEARCH */} + {/* SEARCH ICON */} - )}
)} - {/* TEXT SEARCH INPUT */} + {/* Search Input */} - {/* CLEAR BUTTON (X) */} - {isSearchActive && ( - - )} - - {/* FACE SEARCH */} + {/* Face Search */} - {/* SEARCH ICON */} + {/* Search Icon */} diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 797314bae..89f87f710 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -14,39 +14,40 @@ import { RootState } from '@/app/store'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; -// NEW IMPORTS FOR TEXT SEARCH +// 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([]); - // SEARCH STATE + // GLOBAL SEARCH STATE const searchState = useSelector((state: RootState) => state.search); - const isTextSearchActive = searchState.active && searchState.type === 'text'; - const searchQuery = searchState.query || ''; - const isFaceSearchActive = searchState.active && searchState.type === 'face'; + const isTextSearchActive = searchState.active && searchState.type === "text"; + const isFaceSearchActive = searchState.active && searchState.type === "face"; + const searchQuery = searchState.query || ""; - // FETCH NORMAL IMAGES WHEN NO SEARCH + // NORMAL FETCH — disabled during search const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ - queryKey: ['images'], + queryKey: ["images"], queryFn: () => fetchAllImages(), - enabled: !searchState.active, // disable when ANY search is active + enabled: !searchState.active, }); - // TEXT SEARCH HOOK + // TEXT SEARCH FETCH const { data: searchData, isLoading: searchLoading, isSuccess: searchSuccess, } = useImageSearch(searchQuery, isTextSearchActive); - // LOADING STATUS + // LOADING MERGE const finalLoading = isTextSearchActive ? searchLoading : isLoading; - // FEEDBACK HANDLER + // FEEDBACK useMutationFeedback( { isPending: finalLoading, @@ -54,40 +55,40 @@ export const Home = () => { isError, error, }, - { - loadingMessage: 'Loading images', + loadingMessage: "Loading images", showSuccess: false, - errorTitle: 'Error', - errorMessage: 'Failed to load images. Please try again later.', - }, + errorTitle: "Error", + errorMessage: "Failed to load images. Please try again later.", + } ); - // UPDATE IMAGES ON TEXT SEARCH OR NORMAL FETCH + // UPDATE IMAGES BASED ON STATE useEffect(() => { + // Text search active if (isTextSearchActive && searchSuccess) { - const images = searchData?.data as Image[]; + const images = (searchData?.data || []) as Image[]; + if (!Array.isArray(images)) { + console.error("Invalid search data format"); + return; + } dispatch(setImages(images)); - } else if (!searchState.active && isSuccess) { - const images = data?.data as Image[]; + return; + } + + // No search → normal image fetch + if (!searchState.active && isSuccess) { + const images = (data?.data || []) as Image[]; dispatch(setImages(images)); } - }, [ - dispatch, - isTextSearchActive, - searchSuccess, - searchData, - searchState.active, - data, - isSuccess, - ]); + }, [dispatch, searchData, data]); // TITLE const title = isTextSearchActive ? `Search Results for "${searchQuery}" (${images.length} found)` : isFaceSearchActive && images.length > 0 - ? `Face Search Results (${images.length} found)` - : 'Image Gallery'; + ? `Face Search Results (${images.length} found)` + : "Image Gallery"; return (