feat: Enhance Search component with date taken filters and tag management

This commit adds new date taken filters to the Search component, allowing users to specify a date range for photos based on when they were taken. Additionally, the tagging functionality is improved with options to filter by tags during searches, including a match mode for tag selection. The UI is updated to accommodate these features, enhancing user experience and search capabilities. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-12-11 13:39:36 -05:00
parent bca01a5ac3
commit a8fd0568c9
3 changed files with 537 additions and 54 deletions

View File

@ -46,6 +46,8 @@ export default function Search() {
const [matchAll, setMatchAll] = useState(false)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [dateTakenFrom, setDateTakenFrom] = useState('')
const [dateTakenTo, setDateTakenTo] = useState('')
const [mediaType, setMediaType] = useState<string>('all') // Default to 'all'
// Person autocomplete
@ -136,6 +138,8 @@ export default function Search() {
if (state.matchAll !== undefined) setMatchAll(state.matchAll)
if (state.dateFrom) setDateFrom(state.dateFrom)
if (state.dateTo) setDateTo(state.dateTo)
if (state.dateTakenFrom) setDateTakenFrom(state.dateTakenFrom)
if (state.dateTakenTo) setDateTakenTo(state.dateTakenTo)
if (state.mediaType) setMediaType(state.mediaType)
if (state.selectedPeople) setSelectedPeople(state.selectedPeople)
if (state.inputValue) setInputValue(state.inputValue)
@ -195,6 +199,8 @@ export default function Search() {
matchAll,
dateFrom,
dateTo,
dateTakenFrom,
dateTakenTo,
mediaType,
selectedPeople,
inputValue,
@ -211,7 +217,7 @@ export default function Search() {
} catch (error) {
console.error('Error saving search state:', error)
}
}, [searchType, selectedTags, matchAll, dateFrom, dateTo, mediaType, selectedPeople, inputValue, tagsExpanded, filtersExpanded, configExpanded, sortColumn, sortDir, page, results, total])
}, [searchType, selectedTags, matchAll, dateFrom, dateTo, dateTakenFrom, dateTakenTo, mediaType, selectedPeople, inputValue, tagsExpanded, filtersExpanded, configExpanded, sortColumn, sortDir, page, results, total])
const performSearch = async (pageNum: number = page, showValidationErrors: boolean = true, searchTypeOverride?: SearchType) => {
setLoading(true)
@ -254,6 +260,11 @@ export default function Search() {
return
}
params.person_name = allNames.join(', ')
// Add tags filter if provided
if (selectedTags.length > 0) {
params.tag_names = selectedTags.join(', ')
params.match_all = matchAll
}
} else if (currentSearchType === 'date') {
if (!dateFrom && !dateTo) {
if (showValidationErrors) {
@ -264,7 +275,20 @@ export default function Search() {
}
params.date_from = dateFrom || undefined
params.date_to = dateTo || undefined
} else if (currentSearchType === 'tags') {
// Add tags filter if provided
if (selectedTags.length > 0) {
params.tag_names = selectedTags.join(', ')
params.match_all = matchAll
}
} else {
// For non-date search types, apply date taken filter if provided
if (dateTakenFrom || dateTakenTo) {
params.date_from = dateTakenFrom || undefined
params.date_to = dateTakenTo || undefined
}
}
if (currentSearchType === 'tags') {
if (selectedTags.length === 0) {
if (showValidationErrors) {
alert('Please select at least one tag to search for.')
@ -369,8 +393,8 @@ export default function Search() {
if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed' || searchType === 'favorites') {
handleSearch()
}
// Clear selected tags when switching away from tag search
if (searchType !== 'tags') {
// Clear selected tags when switching away from tag/name/date search types
if (searchType !== 'tags' && searchType !== 'name' && searchType !== 'date') {
setSelectedTags([])
}
// Clear person autocomplete when switching away from name search
@ -808,10 +832,28 @@ export default function Search() {
if (allNames.length > 0) {
baseParams.person_name = allNames.join(', ')
}
// Add tags filter if provided
if (selectedTags.length > 0) {
baseParams.tag_names = selectedTags.join(', ')
baseParams.match_all = matchAll
}
} else if (searchType === 'date') {
baseParams.date_from = dateFrom || undefined
baseParams.date_to = dateTo || undefined
} else if (searchType === 'tags') {
// Add tags filter if provided
if (selectedTags.length > 0) {
baseParams.tag_names = selectedTags.join(', ')
baseParams.match_all = matchAll
}
} else {
// For non-date search types, apply date taken filter if provided
if (dateTakenFrom || dateTakenTo) {
baseParams.date_from = dateTakenFrom || undefined
baseParams.date_to = dateTakenTo || undefined
}
}
if (searchType === 'tags') {
baseParams.tag_names = selectedTags.join(', ')
baseParams.match_all = matchAll
}
@ -994,34 +1036,301 @@ export default function Search() {
<p className="text-xs text-gray-500">
Select people from the dropdown or type names manually. Separate multiple names with commas.
</p>
{/* Tags filter for name search */}
<div className="mt-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium text-gray-700">Filter by tags (optional)</h2>
<button
onClick={() => setTagsExpanded(!tagsExpanded)}
className="text-lg text-gray-600 hover:text-gray-800"
title={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
aria-label={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
>
{tagsExpanded ? '▼' : '▶'}
</button>
</div>
{tagsExpanded && (
<div className="mt-3 space-y-2">
<div>
<label htmlFor="tag-search-input-name" className="block text-sm font-medium text-gray-700 mb-1">
Add tags
</label>
{/* Selected tags display */}
{selectedTags.length > 0 && (
<div className="mb-2">
<div className="flex flex-wrap gap-2 mb-2">
{selectedTags.map(tag => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"
>
{tag}
<button
onClick={() => {
setSelectedTags(selectedTags.filter(t => t !== tag))
}}
className="hover:text-blue-600"
type="button"
>
×
</button>
</span>
))}
</div>
<button
type="button"
onClick={() => {
setSelectedTags([])
}}
className="text-xs text-gray-600 hover:text-gray-800 underline"
title="Clear all selected tags"
>
Clear all
</button>
</div>
)}
{/* Tag input and dropdown */}
<div className="relative">
<input
id="tag-search-input-name"
ref={tagInputRef}
type="text"
value={tagSearchInput}
onChange={(e) => {
setTagSearchInput(e.target.value)
setShowTagDropdown(true)
}}
onFocus={() => setShowTagDropdown(true)}
onBlur={(e) => {
setTimeout(() => {
if (!tagDropdownRef.current?.contains(e.relatedTarget as Node)) {
setShowTagDropdown(false)
}
}, 200)
}}
placeholder="Type to search and select tags..."
className="w-full border rounded px-3 py-2 text-sm"
/>
{showTagDropdown && (
<div
ref={tagDropdownRef}
className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
onMouseDown={(e) => e.preventDefault()}
>
{(() => {
const filtered = availableTags.filter(tag =>
tag.tag_name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
!selectedTags.includes(tag.tag_name)
)
return filtered.length > 0 ? (
filtered.map(tag => (
<div
key={tag.id}
onClick={() => {
if (!selectedTags.includes(tag.tag_name)) {
setSelectedTags([...selectedTags, tag.tag_name])
}
setTagSearchInput('')
setTimeout(() => {
setShowTagDropdown(true)
tagInputRef.current?.focus()
}, 0)
}}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
>
{tag.tag_name}
</div>
))
) : (
<div className="px-3 py-2 text-gray-500 text-sm">
{tagSearchInput.trim() ? 'No matching tags found' : 'Start typing to search for tags'}
</div>
)
})()}
</div>
)}
</div>
</div>
<div>
<label htmlFor="match-mode-select-name" className="block text-sm font-medium text-gray-700 mb-1">
Match mode
</label>
<select
id="match-mode-select-name"
value={matchAll ? 'ALL' : 'ANY'}
onChange={(e) => setMatchAll(e.target.value === 'ALL')}
className="border rounded px-3 py-2"
>
<option value="ANY">Match any tag (photos with at least one selected tag)</option>
<option value="ALL">Match all tags (photos with all selected tags)</option>
</select>
</div>
</div>
)}
</div>
</div>
)}
{searchType === 'date' && (
<div className="space-y-2">
<div>
<label htmlFor="date-from-input" className="block text-sm font-medium text-gray-700 mb-1">
Start date
</label>
<input
id="date-from-input"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-48 border rounded px-3 py-2"
/>
<div className="space-y-4">
<div className="flex items-end gap-4">
<div>
<label htmlFor="date-from-input" className="block text-sm font-medium text-gray-700 mb-1">
Start date
</label>
<input
id="date-from-input"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-48 border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="date-to-input" className="block text-sm font-medium text-gray-700 mb-1">
End date <span className="text-gray-500 font-normal">(optional)</span>
</label>
<input
id="date-to-input"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-48 border rounded px-3 py-2"
/>
</div>
</div>
<div>
<label htmlFor="date-to-input" className="block text-sm font-medium text-gray-700 mb-1">
End date <span className="text-gray-500 font-normal">(optional)</span>
</label>
<input
id="date-to-input"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-48 border rounded px-3 py-2"
/>
{/* Tags filter for date search */}
<div className="w-1/2">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium text-gray-700">Filter by tags (optional)</h2>
<button
onClick={() => setTagsExpanded(!tagsExpanded)}
className="text-lg text-gray-600 hover:text-gray-800"
title={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
aria-label={tagsExpanded ? 'Collapse tags section' : 'Expand tags section'}
>
{tagsExpanded ? '▼' : '▶'}
</button>
</div>
{tagsExpanded && (
<div className="mt-3 space-y-2">
<div>
<label htmlFor="tag-search-input-date" className="block text-sm font-medium text-gray-700 mb-1">
Add tags
</label>
{/* Selected tags display */}
{selectedTags.length > 0 && (
<div className="mb-2">
<div className="flex flex-wrap gap-2 mb-2">
{selectedTags.map(tag => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"
>
{tag}
<button
onClick={() => {
setSelectedTags(selectedTags.filter(t => t !== tag))
}}
className="hover:text-blue-600"
type="button"
>
×
</button>
</span>
))}
</div>
<button
type="button"
onClick={() => {
setSelectedTags([])
}}
className="text-xs text-gray-600 hover:text-gray-800 underline"
title="Clear all selected tags"
>
Clear all
</button>
</div>
)}
{/* Tag input and dropdown */}
<div className="relative">
<input
id="tag-search-input-date"
ref={tagInputRef}
type="text"
value={tagSearchInput}
onChange={(e) => {
setTagSearchInput(e.target.value)
setShowTagDropdown(true)
}}
onFocus={() => setShowTagDropdown(true)}
onBlur={(e) => {
setTimeout(() => {
if (!tagDropdownRef.current?.contains(e.relatedTarget as Node)) {
setShowTagDropdown(false)
}
}, 200)
}}
placeholder="Type to search and select tags..."
className="w-full border rounded px-3 py-2 text-sm"
/>
{showTagDropdown && (
<div
ref={tagDropdownRef}
className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
onMouseDown={(e) => e.preventDefault()}
>
{(() => {
const filtered = availableTags.filter(tag =>
tag.tag_name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
!selectedTags.includes(tag.tag_name)
)
return filtered.length > 0 ? (
filtered.map(tag => (
<div
key={tag.id}
onClick={() => {
if (!selectedTags.includes(tag.tag_name)) {
setSelectedTags([...selectedTags, tag.tag_name])
}
setTagSearchInput('')
setTimeout(() => {
setShowTagDropdown(true)
tagInputRef.current?.focus()
}, 0)
}}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
>
{tag.tag_name}
</div>
))
) : (
<div className="px-3 py-2 text-gray-500 text-sm">
{tagSearchInput.trim() ? 'No matching tags found' : 'Start typing to search for tags'}
</div>
)
})()}
</div>
)}
</div>
</div>
<div>
<label htmlFor="match-mode-select-date" className="block text-sm font-medium text-gray-700 mb-1">
Match mode
</label>
<select
id="match-mode-select-date"
value={matchAll ? 'ALL' : 'ANY'}
onChange={(e) => setMatchAll(e.target.value === 'ALL')}
className="border rounded px-3 py-2"
>
<option value="ANY">Match any tag (photos with at least one selected tag)</option>
<option value="ALL">Match all tags (photos with all selected tags)</option>
</select>
</div>
</div>
)}
</div>
</div>
)}
@ -1179,20 +1488,68 @@ export default function Search() {
</div>
{filtersExpanded && (
<div className="mt-3 space-y-2">
<div>
<label htmlFor="media-type-select" className="block text-sm font-medium text-gray-700 mb-1">
Media type
</label>
<select
id="media-type-select"
value={mediaType}
onChange={(e) => setMediaType(e.target.value)}
className="w-48 border rounded px-3 py-2"
>
<option value="all">All media types</option>
<option value="image">Photos only</option>
<option value="video">Videos only</option>
</select>
<div className="flex items-end gap-4">
<div>
<label htmlFor="media-type-select" className="block text-sm font-medium text-gray-700 mb-1">
Media type
</label>
<select
id="media-type-select"
value={mediaType}
onChange={(e) => setMediaType(e.target.value)}
className="w-48 border rounded px-3 py-2"
>
<option value="all">All media types</option>
<option value="image">Photos only</option>
<option value="video">Videos only</option>
</select>
</div>
{searchType !== 'date' && (
<>
<div>
<label htmlFor="date-taken-from-input" className="block text-sm font-medium text-gray-700 mb-1">
Date Taken - From
</label>
<input
id="date-taken-from-input"
type="date"
value={dateTakenFrom}
onChange={(e) => setDateTakenFrom(e.target.value)}
className="w-48 border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="date-taken-to-input" className="block text-sm font-medium text-gray-700 mb-1">
Date Taken - To <span className="text-gray-500 font-normal">(optional)</span>
</label>
<input
id="date-taken-to-input"
type="date"
value={dateTakenTo}
onChange={(e) => setDateTakenTo(e.target.value)}
className="w-48 border rounded px-3 py-2"
/>
</div>
{(dateTakenFrom || dateTakenTo) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
&nbsp;
</label>
<button
type="button"
onClick={() => {
setDateTakenFrom('')
setDateTakenTo('')
}}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 border rounded hover:bg-gray-50"
title="Clear date filters"
>
Clear dates
</button>
</div>
)}
</>
)}
</div>
</div>
)}

View File

@ -99,6 +99,15 @@ def search_photos(
).first()
return favorite is not None
# Parse date filters for use as additional filters (when not using date search type)
df = date.fromisoformat(date_from) if date_from else None
dt = date.fromisoformat(date_to) if date_to else None
# Parse tag filters for use as additional filters
tag_list = None
if tag_names:
tag_list = [t.strip() for t in tag_names.split(",") if t.strip()]
if search_type == "name":
if not person_name:
raise HTTPException(
@ -106,7 +115,7 @@ def search_photos(
detail="person_name is required for name search",
)
results, total = search_photos_by_name(
db, person_name, folder_path, media_type, page, page_size
db, person_name, folder_path, media_type, df, dt, tag_list, match_all, page, page_size
)
for photo, full_name in results:
tags = get_photo_tags(db, photo.id)
@ -134,9 +143,7 @@ def search_photos(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one of date_from or date_to is required",
)
df = date.fromisoformat(date_from) if date_from else None
dt = date.fromisoformat(date_to) if date_to else None
results, total = search_photos_by_date(db, df, dt, folder_path, media_type, page, page_size)
results, total = search_photos_by_date(db, df, dt, folder_path, media_type, tag_list, match_all, page, page_size)
for photo in results:
tags = get_photo_tags(db, photo.id)
face_count = get_photo_face_count(db, photo.id)
@ -164,14 +171,13 @@ def search_photos(
status_code=status.HTTP_400_BAD_REQUEST,
detail="tag_names is required for tag search",
)
tag_list = [t.strip() for t in tag_names.split(",") if t.strip()]
if not tag_list:
if not tag_list or len(tag_list) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one tag name is required",
)
results, total = search_photos_by_tags(
db, tag_list, match_all, folder_path, media_type, page, page_size
db, tag_list, match_all, folder_path, media_type, df, dt, page, page_size
)
for photo in results:
tags = get_photo_tags(db, photo.id)
@ -195,7 +201,7 @@ def search_photos(
)
)
elif search_type == "no_faces":
results, total = get_photos_without_faces(db, folder_path, media_type, page, page_size)
results, total = get_photos_without_faces(db, folder_path, media_type, df, dt, page, page_size)
for photo in results:
tags = get_photo_tags(db, photo.id)
# Convert datetime to date for date_added
@ -216,7 +222,7 @@ def search_photos(
)
)
elif search_type == "no_tags":
results, total = get_photos_without_tags(db, folder_path, media_type, page, page_size)
results, total = get_photos_without_tags(db, folder_path, media_type, df, dt, page, page_size)
for photo in results:
face_count = get_photo_face_count(db, photo.id)
person_name_val = get_photo_person(db, photo.id)
@ -238,7 +244,7 @@ def search_photos(
)
)
elif search_type == "processed":
results, total = get_processed_photos(db, folder_path, media_type, page, page_size)
results, total = get_processed_photos(db, folder_path, media_type, df, dt, page, page_size)
for photo in results:
tags = get_photo_tags(db, photo.id)
face_count = get_photo_face_count(db, photo.id)
@ -261,7 +267,7 @@ def search_photos(
)
)
elif search_type == "unprocessed":
results, total = get_unprocessed_photos(db, folder_path, media_type, page, page_size)
results, total = get_unprocessed_photos(db, folder_path, media_type, df, dt, page, page_size)
for photo in results:
tags = get_photo_tags(db, photo.id)
face_count = get_photo_face_count(db, photo.id)
@ -289,7 +295,7 @@ def search_photos(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for favorites search",
)
results, total = get_favorite_photos(db, username, folder_path, media_type, page, page_size)
results, total = get_favorite_photos(db, username, folder_path, media_type, df, dt, page, page_size)
for photo in results:
tags = get_photo_tags(db, photo.id)
face_count = get_photo_face_count(db, photo.id)

View File

@ -16,6 +16,10 @@ def search_photos_by_name(
person_name: str,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
tag_names: Optional[List[str]] = None,
match_all: bool = False,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Tuple[Photo, str]], int]:
@ -81,6 +85,42 @@ def search_photos_by_name(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply date taken filter if provided
if date_from:
query = query.filter(Photo.date_taken >= date_from)
if date_to:
query = query.filter(Photo.date_taken <= date_to)
# Apply tag filter if provided
if tag_names:
# Find tag IDs (case-insensitive)
tag_ids = (
db.query(Tag.id)
.filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names]))
.all()
)
tag_ids = [tid[0] for tid in tag_ids]
if tag_ids:
if match_all:
# Photos that have ALL specified tags
query = (
query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
.group_by(Photo.id, Person.id)
.having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids))
)
else:
# Photos that have ANY of the specified tags
tagged_photo_ids_subquery = (
db.query(PhotoTagLinkage.photo_id)
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
)
query = query.filter(Photo.id.in_(tagged_photo_ids_subquery))
else:
# No matching tags found - return empty result
return [], 0
# Total count
total = query.count()
@ -102,6 +142,8 @@ def search_photos_by_date(
date_to: Optional[date] = None,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
tag_names: Optional[List[str]] = None,
match_all: bool = False,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
@ -129,6 +171,36 @@ def search_photos_by_date(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply tag filter if provided
if tag_names:
# Find tag IDs (case-insensitive)
tag_ids = (
db.query(Tag.id)
.filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names]))
.all()
)
tag_ids = [tid[0] for tid in tag_ids]
if tag_ids:
if match_all:
# Photos that have ALL specified tags
query = (
query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
.group_by(Photo.id)
.having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids))
)
else:
# Photos that have ANY of the specified tags
tagged_photo_ids_subquery = (
db.query(PhotoTagLinkage.photo_id)
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
)
query = query.filter(Photo.id.in_(tagged_photo_ids_subquery))
else:
# No matching tags found - return empty result
return [], 0
# Total count
total = query.count()
@ -144,6 +216,8 @@ def search_photos_by_tags(
match_all: bool = False,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
@ -196,6 +270,12 @@ def search_photos_by_tags(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply date taken filter if provided
if date_from:
query = query.filter(Photo.date_taken >= date_from)
if date_to:
query = query.filter(Photo.date_taken <= date_to)
# Total count
total = query.count()
@ -209,6 +289,8 @@ def get_photos_without_faces(
db: Session,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
@ -234,6 +316,12 @@ def get_photos_without_faces(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply date taken filter if provided
if date_from:
query = query.filter(Photo.date_taken >= date_from)
if date_to:
query = query.filter(Photo.date_taken <= date_to)
# Total count
total = query.count()
@ -247,6 +335,8 @@ def get_photos_without_tags(
db: Session,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
@ -270,6 +360,12 @@ def get_photos_without_tags(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply date taken filter if provided
if date_from:
query = query.filter(Photo.date_taken >= date_from)
if date_to:
query = query.filter(Photo.date_taken <= date_to)
# Total count
total = query.count()
@ -312,6 +408,8 @@ def get_processed_photos(
db: Session,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
@ -331,6 +429,12 @@ def get_processed_photos(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply date taken filter if provided
if date_from:
query = query.filter(Photo.date_taken >= date_from)
if date_to:
query = query.filter(Photo.date_taken <= date_to)
# Total count
total = query.count()
@ -345,6 +449,8 @@ def get_favorite_photos(
username: str,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
@ -368,6 +474,12 @@ def get_favorite_photos(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply date taken filter if provided
if date_from:
query = query.filter(Photo.date_taken >= date_from)
if date_to:
query = query.filter(Photo.date_taken <= date_to)
total = query.count()
# Order by favorite date (most recent first), then date_taken
@ -387,6 +499,8 @@ def get_unprocessed_photos(
db: Session,
folder_path: Optional[str] = None,
media_type: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
@ -406,6 +520,12 @@ def get_unprocessed_photos(
if media_type and media_type.lower() != "all":
query = query.filter(Photo.media_type == media_type.lower())
# Apply date taken filter if provided
if date_from:
query = query.filter(Photo.date_taken >= date_from)
if date_to:
query = query.filter(Photo.date_taken <= date_to)
# Total count
total = query.count()