feat: Implement photo search functionality with filtering and tagging support
This commit introduces a comprehensive photo search feature in the application, allowing users to search photos by various criteria including name, date, and tags. The API has been updated to support these search parameters, returning results that match the specified filters. Additionally, new endpoints for managing tags have been added, enabling users to add and remove tags from multiple photos. The frontend has been enhanced with a user-friendly interface for search inputs and results display, improving overall usability. Documentation and tests have been updated to reflect these new features and ensure reliability.
This commit is contained in:
parent
59dc01118e
commit
c0f9d19368
@ -1,5 +1,4 @@
|
||||
import apiClient from './client'
|
||||
import { JobResponse } from './jobs'
|
||||
|
||||
export interface ProcessFacesRequest {
|
||||
batch_size?: number
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import apiClient from './client'
|
||||
import { JobResponse } from './jobs'
|
||||
|
||||
export interface PhotoImportRequest {
|
||||
folder_path: string
|
||||
@ -72,5 +71,42 @@ export const photosApi = {
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
|
||||
},
|
||||
|
||||
searchPhotos: async (params: {
|
||||
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags'
|
||||
person_name?: string
|
||||
tag_names?: string
|
||||
match_all?: boolean
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
folder_path?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<SearchPhotosResponse> => {
|
||||
const { data } = await apiClient.get<SearchPhotosResponse>('/api/v1/photos', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export interface PhotoSearchResult {
|
||||
id: number
|
||||
path: string
|
||||
filename: string
|
||||
date_taken?: string
|
||||
date_added: string
|
||||
processed: boolean
|
||||
person_name?: string
|
||||
tags: string[]
|
||||
has_faces: boolean
|
||||
face_count: number
|
||||
}
|
||||
|
||||
export interface SearchPhotosResponse {
|
||||
items: PhotoSearchResult[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
|
||||
57
frontend/src/api/tags.ts
Normal file
57
frontend/src/api/tags.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface TagResponse {
|
||||
id: number
|
||||
tag_name: string
|
||||
created_date: string
|
||||
}
|
||||
|
||||
export interface TagsResponse {
|
||||
items: TagResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PhotoTagsRequest {
|
||||
photo_ids: number[]
|
||||
tag_names: string[]
|
||||
}
|
||||
|
||||
export interface PhotoTagsResponse {
|
||||
message: string
|
||||
photos_updated: number
|
||||
tags_added: number
|
||||
tags_removed: number
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: async (): Promise<TagsResponse> => {
|
||||
const { data } = await apiClient.get<TagsResponse>('/api/v1/tags')
|
||||
return data
|
||||
},
|
||||
|
||||
create: async (tagName: string): Promise<TagResponse> => {
|
||||
const { data } = await apiClient.post<TagResponse>('/api/v1/tags', {
|
||||
tag_name: tagName,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
addToPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
|
||||
const { data } = await apiClient.post<PhotoTagsResponse>(
|
||||
'/api/v1/tags/photos/add',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
removeFromPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
|
||||
const { data } = await apiClient.post<PhotoTagsResponse>(
|
||||
'/api/v1/tags/photos/remove',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default tagsApi
|
||||
|
||||
@ -8,8 +8,7 @@ type SortDir = 'asc' | 'desc'
|
||||
|
||||
export default function Identify() {
|
||||
const [faces, setFaces] = useState<FaceItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [, setTotal] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
const [minQuality, setMinQuality] = useState(0.0)
|
||||
const [sortBy, setSortBy] = useState<SortBy>('quality')
|
||||
@ -33,6 +32,7 @@ export default function Identify() {
|
||||
const [maidenName, setMaidenName] = useState('')
|
||||
const [dob, setDob] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
@ -53,7 +53,7 @@ export default function Identify() {
|
||||
|
||||
const loadFaces = async () => {
|
||||
const res = await facesApi.getUnidentified({
|
||||
page,
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
min_quality: minQuality,
|
||||
date_from: dateFrom || undefined,
|
||||
@ -99,7 +99,7 @@ export default function Identify() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking similar faces for face ${face.id}:`, error)
|
||||
// Silently skip faces with errors
|
||||
}
|
||||
|
||||
similarityMap.set(face.id, similarSet)
|
||||
@ -167,14 +167,9 @@ export default function Identify() {
|
||||
}
|
||||
try {
|
||||
const res = await facesApi.getSimilar(faceId)
|
||||
console.log('Similar faces response:', res)
|
||||
console.log('Similar faces items:', res.items)
|
||||
console.log('Similar faces count:', res.items?.length || 0)
|
||||
setSimilar(res.items || [])
|
||||
setSelectedSimilar({})
|
||||
} catch (error) {
|
||||
console.error('Error loading similar faces:', error)
|
||||
console.error('Error details:', error instanceof Error ? error.message : String(error))
|
||||
setSimilar([])
|
||||
}
|
||||
}
|
||||
@ -183,13 +178,49 @@ export default function Identify() {
|
||||
loadFaces()
|
||||
loadPeople()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly])
|
||||
}, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFace) loadSimilar(currentFace.id)
|
||||
if (currentFace) {
|
||||
setImageLoading(true) // Show loading indicator when face changes
|
||||
loadSimilar(currentFace.id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentFace?.id, compareEnabled])
|
||||
|
||||
// Preload images for next/previous faces
|
||||
useEffect(() => {
|
||||
if (!currentFace || faces.length === 0) return
|
||||
|
||||
const preloadImages = () => {
|
||||
const preloadUrls: string[] = []
|
||||
const baseUrl = apiClient.defaults.baseURL || 'http://127.0.0.1:8000'
|
||||
|
||||
// Preload next face
|
||||
if (currentIdx + 1 < faces.length) {
|
||||
const nextFace = faces[currentIdx + 1]
|
||||
preloadUrls.push(`${baseUrl}/api/v1/faces/${nextFace.id}/crop`)
|
||||
}
|
||||
|
||||
// Preload previous face
|
||||
if (currentIdx > 0) {
|
||||
const prevFace = faces[currentIdx - 1]
|
||||
preloadUrls.push(`${baseUrl}/api/v1/faces/${prevFace.id}/crop`)
|
||||
}
|
||||
|
||||
// Preload images in background
|
||||
preloadUrls.forEach(url => {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// Small delay to avoid blocking current image load
|
||||
const timer = setTimeout(preloadImages, 100)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentIdx, faces])
|
||||
|
||||
// Save form data whenever fields change (for current face)
|
||||
useEffect(() => {
|
||||
if (!currentFace) return
|
||||
@ -392,22 +423,31 @@ export default function Identify() {
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
className="aspect-video bg-gray-100 rounded mb-3 overflow-hidden flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className="aspect-video bg-gray-100 rounded mb-3 overflow-hidden flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity relative"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentFace.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100 z-10">
|
||||
<div className="text-gray-400 text-sm">Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
key={currentFace.id}
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentFace.id}/crop?t=${Date.now()}`}
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentFace.id}/crop`}
|
||||
alt={`Face ${currentFace.id}`}
|
||||
className="max-w-full max-h-full object-contain pointer-events-none"
|
||||
crossOrigin="anonymous"
|
||||
loading="eager"
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onLoadStart={() => setImageLoading(true)}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
setImageLoading(false)
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
@ -589,10 +629,11 @@ export default function Identify() {
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop?t=${Date.now()}`}
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop`}
|
||||
alt={`Face ${s.id}`}
|
||||
className="max-w-full max-h-full object-contain pointer-events-none"
|
||||
crossOrigin="anonymous"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { facesApi, ProcessFacesRequest } from '../api/faces'
|
||||
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
|
||||
|
||||
|
||||
@ -1,11 +1,617 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { photosApi, PhotoSearchResult } from '../api/photos'
|
||||
import tagsApi, { TagResponse } from '../api/tags'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags'
|
||||
|
||||
const SEARCH_TYPES: { value: SearchType; label: string }[] = [
|
||||
{ value: 'name', label: 'Search photos by name' },
|
||||
{ value: 'date', label: 'Search photos by date' },
|
||||
{ value: 'tags', label: 'Search photos by tags' },
|
||||
{ value: 'no_faces', label: 'Photos without faces' },
|
||||
{ value: 'no_tags', label: 'Photos without tags' },
|
||||
]
|
||||
|
||||
type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
export default function Search() {
|
||||
const [searchType, setSearchType] = useState<SearchType>('name')
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false)
|
||||
const [folderPath, setFolderPath] = useState('')
|
||||
|
||||
// Search inputs
|
||||
const [personName, setPersonName] = useState('')
|
||||
const [tagNames, setTagNames] = useState('')
|
||||
const [matchAll, setMatchAll] = useState(false)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
// Results
|
||||
const [results, setResults] = useState<PhotoSearchResult[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(50)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Selection
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set())
|
||||
|
||||
// Sorting
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
// Tags
|
||||
const [availableTags, setAvailableTags] = useState<TagResponse[]>([])
|
||||
const [showTagHelp, setShowTagHelp] = useState(false)
|
||||
|
||||
// Tag modal
|
||||
const [showTagModal, setShowTagModal] = useState(false)
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const res = await tagsApi.list()
|
||||
setAvailableTags(res.items)
|
||||
} catch (error) {
|
||||
console.error('Error loading tags:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTags()
|
||||
}, [])
|
||||
|
||||
const performSearch = async (pageNum: number = page) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = {
|
||||
search_type: searchType,
|
||||
folder_path: folderPath || undefined,
|
||||
page: pageNum,
|
||||
page_size: pageSize,
|
||||
}
|
||||
|
||||
if (searchType === 'name') {
|
||||
if (!personName.trim()) {
|
||||
alert('Please enter a name to search.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
params.person_name = personName.trim()
|
||||
} else if (searchType === 'date') {
|
||||
if (!dateFrom && !dateTo) {
|
||||
alert('Please enter at least one date (from date or to date).')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
params.date_from = dateFrom || undefined
|
||||
params.date_to = dateTo || undefined
|
||||
} else if (searchType === 'tags') {
|
||||
if (!tagNames.trim()) {
|
||||
alert('Please enter tags to search for.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
params.tag_names = tagNames.trim()
|
||||
params.match_all = matchAll
|
||||
}
|
||||
|
||||
const res = await photosApi.searchPhotos(params)
|
||||
setResults(res.items)
|
||||
setTotal(res.total)
|
||||
|
||||
// Auto-run search for no_faces and no_tags
|
||||
if (searchType === 'no_faces' || searchType === 'no_tags') {
|
||||
if (res.items.length === 0) {
|
||||
const folderMsg = folderPath ? ` in folder '${folderPath}'` : ''
|
||||
alert(`No photos found${folderMsg}.`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching photos:', error)
|
||||
alert('Error searching photos. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
setSelectedPhotos(new Set())
|
||||
performSearch(1)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchType === 'no_faces' || searchType === 'no_tags') {
|
||||
handleSearch()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchType])
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedResults = [...results].sort((a, b) => {
|
||||
if (!sortColumn) return 0
|
||||
|
||||
let aVal: any
|
||||
let bVal: any
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'person':
|
||||
aVal = a.person_name || ''
|
||||
bVal = b.person_name || ''
|
||||
break
|
||||
case 'tags':
|
||||
aVal = a.tags.join(', ')
|
||||
bVal = b.tags.join(', ')
|
||||
break
|
||||
case 'processed':
|
||||
aVal = a.processed ? 'Yes' : 'No'
|
||||
bVal = b.processed ? 'Yes' : 'No'
|
||||
break
|
||||
case 'path':
|
||||
aVal = a.path
|
||||
bVal = b.path
|
||||
break
|
||||
case 'date_taken':
|
||||
aVal = a.date_taken || '9999-12-31'
|
||||
bVal = b.date_taken || '9999-12-31'
|
||||
break
|
||||
}
|
||||
|
||||
if (typeof aVal === 'string') {
|
||||
aVal = aVal.toLowerCase()
|
||||
bVal = bVal.toLowerCase()
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
|
||||
const toggleSelection = (photoId: number) => {
|
||||
setSelectedPhotos(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(photoId)) {
|
||||
newSet.delete(photoId)
|
||||
} else {
|
||||
newSet.add(photoId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllSelected = () => {
|
||||
setSelectedPhotos(new Set())
|
||||
}
|
||||
|
||||
const handleTagSelected = async () => {
|
||||
if (selectedPhotos.size === 0) {
|
||||
alert('Please select photos to tag.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!tagInput.trim()) {
|
||||
alert('Please enter tags to add.')
|
||||
return
|
||||
}
|
||||
|
||||
const tags = tagInput.split(',').map(t => t.trim()).filter(t => t)
|
||||
if (tags.length === 0) {
|
||||
alert('Please enter valid tags.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await tagsApi.addToPhotos({
|
||||
photo_ids: Array.from(selectedPhotos),
|
||||
tag_names: tags,
|
||||
})
|
||||
alert(`Added tags to ${selectedPhotos.size} photos.`)
|
||||
setShowTagModal(false)
|
||||
setTagInput('')
|
||||
// Refresh search results
|
||||
performSearch(page)
|
||||
} catch (error) {
|
||||
console.error('Error tagging photos:', error)
|
||||
alert('Error tagging photos. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const openPhoto = (photoId: number) => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}
|
||||
|
||||
const openFolder = (path: string) => {
|
||||
// Extract folder path from file path
|
||||
const folder = path.substring(0, path.lastIndexOf('/'))
|
||||
alert(`Open folder: ${folder}\n\nNote: Folder opening not implemented in web version.`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Search</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Search functionality coming in Phase 3.</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">🔎 Search Photos</h1>
|
||||
|
||||
{/* Search Type Selector */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Search type:
|
||||
</label>
|
||||
<select
|
||||
value={searchType}
|
||||
onChange={(e) => setSearchType(e.target.value as SearchType)}
|
||||
className="block w-full border rounded px-3 py-2"
|
||||
>
|
||||
{SEARCH_TYPES.map(type => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-medium text-gray-700">Filters</h2>
|
||||
<button
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
{filtersExpanded ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
{filtersExpanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Folder location:</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder="(optional - filter by folder path)"
|
||||
className="flex-1 border rounded px-3 py-2"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFolderPath('')}
|
||||
className="px-3 py-2 border rounded hover:bg-gray-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Inputs */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
{searchType === 'name' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Person name:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={personName}
|
||||
onChange={(e) => setPersonName(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Enter person name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchType === 'date' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
From date:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
To date:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
placeholder="YYYY-MM-DD (optional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchType === 'tags' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tags:
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagNames}
|
||||
onChange={(e) => setTagNames(e.target.value)}
|
||||
className="flex-1 border rounded px-3 py-2"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Comma-separated tags"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowTagHelp(!showTagHelp)}
|
||||
className="text-lg"
|
||||
title="Show available tags"
|
||||
>
|
||||
❓
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">(comma-separated)</span>
|
||||
</div>
|
||||
{showTagHelp && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded border max-h-40 overflow-y-auto">
|
||||
<div className="text-sm font-medium mb-2">Available tags:</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
{availableTags.map(tag => (
|
||||
<div key={tag.id}>{tag.tag_name}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Match mode:
|
||||
</label>
|
||||
<select
|
||||
value={matchAll ? 'ALL' : 'ANY'}
|
||||
onChange={(e) => setMatchAll(e.target.value === 'ALL')}
|
||||
className="border rounded px-3 py-2"
|
||||
>
|
||||
<option value="ANY">ANY (photos with any tag)</option>
|
||||
<option value="ALL">ALL (photos with all tags)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchType === 'no_faces' || searchType === 'no_tags') && (
|
||||
<p className="text-sm text-gray-600">No input needed for this search type.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Results:</span>
|
||||
<span className="text-sm text-gray-500 ml-2">({total} items)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowTagModal(true)}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Tag selected photos
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAllSelected}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Clear all selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 w-12">☑</th>
|
||||
{searchType === 'name' && (
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('person')}
|
||||
>
|
||||
Person {sortColumn === 'person' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('tags')}
|
||||
>
|
||||
Tags {sortColumn === 'tags' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
{searchType !== 'name' && (
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('processed')}
|
||||
>
|
||||
Processed {sortColumn === 'processed' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left p-2 w-12">📁</th>
|
||||
{searchType !== 'no_faces' && (
|
||||
<th className="text-left p-2 w-12">👤</th>
|
||||
)}
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('path')}
|
||||
>
|
||||
Photo path {sortColumn === 'path' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('date_taken')}
|
||||
>
|
||||
Date Taken {sortColumn === 'date_taken' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedResults.map(photo => (
|
||||
<tr key={photo.id} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPhotos.has(photo.id)}
|
||||
onChange={() => toggleSelection(photo.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
{searchType === 'name' && (
|
||||
<td className="p-2">{photo.person_name || ''}</td>
|
||||
)}
|
||||
<td className="p-2" title={photo.tags.join(', ')}>
|
||||
{photo.tags.length > 0 ? photo.tags.join(', ') : 'No tags'}
|
||||
</td>
|
||||
{searchType !== 'name' && (
|
||||
<td className="p-2 text-center">{photo.processed ? 'Yes' : 'No'}</td>
|
||||
)}
|
||||
<td className="p-2">
|
||||
<button
|
||||
onClick={() => openFolder(photo.path)}
|
||||
className="cursor-pointer hover:text-blue-600"
|
||||
title="Open file location"
|
||||
>
|
||||
📁
|
||||
</button>
|
||||
</td>
|
||||
{searchType !== 'no_faces' && (
|
||||
<td className="p-2">
|
||||
{photo.has_faces && (
|
||||
<span title={`${photo.face_count} face(s)`}>👤</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-2">
|
||||
<button
|
||||
onClick={() => openPhoto(photo.id)}
|
||||
className="text-blue-600 hover:underline cursor-pointer"
|
||||
title="Open photo"
|
||||
>
|
||||
{photo.path}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-2">{photo.date_taken || 'No date'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > pageSize && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {page} of {Math.ceil(total / pageSize)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newPage = Math.max(1, page - 1)
|
||||
setPage(newPage)
|
||||
performSearch(newPage)
|
||||
}}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newPage = Math.min(Math.ceil(total / pageSize), page + 1)
|
||||
setPage(newPage)
|
||||
performSearch(newPage)
|
||||
}}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Modal */}
|
||||
{showTagModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Tag Selected Photos</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tags (comma-separated):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
placeholder="Enter tags"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleTagSelected()
|
||||
if (e.key === 'Escape') setShowTagModal(false)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{selectedPhotos.size} photo(s) selected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTagModal(false)
|
||||
setTagInput('')
|
||||
}}
|
||||
className="px-4 py-2 border rounded hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTagSelected}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Add Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from rq import Queue
|
||||
from redis import Redis
|
||||
@ -18,19 +21,194 @@ from src.web.schemas.photos import (
|
||||
PhotoImportResponse,
|
||||
PhotoResponse,
|
||||
)
|
||||
from src.web.schemas.search import (
|
||||
PhotoSearchResult,
|
||||
SearchPhotosResponse,
|
||||
)
|
||||
from src.web.services.photo_service import (
|
||||
find_photos_in_folder,
|
||||
import_photo_from_path,
|
||||
)
|
||||
from src.web.services.search_service import (
|
||||
get_photo_face_count,
|
||||
get_photo_person,
|
||||
get_photo_tags,
|
||||
get_photos_without_faces,
|
||||
get_photos_without_tags,
|
||||
search_photos_by_date,
|
||||
search_photos_by_name,
|
||||
search_photos_by_tags,
|
||||
)
|
||||
# Note: Function passed as string path to avoid RQ serialization issues
|
||||
|
||||
router = APIRouter(prefix="/photos", tags=["photos"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_photos() -> dict:
|
||||
"""List photos - placeholder for Phase 2."""
|
||||
return {"message": "Photos endpoint - to be implemented in Phase 2"}
|
||||
@router.get("", response_model=SearchPhotosResponse)
|
||||
def search_photos(
|
||||
search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags"),
|
||||
person_name: Optional[str] = Query(None, description="Person name for name search"),
|
||||
tag_names: Optional[str] = Query(None, description="Comma-separated tag names for tag search"),
|
||||
match_all: bool = Query(False, description="Match all tags (for tag search)"),
|
||||
date_from: Optional[str] = Query(None, description="Date from (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Date to (YYYY-MM-DD)"),
|
||||
folder_path: Optional[str] = Query(None, description="Filter by folder path"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SearchPhotosResponse:
|
||||
"""Search photos with filters.
|
||||
|
||||
Matches desktop search functionality exactly:
|
||||
- Search by name: person_name required
|
||||
- Search by date: date_from or date_to required
|
||||
- Search by tags: tag_names required (comma-separated)
|
||||
- Search no faces: returns photos without faces
|
||||
- Search no tags: returns photos without tags
|
||||
"""
|
||||
items: List[PhotoSearchResult] = []
|
||||
total = 0
|
||||
|
||||
if search_type == "name":
|
||||
if not person_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="person_name is required for name search",
|
||||
)
|
||||
results, total = search_photos_by_name(
|
||||
db, person_name, folder_path, page, page_size
|
||||
)
|
||||
for photo, full_name in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=full_name,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
)
|
||||
)
|
||||
elif search_type == "date":
|
||||
if not date_from and not date_to:
|
||||
raise HTTPException(
|
||||
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, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
)
|
||||
)
|
||||
elif search_type == "tags":
|
||||
if not tag_names:
|
||||
raise HTTPException(
|
||||
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:
|
||||
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, page, page_size
|
||||
)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
)
|
||||
)
|
||||
elif search_type == "no_faces":
|
||||
results, total = get_photos_without_faces(db, folder_path, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=None,
|
||||
tags=tags,
|
||||
has_faces=False,
|
||||
face_count=0,
|
||||
)
|
||||
)
|
||||
elif search_type == "no_tags":
|
||||
results, total = get_photos_without_tags(db, folder_path, 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)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=[],
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid search_type: {search_type}",
|
||||
)
|
||||
|
||||
return SearchPhotosResponse(items=items, page=page, page_size=page_size, total=total)
|
||||
|
||||
|
||||
@router.post("/import", response_model=PhotoImportResponse)
|
||||
|
||||
@ -2,28 +2,96 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_db
|
||||
from src.web.schemas.tags import (
|
||||
PhotoTagsRequest,
|
||||
PhotoTagsResponse,
|
||||
TagCreateRequest,
|
||||
TagResponse,
|
||||
TagsResponse,
|
||||
)
|
||||
from src.web.services.tag_service import (
|
||||
add_tags_to_photos,
|
||||
get_or_create_tag,
|
||||
list_tags,
|
||||
remove_tags_from_photos,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tags() -> dict:
|
||||
"""List tags - placeholder for Phase 3."""
|
||||
return {"message": "Tags endpoint - to be implemented in Phase 3"}
|
||||
@router.get("", response_model=TagsResponse)
|
||||
def get_tags(db: Session = Depends(get_db)) -> TagsResponse:
|
||||
"""List all tags."""
|
||||
tags = list_tags(db)
|
||||
items = [TagResponse.model_validate(t) for t in tags]
|
||||
return TagsResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_tag() -> dict:
|
||||
"""Create tag - placeholder for Phase 3."""
|
||||
return {"message": "Create tag endpoint - to be implemented in Phase 3"}
|
||||
@router.post("", response_model=TagResponse)
|
||||
def create_tag(
|
||||
request: TagCreateRequest, db: Session = Depends(get_db)
|
||||
) -> TagResponse:
|
||||
"""Create a new tag (or return existing if already exists)."""
|
||||
tag = get_or_create_tag(db, request.tag_name)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.post("/photos/{photo_id}/tags")
|
||||
def add_tags_to_photo(photo_id: int) -> dict:
|
||||
"""Add tags to photo - placeholder for Phase 3."""
|
||||
return {
|
||||
"message": f"Add tags to photo {photo_id} - to be implemented in Phase 3",
|
||||
"id": photo_id,
|
||||
}
|
||||
@router.post("/photos/add", response_model=PhotoTagsResponse)
|
||||
def add_tags_to_photos_endpoint(
|
||||
request: PhotoTagsRequest, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsResponse:
|
||||
"""Add tags to multiple photos."""
|
||||
if not request.photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
|
||||
)
|
||||
if not request.tag_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
|
||||
)
|
||||
|
||||
photos_updated, tags_added = add_tags_to_photos(
|
||||
db, request.photo_ids, request.tag_names
|
||||
)
|
||||
|
||||
return PhotoTagsResponse(
|
||||
message=f"Added tags to {photos_updated} photos",
|
||||
photos_updated=photos_updated,
|
||||
tags_added=tags_added,
|
||||
tags_removed=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/photos/remove", response_model=PhotoTagsResponse)
|
||||
def remove_tags_from_photos_endpoint(
|
||||
request: PhotoTagsRequest, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsResponse:
|
||||
"""Remove tags from multiple photos."""
|
||||
if not request.photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
|
||||
)
|
||||
if not request.tag_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
|
||||
)
|
||||
|
||||
photos_updated, tags_removed = remove_tags_from_photos(
|
||||
db, request.photo_ids, request.tag_names
|
||||
)
|
||||
|
||||
return PhotoTagsResponse(
|
||||
message=f"Removed tags from {photos_updated} photos",
|
||||
photos_updated=photos_updated,
|
||||
tags_added=0,
|
||||
tags_removed=tags_removed,
|
||||
)
|
||||
|
||||
|
||||
48
src/web/schemas/search.py
Normal file
48
src/web/schemas/search.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Search schemas for Phase 5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchPhotosQuery(BaseModel):
|
||||
"""Query parameters for searching photos."""
|
||||
|
||||
person_ids: Optional[List[int]] = Field(None, description="Filter by person IDs")
|
||||
tag_ids: Optional[List[int]] = Field(None, description="Filter by tag IDs")
|
||||
date_from: Optional[date] = Field(None, description="Filter by date taken (from)")
|
||||
date_to: Optional[date] = Field(None, description="Filter by date taken (to)")
|
||||
min_quality: Optional[float] = Field(None, ge=0.0, le=1.0, description="Minimum face quality score")
|
||||
folder_path: Optional[str] = Field(None, description="Filter by folder path prefix")
|
||||
sort_by: str = Field("date_taken", description="Sort column: date_taken, date_added, filename, path")
|
||||
sort_dir: str = Field("desc", description="Sort direction: asc|desc")
|
||||
page: int = Field(1, ge=1, description="Page number")
|
||||
page_size: int = Field(50, ge=1, le=200, description="Page size")
|
||||
|
||||
|
||||
class PhotoSearchResult(BaseModel):
|
||||
"""Photo search result item."""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
filename: str
|
||||
date_taken: Optional[date] = None
|
||||
date_added: date
|
||||
processed: bool
|
||||
person_name: Optional[str] = None # For name search
|
||||
tags: List[str] = Field(default_factory=list) # All tags for the photo
|
||||
has_faces: bool = False
|
||||
face_count: int = 0
|
||||
|
||||
|
||||
class SearchPhotosResponse(BaseModel):
|
||||
"""Response for photo search."""
|
||||
|
||||
items: List[PhotoSearchResult]
|
||||
page: int
|
||||
page_size: int
|
||||
total: int
|
||||
|
||||
49
src/web/schemas/tags.py
Normal file
49
src/web/schemas/tags.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Tag schemas for Phase 5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
"""Tag response schema."""
|
||||
|
||||
id: int
|
||||
tag_name: str
|
||||
created_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TagCreateRequest(BaseModel):
|
||||
"""Request to create a tag."""
|
||||
|
||||
tag_name: str = Field(..., description="Tag name")
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
"""Response for listing tags."""
|
||||
|
||||
items: List[TagResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PhotoTagsRequest(BaseModel):
|
||||
"""Request to add/remove tags from photos."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="Photo IDs")
|
||||
tag_names: List[str] = Field(..., description="Tag names to add/remove")
|
||||
|
||||
|
||||
class PhotoTagsResponse(BaseModel):
|
||||
"""Response for photo tagging operations."""
|
||||
|
||||
message: str
|
||||
photos_updated: int
|
||||
tags_added: int
|
||||
tags_removed: int
|
||||
|
||||
264
src/web/services/search_service.py
Normal file
264
src/web/services/search_service.py
Normal file
@ -0,0 +1,264 @@
|
||||
"""Search service for photos - matches desktop search functionality exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.models import Face, Photo, Person, PhotoTagLinkage, Tag
|
||||
|
||||
|
||||
def search_photos_by_name(
|
||||
db: Session,
|
||||
person_name: str,
|
||||
folder_path: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Tuple[Photo, str]], int]:
|
||||
"""Search photos by person name (partial, case-insensitive).
|
||||
|
||||
Matches desktop behavior exactly:
|
||||
- Searches first_name, last_name, middle_name, maiden_name
|
||||
- Returns (photo, full_name) tuples
|
||||
- Filters by folder_path if provided
|
||||
"""
|
||||
search_name = (person_name or "").strip().lower()
|
||||
if not search_name:
|
||||
return [], 0
|
||||
|
||||
# Find matching people
|
||||
matching_people = (
|
||||
db.query(Person)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(Person.first_name).contains(search_name),
|
||||
func.lower(Person.last_name).contains(search_name),
|
||||
func.lower(Person.middle_name).contains(search_name),
|
||||
func.lower(Person.maiden_name).contains(search_name),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not matching_people:
|
||||
return [], 0
|
||||
|
||||
person_ids = [p.id for p in matching_people]
|
||||
|
||||
# Query photos with faces linked to matching people
|
||||
query = (
|
||||
db.query(Photo, Person)
|
||||
.join(Face, Photo.id == Face.photo_id)
|
||||
.join(Person, Face.person_id == Person.id)
|
||||
.filter(Face.person_id.in_(person_ids))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination
|
||||
results = query.order_by(Person.last_name, Person.first_name, Photo.path).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# Format results: (photo, full_name)
|
||||
formatted = []
|
||||
for photo, person in results:
|
||||
full_name = f"{person.first_name or ''} {person.last_name or ''}".strip() or "Unknown"
|
||||
formatted.append((photo, full_name))
|
||||
|
||||
return formatted, total
|
||||
|
||||
|
||||
def search_photos_by_date(
|
||||
db: Session,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
folder_path: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Search photos by date range.
|
||||
|
||||
Matches desktop behavior exactly:
|
||||
- Filters by date_taken
|
||||
- Requires at least one date
|
||||
- Returns photos ordered by date_taken DESC
|
||||
"""
|
||||
query = db.query(Photo).filter(Photo.date_taken.is_not(None))
|
||||
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.date_taken.desc().nullslast(), Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def search_photos_by_tags(
|
||||
db: Session,
|
||||
tag_names: List[str],
|
||||
match_all: bool = False,
|
||||
folder_path: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Search photos by tags.
|
||||
|
||||
Matches desktop behavior exactly:
|
||||
- match_all=True: photos must have ALL tags
|
||||
- match_all=False: photos with ANY tag
|
||||
- Case-insensitive tag matching
|
||||
"""
|
||||
if not tag_names:
|
||||
return [], 0
|
||||
|
||||
# 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 not tag_ids:
|
||||
return [], 0
|
||||
|
||||
if match_all:
|
||||
# Photos that have ALL specified tags
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.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
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.path).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_photos_without_faces(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have no detected faces.
|
||||
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.outerjoin(Face, Photo.id == Face.photo_id)
|
||||
.filter(Face.photo_id.is_(None))
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_photos_without_tags(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have no tags.
|
||||
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.outerjoin(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.photo_id.is_(None))
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_photo_tags(db: Session, photo_id: int) -> List[str]:
|
||||
"""Get all tags for a photo."""
|
||||
tags = (
|
||||
db.query(Tag.tag_name)
|
||||
.join(PhotoTagLinkage, Tag.id == PhotoTagLinkage.tag_id)
|
||||
.filter(PhotoTagLinkage.photo_id == photo_id)
|
||||
.all()
|
||||
)
|
||||
return [t[0] for t in tags]
|
||||
|
||||
|
||||
def get_photo_person(db: Session, photo_id: int) -> Optional[str]:
|
||||
"""Get person name for a photo (first face found)."""
|
||||
person = (
|
||||
db.query(Person)
|
||||
.join(Face, Person.id == Face.person_id)
|
||||
.filter(Face.photo_id == photo_id)
|
||||
.first()
|
||||
)
|
||||
if person:
|
||||
return f"{person.first_name or ''} {person.last_name or ''}".strip() or "Unknown"
|
||||
return None
|
||||
|
||||
|
||||
def get_photo_face_count(db: Session, photo_id: int) -> int:
|
||||
"""Get face count for a photo."""
|
||||
return db.query(Face).filter(Face.photo_id == photo_id).count()
|
||||
|
||||
135
src/web/services/tag_service.py
Normal file
135
src/web/services/tag_service.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""Tag service for managing tags - matches desktop functionality exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.models import Photo, PhotoTagLinkage, Tag
|
||||
|
||||
|
||||
def list_tags(db: Session) -> List[Tag]:
|
||||
"""Get all tags."""
|
||||
return db.query(Tag).order_by(Tag.tag_name).all()
|
||||
|
||||
|
||||
def get_or_create_tag(db: Session, tag_name: str) -> Tag:
|
||||
"""Get existing tag or create new one (case-insensitive)."""
|
||||
# Check if tag exists (case-insensitive)
|
||||
existing = (
|
||||
db.query(Tag)
|
||||
.filter(Tag.tag_name.ilike(tag_name.strip()))
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Create new tag
|
||||
tag = Tag(tag_name=tag_name.strip())
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
return tag
|
||||
|
||||
|
||||
def add_tags_to_photos(
|
||||
db: Session, photo_ids: List[int], tag_names: List[str]
|
||||
) -> tuple[int, int]:
|
||||
"""Add tags to photos.
|
||||
|
||||
Returns:
|
||||
Tuple of (photos_updated, tags_added)
|
||||
"""
|
||||
photos_updated = 0
|
||||
tags_added = 0
|
||||
|
||||
# Deduplicate tag names (case-insensitive)
|
||||
seen_tags = set()
|
||||
unique_tags = []
|
||||
for tag_name in tag_names:
|
||||
normalized = tag_name.strip().lower()
|
||||
if normalized and normalized not in seen_tags:
|
||||
seen_tags.add(normalized)
|
||||
unique_tags.append(tag_name.strip())
|
||||
|
||||
if not unique_tags:
|
||||
return 0, 0
|
||||
|
||||
# Get or create tags
|
||||
tag_objs = []
|
||||
for tag_name in unique_tags:
|
||||
tag = get_or_create_tag(db, tag_name)
|
||||
tag_objs.append(tag)
|
||||
|
||||
# Add tags to photos
|
||||
for photo_id in photo_ids:
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
continue
|
||||
|
||||
for tag in tag_objs:
|
||||
# Check if linkage already exists
|
||||
existing = (
|
||||
db.query(PhotoTagLinkage)
|
||||
.filter(
|
||||
PhotoTagLinkage.photo_id == photo_id,
|
||||
PhotoTagLinkage.tag_id == tag.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
linkage = PhotoTagLinkage(photo_id=photo_id, tag_id=tag.id)
|
||||
db.add(linkage)
|
||||
tags_added += 1
|
||||
|
||||
photos_updated += 1
|
||||
|
||||
db.commit()
|
||||
return photos_updated, tags_added
|
||||
|
||||
|
||||
def remove_tags_from_photos(
|
||||
db: Session, photo_ids: List[int], tag_names: List[str]
|
||||
) -> tuple[int, int]:
|
||||
"""Remove tags from photos.
|
||||
|
||||
Returns:
|
||||
Tuple of (photos_updated, tags_removed)
|
||||
"""
|
||||
photos_updated = 0
|
||||
tags_removed = 0
|
||||
|
||||
# Find tag IDs (case-insensitive)
|
||||
tag_ids = []
|
||||
for tag_name in tag_names:
|
||||
tag = (
|
||||
db.query(Tag)
|
||||
.filter(Tag.tag_name.ilike(tag_name.strip()))
|
||||
.first()
|
||||
)
|
||||
if tag:
|
||||
tag_ids.append(tag.id)
|
||||
|
||||
if not tag_ids:
|
||||
return 0, 0
|
||||
|
||||
# Remove linkages
|
||||
for photo_id in photo_ids:
|
||||
linkages = (
|
||||
db.query(PhotoTagLinkage)
|
||||
.filter(
|
||||
PhotoTagLinkage.photo_id == photo_id,
|
||||
PhotoTagLinkage.tag_id.in_(tag_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for linkage in linkages:
|
||||
db.delete(linkage)
|
||||
tags_removed += 1
|
||||
|
||||
if linkages:
|
||||
photos_updated += 1
|
||||
|
||||
db.commit()
|
||||
return photos_updated, tags_removed
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user