Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions backend/app/database/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,106 @@ 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
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
WHERE (
m.name LIKE ? OR
i.metadata 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,
) 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()
Expand Down
66 changes: 66 additions & 0 deletions backend/app/routes/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import BaseModel
from app.database.images import db_toggle_image_favourite_status, db_get_image_by_id
from app.logging.setup_logging import get_logger
from app.database.images import db_search_images

# Initialize logger
logger = get_logger(__name__)
Expand Down Expand Up @@ -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


Expand Down
84 changes: 84 additions & 0 deletions docs/backend/backend_python/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,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": ["Images"],
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/api/api-functions/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,15 @@ export const fetchAllImages = async (
);
return response.data;
};

export const searchImages = async (
query: string,
tagged?: boolean,
): Promise<any> => {
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;
};
4 changes: 2 additions & 2 deletions frontend/src/components/Dialog/FaceSearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Comment on lines +86 to 88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear face-search state on request failure to avoid stale active mode.

startFaceSearch is dispatched before the API call, but the error path does not rollback search state. A failed request can leave search.active=true with stale face mode.

Suggested fix
   onError: () => {
     dispatch(hideLoader());
+    dispatch(clearSearch());
     dispatch(
       showInfoDialog({
         title: 'Search Failed',
         message: 'There was an error while searching for faces.',
         variant: 'error',
       }),
     );
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/Dialog/FaceSearchDialog.tsx` around lines 86 - 88,
startFaceSearch is dispatched before calling getSearchImages but failures don't
rollback the search state, leaving search.active=true; wrap the getSearchImages
call in a try/catch (or handle its promise rejection) and on error dispatch an
action that clears the face-search active state (e.g., stopFaceSearch /
clearFaceSearch), hide the loader (counterpart to showLoader) and surface/log
the error; update the error path where getSearchImages is invoked so the search
mode is reset whenever the API call fails.

}
Expand Down
Loading
Loading