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
96 changes: 96 additions & 0 deletions backend/app/database/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,102 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]:
conn.close()


def db_search_images_by_tags(tags: List[str]) -> List[dict]:
"""
Search images that contain ANY of the specified tag names (case-insensitive).

Args:
tags: List of tag name strings to search for.

Returns:
List of image dicts (same shape as db_get_all_images) that have at least
one matching tag.
"""
if not tags:
return []

conn = _connect()
cursor = conn.cursor()

try:
placeholders = ",".join("?" for _ in tags)
query = f"""
SELECT DISTINCT
i.id,
i.path,
i.folder_id,
i.thumbnailPath,
i.metadata,
i.isTagged,
i.isFavourite,
i.latitude,
i.longitude,
i.captured_at,
all_tags.name as tag_name
FROM images i
INNER JOIN image_classes match_ic ON i.id = match_ic.image_id
INNER JOIN mappings match_m ON match_ic.class_id = match_m.class_id
LEFT JOIN image_classes all_ic ON i.id = all_ic.image_id
LEFT JOIN mappings all_tags ON all_ic.class_id = all_tags.class_id
WHERE LOWER(match_m.name) IN ({placeholders})
ORDER BY i.path, all_tags.name
"""

params = [tag.lower() for tag in tags]
cursor.execute(query, params)
results = cursor.fetchall()

images_dict = {}
for (
image_id,
path,
folder_id,
thumbnail_path,
metadata,
is_tagged,
is_favourite,
latitude,
longitude,
captured_at,
tag_name,
) in results:
if image_id not in images_dict:
from app.utils.images import image_util_parse_metadata

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),
"latitude": latitude,
"longitude": longitude,
"captured_at": captured_at if captured_at else None,
"tags": [],
}

if tag_name and tag_name not in images_dict[image_id]["tags"]:
images_dict[image_id]["tags"].append(tag_name)

images = []
for image_data in images_dict.values():
if not image_data["tags"]:
image_data["tags"] = None
images.append(image_data)

images.sort(key=lambda x: x["path"])
return images

except Exception as e:
logger.error(f"Error searching images by tags: {e}")
return []
finally:
conn.close()


