Skip to content
Open
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
105 changes: 104 additions & 1 deletion backend/app/database/images.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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()
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
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 @@ -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": [
Expand Down
10 changes: 10 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,13 @@ 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);
}
Expand Down
Loading