def db_get_untagged_images() -> List[UntaggedImageRecord]:
"""
Find all images that need AI tagging.
Expand Down
55 changes: 54 additions & 1 deletion backend/app/routes/images.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException, Query, status
from typing import List, Optional
from app.database.images import db_get_all_images
from app.database.images import db_get_all_images, db_search_images_by_tags
from app.schemas.images import ErrorResponse
from app.utils.images import image_util_parse_metadata
from pydantic import BaseModel
Expand Down Expand Up @@ -128,3 +128,56 @@ class ImageInfoResponse(BaseModel):
isTagged: bool
isFavourite: bool
tags: Optional[List[str]] = None


@router.get(
"/search",
response_model=GetAllImagesResponse,
responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
def search_images_by_tags(
tags: str = Query(
...,
description="Comma-separated tag names to search for (e.g. dog,beach,person)",
)
):
"""Return images whose AI-detected tags contain any of the requested tags."""
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if not tag_list:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse(
success=False,
error="Bad request",
message="At least one tag must be provided.",
).model_dump(),
)
try:
images = db_search_images_by_tags(tag_list)
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)} image(s) matching tags: {', '.join(tag_list)}",
data=image_data,
)
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(),
)
Comment on lines +133 to +183
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for tests covering the search_images_by_tags function and related functionality
rg -n -C2 "search_images_by_tags|db_search_images_by_tags|/images/search|tags.*dog.*beach" -g "*test*" -t py

Repository: AOSSIE-Org/PictoPy

Length of output: 44


🏁 Script executed:

# Also search more broadly for test files related to images
fd -type f -name "*test*.py" | head -20

Repository: AOSSIE-Org/PictoPy

Length of output: 232


🏁 Script executed:

# Check the structure of test directories
fd -type d -name "test*" -o -name "*test"

Repository: AOSSIE-Org/PictoPy

Length of output: 232


🏁 Script executed:

# Find all test files
fd -type f -name "*test*.py"

Repository: AOSSIE-Org/PictoPy

Length of output: 232


🏁 Script executed:

# Search for any test directory
find . -type d \( -name test -o -name tests -o -name "*test*" \) | head -20

Repository: AOSSIE-Org/PictoPy

Length of output: 108


🏁 Script executed:

# Search for imports of the search function or route handler in any Python files
rg "search_images_by_tags|db_search_images_by_tags" --type py

Repository: AOSSIE-Org/PictoPy

Length of output: 395


🏁 Script executed:

# Check if there's a test file for the images route
find . -type f -name "*.py" -path "*test*" | xargs grep -l "search.*image\|image.*search" 2>/dev/null

Repository: AOSSIE-Org/PictoPy

Length of output: 46


🏁 Script executed:

# List contents of the backend/tests directory
find ./backend/tests -type f -name "*.py" | head -30

Repository: AOSSIE-Org/PictoPy

Length of output: 259


🏁 Script executed:

# Search for any references to images in test files
rg "search.*image\|image.*search\|test.*search" ./backend/tests -A 3

Repository: AOSSIE-Org/PictoPy

Length of output: 44


🏁 Script executed:

# Look for test files specifically for the routes
find ./backend/tests -type f -name "*image*"

Repository: AOSSIE-Org/PictoPy

Length of output: 44


🏁 Script executed:

# Check the structure of backend/tests
ls -la ./backend/tests/

Repository: AOSSIE-Org/PictoPy

Length of output: 692


🏁 Script executed:

# Search all test files for any reference to /images endpoint
rg "/images|images_route|test.*image" ./backend/tests/

Repository: AOSSIE-Org/PictoPy

Length of output: 1847


🏁 Script executed:

# Check the conftest.py to understand test setup
cat ./backend/tests/conftest.py

Repository: AOSSIE-Org/PictoPy

Length of output: 1602


🏁 Script executed:

# Look at one of the test files to understand the pattern and structure
head -50 ./backend/tests/test_folders.py

Repository: AOSSIE-Org/PictoPy

Length of output: 1411


Add automated tests for /images/search endpoint before merge.

No test coverage exists for the search_images_by_tags endpoint or its database function. This critical user-facing search feature lacks tests for validation (empty/invalid tags), OR semantics (matching any tag), and error scenarios. Given the established test pattern in the codebase (test_albums.py, test_folders.py, test_face_clusters.py), a test_images.py file with comprehensive route and database-level tests is required to meet project standards.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/routes/images.py` around lines 133 - 183, Add a new
test_images.py following the existing test patterns to cover the
search_images_by_tags endpoint and its DB function: write route tests that call
search_images_by_tags (via test client) to assert 400 on empty/invalid tags,
assert OR semantics by seeding images with overlapping tags and verifying
GetAllImagesResponse contains any matching ImageData (check id, path, tags,
thumbnailPath, metadata via image_util_parse_metadata expectations), and
simulate an internal error by patching/db_search_images_by_tags to raise an
exception and assert a 500 with the ErrorResponse shape; also add unit tests for
db_search_images_by_tags to verify correct results for single/multiple tags and
edge cases (no matches), and ensure tests clean up/seed fixtures consistent with
test_albums.py pattern.

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 searchImagesByTags = async (
tags: string[],
): Promise<APIResponse> => {
const response = await apiClient.get<APIResponse>(
imagesEndpoints.searchByTags,
{ params: { tags: tags.join(',') } },
);
return response.data;
};
1 change: 1 addition & 0 deletions frontend/src/api/apiEndpoints.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const imagesEndpoints = {
getAllImages: '/images/',
searchByTags: '/images/search',
setFavourite: '/images/toggle-favourite',
};

Expand Down
62 changes: 60 additions & 2 deletions frontend/src/components/Navigation/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { ThemeSelector } from '@/components/ThemeToggle';
import { Search } from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux';
import { selectAvatar, selectName } from '@/features/onboardingSelectors';
import { clearSearch } from '@/features/searchSlice';
import { clearSearch, startTextSearch, clearTextSearch } from '@/features/searchSlice';
import { convertFileSrc } from '@tauri-apps/api/core';
import { FaceSearchDialog } from '@/components/Dialog/FaceSearchDialog';
import { useState, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { searchImagesByTags } from '@/api/api-functions/images';
import { setImages } from '@/features/imageSlice';

export function Navbar() {
const userName = useSelector(selectName);
Expand All @@ -14,8 +18,43 @@ export function Navbar() {
const searchState = useSelector((state: any) => state.search);
const isSearchActive = searchState.active;
const queryImage = searchState.queryImage;
const isTextSearchActive = searchState.textSearchActive;

const [textQuery, setTextQuery] = useState('');
const dispatch = useDispatch();
const queryClient = useQueryClient();

const handleTextSearch = useCallback(async () => {
const trimmed = textQuery.trim();
if (!trimmed) return;
// Split on commas only β€” preserves multi-word tags like "traffic light"
const tags = trimmed.split(',').map((t) => t.trim()).filter(Boolean);
try {
const result = await searchImagesByTags(tags);
if (result?.data) {
dispatch(setImages(result.data));
dispatch(startTextSearch(trimmed));
}
} catch (err) {
console.error('Tag search failed:', err);
}
}, [textQuery, dispatch]);

const handleClearTextSearch = useCallback(() => {
setTextQuery('');
dispatch(clearTextSearch());
// Invalidate the images query so Home.tsx refetches the full gallery
queryClient.invalidateQueries({ queryKey: ['images'] });
}, [dispatch, queryClient]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleTextSearch();
if (e.key === 'Escape') handleClearTextSearch();
},
[handleTextSearch, handleClearTextSearch],
);

return (
<div className="sticky top-0 z-40 flex h-14 w-full items-center justify-between border-b pr-4 backdrop-blur">
{/* Logo */}
Expand Down Expand Up @@ -57,18 +96,37 @@ export function Navbar() {
{/* Input */}
<Input
type="search"
placeholder="Add to your search"
placeholder="Search by tags (e.g. dog, beach)"
className="mr-2 flex-1 border-0 bg-neutral-200"
value={textQuery}
onChange={(e) => {
setTextQuery(e.target.value);
if (e.target.value === '' && isTextSearchActive) {
handleClearTextSearch();
}
}}
onKeyDown={handleKeyDown}
/>

{/* FaceSearch Dialog */}

<FaceSearchDialog />

{isTextSearchActive && (
<button
onClick={handleClearTextSearch}
className="text-muted-foreground hover:bg-accent dark:hover:bg-accent/50 hover:text-foreground mx-1 cursor-pointer rounded-sm p-2"
title="Clear text search"
aria-label="Clear text search"
>
βœ•
</button>
)}
<button
className="text-muted-foreground hover:bg-accent dark:hover:bg-accent/50 hover:text-foreground mx-1 cursor-pointer rounded-sm p-2"
title="Search"
aria-label="Search"
onClick={handleTextSearch}
>
<Search className="h-4 w-4" />
</button>
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/features/searchSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface SearchState {
active: boolean;
queryImage?: string;
queryText?: string;
textSearchActive: boolean;
}

const initialState: SearchState = {
active: false,
queryImage: undefined,
queryText: undefined,
textSearchActive: false,
};

const searchSlice = createSlice({
Expand All @@ -22,8 +26,17 @@ const searchSlice = createSlice({
state.active = false;
state.queryImage = undefined;
},
startTextSearch(state, action: PayloadAction<string>) {
state.textSearchActive = true;
state.queryText = action.payload;
},
clearTextSearch(state) {
state.textSearchActive = false;
state.queryText = undefined;
},
},
});

export const { startSearch, clearSearch } = searchSlice.actions;
export const { startSearch, clearSearch, startTextSearch, clearTextSearch } =
searchSlice.actions;
export default searchSlice.reducer;