refactor: Simplify tag management by removing linkage type from API and UI

This commit refactors the tag management system by removing the linkage type parameter from various components, including the API and frontend. The changes streamline the process of adding and removing tags, allowing for both single and bulk tags to be handled uniformly. The UI has been updated to reflect these changes, enhancing user experience and simplifying the codebase. Documentation has been updated accordingly.
This commit is contained in:
tanyar09 2025-11-13 14:10:55 -05:00
parent 5ca130f8bd
commit 4f0b72ee5f
7 changed files with 148 additions and 215 deletions

View File

@ -14,7 +14,6 @@ export interface TagsResponse {
export interface PhotoTagsRequest {
photo_ids: number[]
tag_names: string[]
linkage_type?: number // 0=single, 1=bulk
}
export interface PhotoTagsResponse {
@ -27,7 +26,6 @@ export interface PhotoTagsResponse {
export interface PhotoTagItem {
tag_id: number
tag_name: string
linkage_type: number // 0=single, 1=bulk
}
export interface PhotoTagsListResponse {

View File

@ -91,7 +91,7 @@ export default function Search() {
if (searchType === 'name') {
if (!personName.trim()) {
alert('Please enter a name to search.')
alert('Please enter at least one name to search.')
setLoading(false)
return
}
@ -259,8 +259,7 @@ export default function Search() {
}, [showTagModal, selectedPhotos.size])
// Get all unique tags from selected photos
// Only include tags that are single (linkage_type = 0) in ALL selected photos
// Tags that are bulk (linkage_type = 1) in ANY photo cannot be removed
// Include all tags (both single and bulk) that are present in all selected photos
const allPhotoTags = useMemo(() => {
if (selectedPhotos.size === 0) return []
@ -276,18 +275,18 @@ export default function Search() {
})
})
// Filter to only include tags that are single (linkage_type = 0) in ALL photos
const removableTags = Array.from(tagMap.values()).filter(tag => {
// Check if this tag is single in all selected photos
// Filter to only include tags that exist in ALL selected photos (both single and bulk)
const commonTags = Array.from(tagMap.values()).filter(tag => {
// Check if this tag exists in all selected photos
return photoIds.every(photoId => {
const photoTagList = photoTags[photoId] || []
const photoTag = photoTagList.find(t => t.tag_id === tag.tag_id)
// Tag must exist in photo and be single (linkage_type = 0)
return photoTag && photoTag.linkage_type === 0
// Tag must exist in photo
return photoTag !== undefined
})
})
return removableTags
return commonTags
}, [photoTags, selectedPhotos])
const handleAddTag = async () => {
@ -314,11 +313,10 @@ export default function Search() {
setNewTagName('')
}
// Add tag to photos (always as single/linkage_type = 0)
// Add tag to photos
await tagsApi.addToPhotos({
photo_ids: Array.from(selectedPhotos),
tag_names: [tagToAdd],
linkage_type: 0, // Always single
})
setSelectedTagName('')
@ -349,18 +347,8 @@ export default function Search() {
setShowDeleteConfirm(false)
// Get tag names from selected tag IDs
// Filter to only include tags that are single (linkage_type = 0) in all photos
// This is a defensive check - allPhotoTags should already only contain single tags
const photoIds = Array.from(selectedPhotos)
// Allow removing both single and bulk tags
const tagNamesToDelete = Array.from(selectedTagIds)
.filter(tagId => {
// Check if tag is single in all selected photos
return photoIds.every(photoId => {
const photoTagList = photoTags[photoId] || []
const photoTag = photoTagList.find(t => t.tag_id === tagId)
return photoTag && photoTag.linkage_type === 0
})
})
.map(tagId => {
const tag = allPhotoTags.find(t => t.tag_id === tagId)
return tag ? tag.tag_name : null
@ -368,7 +356,7 @@ export default function Search() {
.filter(Boolean) as string[]
if (tagNamesToDelete.length === 0) {
alert('No removable tags selected. Bulk tags cannot be removed from this dialog.')
alert('No tags selected to remove.')
return
}
@ -618,7 +606,7 @@ export default function Search() {
{searchType === 'name' && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">
Person name:
Person name(s):
</label>
<input
type="text"
@ -626,7 +614,7 @@ export default function Search() {
onChange={(e) => setPersonName(e.target.value)}
className="flex-1 border rounded px-3 py-2"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter person name"
placeholder="Enter person name(s), separated by commas (e.g., John, Jane, Bob)"
/>
</div>
)}
@ -1019,11 +1007,8 @@ export default function Search() {
<div className="mb-4 border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Removable Tags (single tags present in all selected photos):
Tags (present in all selected photos):
</label>
<p className="text-xs text-gray-500 mb-2">
Note: Bulk tags cannot be removed from this dialog
</p>
{loadingTags ? (
<p className="text-sm text-gray-500">Loading tags...</p>
) : allPhotoTags.length === 0 ? (

View File

@ -7,7 +7,6 @@ type ViewMode = 'list' | 'icons' | 'compact'
interface PendingTagChange {
photoId: number
tagIds: number[]
linkageType: number // 0=single, 1=bulk
}
interface PendingTagRemoval {
@ -50,7 +49,6 @@ export default function Tags() {
const [folderStates, setFolderStates] = useState<Record<string, boolean>>(() => loadFolderStatesFromStorage())
const [pendingTagChanges, setPendingTagChanges] = useState<Record<number, number[]>>({})
const [pendingTagRemovals, setPendingTagRemovals] = useState<Record<number, number[]>>({})
const [pendingLinkageTypes, setPendingLinkageTypes] = useState<Record<number, Record<number, number>>>({})
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [showManageTags, setShowManageTags] = useState(false)
@ -152,6 +150,33 @@ export default function Tags() {
}).length
}, [selectedPhotoIds, photos])
// Check if all photos in a folder are selected
const isFolderFullySelected = (folder: FolderGroup): boolean => {
return folder.photos.length > 0 && folder.photos.every(photo => selectedPhotoIds.has(photo.id))
}
// Check if some (but not all) photos in a folder are selected
const isFolderPartiallySelected = (folder: FolderGroup): boolean => {
const selectedCount = folder.photos.filter(photo => selectedPhotoIds.has(photo.id)).length
return selectedCount > 0 && selectedCount < folder.photos.length
}
// Toggle selection of all photos in a folder
const toggleFolderSelection = (folder: FolderGroup) => {
const newSet = new Set(selectedPhotoIds)
const allSelected = isFolderFullySelected(folder)
if (allSelected) {
// Deselect all photos in folder
folder.photos.forEach(photo => newSet.delete(photo.id))
} else {
// Select all photos in folder
folder.photos.forEach(photo => newSet.add(photo.id))
}
setSelectedPhotoIds(newSet)
}
// Get tags for a photo (including pending changes)
const getPhotoTags = (photoId: number): string => {
const photo = photos.find(p => p.id === photoId)
@ -205,7 +230,7 @@ export default function Tags() {
}
// Add tag to photo (with optional immediate save)
const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0, saveImmediately: boolean = false) => {
const addTagToPhoto = async (photoId: number, tagName: string, saveImmediately: boolean = false) => {
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim())
let tagId: number
@ -230,7 +255,6 @@ export default function Tags() {
await tagsApi.addToPhotos({
photo_ids: [photoId],
tag_names: [tagName.trim()],
linkage_type: linkageType,
})
// Don't reload here - will reload when dialog closes
} catch (error) {
@ -244,18 +268,11 @@ export default function Tags() {
...prev,
[photoId]: [...(prev[photoId] || []), tagId],
}))
setPendingLinkageTypes(prev => ({
...prev,
[photoId]: {
...(prev[photoId] || {}),
[tagId]: linkageType,
},
}))
}
}
// Remove tag from photo (with optional immediate save)
const removeTagFromPhoto = async (photoId: number, tagId: number, linkageType: number, saveImmediately: boolean = false) => {
const removeTagFromPhoto = async (photoId: number, tagId: number, saveImmediately: boolean = false) => {
const tag = tags.find(t => t.id === tagId)
if (!tag) return
@ -279,16 +296,6 @@ export default function Tags() {
...prev,
[photoId]: (prev[photoId] || []).filter(id => id !== tagId),
}))
setPendingLinkageTypes(prev => {
const newTypes = { ...prev }
if (newTypes[photoId]) {
delete newTypes[photoId][tagId]
if (Object.keys(newTypes[photoId]).length === 0) {
delete newTypes[photoId]
}
}
return newTypes
})
return
}
@ -324,34 +331,10 @@ export default function Tags() {
return tag ? tag.tag_name : ''
}).filter(Boolean)
// Group by linkage type
const linkageTypes = pendingLinkageTypes[photoId] || {}
const singleTagIds = tagIds.filter(id => (linkageTypes[id] || 0) === 0)
const bulkTagIds = tagIds.filter(id => linkageTypes[id] === 1)
if (singleTagIds.length > 0) {
const singleTagNames = singleTagIds.map(id => {
const tag = tags.find(t => t.id === id)
return tag ? tag.tag_name : ''
}).filter(Boolean)
await tagsApi.addToPhotos({
photo_ids: [photoId],
tag_names: singleTagNames,
linkage_type: 0,
})
}
if (bulkTagIds.length > 0) {
const bulkTagNames = bulkTagIds.map(id => {
const tag = tags.find(t => t.id === id)
return tag ? tag.tag_name : ''
}).filter(Boolean)
await tagsApi.addToPhotos({
photo_ids: [photoId],
tag_names: bulkTagNames,
linkage_type: 1,
})
}
await tagsApi.addToPhotos({
photo_ids: [photoId],
tag_names: tagNames,
})
}
// Process removals
@ -373,7 +356,6 @@ export default function Tags() {
// Clear pending changes
setPendingTagChanges({})
setPendingTagRemovals({})
setPendingLinkageTypes({})
// Reload data
await loadData()
@ -452,7 +434,7 @@ export default function Tags() {
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Open Identify page in new tab with faces from selected photos"
>
🔍 Identify Faces ({selectedPhotosWithFacesCount})
👤 Identify Faces ({selectedPhotosWithFacesCount})
</button>
<button
onClick={() => setSelectedPhotoIds(new Set())}
@ -506,6 +488,18 @@ export default function Tags() {
<tr className="bg-gray-50 border-b">
<td colSpan={8} className="p-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isFolderFullySelected(folder)}
onChange={() => toggleFolderSelection(folder)}
className="w-4 h-4 cursor-pointer"
title="Select all photos in this folder"
ref={(el) => {
if (el) {
el.indeterminate = isFolderPartiallySelected(folder)
}
}}
/>
<button
onClick={() => toggleFolder(folder.folderPath)}
className="px-2 py-1 text-sm"
@ -515,13 +509,6 @@ export default function Tags() {
<span className="font-semibold">
📁 {folder.folderName} ({folder.photoCount} photos)
</span>
<button
onClick={() => setShowBulkTagDialog(folder.folderPath)}
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Manage bulk tags for this folder"
>
🔗
</button>
</div>
</td>
</tr>
@ -558,17 +545,19 @@ export default function Tags() {
<td className="p-2">{photo.processed ? 'Yes' : 'No'}</td>
<td className="p-2">{photo.date_taken || 'Unknown'}</td>
<td className="p-2">
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? (
<button
onClick={() => handleIdentifyFaces([photo.id])}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Identify faces in this photo"
>
🔍
</button>
) : (
<span>{photo.face_count || 0}</span>
)}
<div className="flex items-center justify-center">
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? (
<button
onClick={() => handleIdentifyFaces([photo.id])}
className="px-1 py-0.5 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Identify faces in this photo"
>
👤
</button>
) : (
<span className="text-center">{photo.face_count || 0}</span>
)}
</div>
</td>
<td className="p-2">
<div className="flex items-center gap-2">
@ -576,9 +565,9 @@ export default function Tags() {
<button
onClick={() => setShowTagDialog(photo.id)}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Tag photos"
title="Tag this photo"
>
🔗
🏷
</button>
</div>
</td>
@ -598,6 +587,18 @@ export default function Tags() {
{/* Folder Header */}
<div className="bg-gray-50 border-b p-3 mb-3 rounded-t">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isFolderFullySelected(folder)}
onChange={() => toggleFolderSelection(folder)}
className="w-4 h-4 cursor-pointer"
title="Select all photos in this folder"
ref={(el) => {
if (el) {
el.indeterminate = isFolderPartiallySelected(folder)
}
}}
/>
<button
onClick={() => toggleFolder(folder.folderPath)}
className="px-2 py-1 text-sm hover:bg-gray-200 rounded"
@ -607,13 +608,6 @@ export default function Tags() {
<span className="font-semibold">
📁 {folder.folderName} ({folder.photoCount} photos)
</span>
<button
onClick={() => setShowBulkTagDialog(folder.folderPath)}
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Manage bulk tags for this folder"
>
🔗
</button>
</div>
</div>
@ -659,7 +653,7 @@ export default function Tags() {
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
title="Identify faces in this photo"
>
🔍
👤
</button>
)}
<button
@ -668,9 +662,9 @@ export default function Tags() {
setShowTagDialog(photo.id)
}}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
title="Tag photos"
title="Tag this photo"
>
🔗
🏷
</button>
</div>
@ -753,8 +747,8 @@ export default function Tags() {
// Reload data when dialog is closed
await loadData()
}}
onAddTag={(tagName, linkageType) => addTagToPhoto(showTagDialog, tagName, linkageType, true)}
onRemoveTag={(tagId, linkageType) => removeTagFromPhoto(showTagDialog, tagId, linkageType, true)}
onAddTag={(tagName) => addTagToPhoto(showTagDialog, tagName, true)}
onRemoveTag={(tagId) => removeTagFromPhoto(showTagDialog, tagId, true)}
getPhotoTags={async (photoId) => {
return await tagsApi.getPhotoTags(photoId)
}}
@ -799,7 +793,6 @@ export default function Tags() {
await tagsApi.addToPhotos({
photo_ids: photoIds,
tag_names: [tagName.trim()],
linkage_type: 1, // bulk
})
} catch (error) {
console.error('Failed to save tags:', error)
@ -1118,8 +1111,8 @@ function PhotoTagDialog({
pendingTagChanges: number[]
pendingTagRemovals: number[]
onClose: () => void
onAddTag: (tagName: string, linkageType: number) => Promise<void>
onRemoveTag: (tagId: number, linkageType: number) => Promise<void>
onAddTag: (tagName: string) => Promise<void>
onRemoveTag: (tagId: number) => Promise<void>
getPhotoTags: (photoId: number) => Promise<any>
}) {
const [selectedTagName, setSelectedTagName] = useState('')
@ -1142,7 +1135,7 @@ function PhotoTagDialog({
const handleAddTag = async () => {
if (!selectedTagName.trim()) return
await onAddTag(selectedTagName.trim(), 0) // linkage_type = 0 (single)
await onAddTag(selectedTagName.trim())
setSelectedTagName('')
await loadPhotoTags()
}
@ -1156,9 +1149,7 @@ function PhotoTagDialog({
setShowConfirmDialog(false)
if (selectedTagIds.size === 0) return
for (const tagId of selectedTagIds) {
const tag = photoTags.find(t => t.tag_id === tagId)
const linkageType = tag?.linkage_type || 0
await onRemoveTag(tagId, linkageType)
await onRemoveTag(tagId)
}
setSelectedTagIds(new Set())
await loadPhotoTags()
@ -1171,7 +1162,7 @@ function PhotoTagDialog({
.filter(id => !pendingTagRemovals.includes(id))
.map(id => {
const tag = tags.find(t => t.id === id)
return tag ? { tag_id: id, tag_name: tag.tag_name, linkage_type: 0 } : null
return tag ? { tag_id: id, tag_name: tag.tag_name } : null
})
.filter(Boolean) as any[]
return [...savedTags, ...pendingTagObjs]
@ -1231,8 +1222,6 @@ function PhotoTagDialog({
) : (
allTags.map(tag => {
const isPending = pendingTagChanges.includes(tag.tag_id)
const linkageType = tag.linkage_type || 0
const canSelect = isPending || linkageType === 0
return (
<div key={tag.tag_id} className="flex items-center gap-2 py-1">
@ -1248,12 +1237,11 @@ function PhotoTagDialog({
}
setSelectedTagIds(newSet)
}}
disabled={!canSelect}
className="w-4 h-4"
/>
<span className={`flex-1 ${!canSelect ? 'text-gray-400' : ''}`}>
<span className="flex-1">
{tag.tag_name}
{isPending ? ' (pending)' : ` (saved ${linkageType === 0 ? 'single' : 'bulk'})`}
{isPending && ' (pending)'}
</span>
</div>
)
@ -1321,11 +1309,8 @@ function BulkTagDialog({
try {
// Get tags from the first photo in the folder
// Bulk tags should be the same for all photos in the folder
const response = await getPhotoTags(folder.photos[0].id)
// Filter for bulk tags (linkage_type = 1)
const bulkTags = (response.tags || []).filter((tag: any) => tag.linkage_type === 1)
setFolderTags(bulkTags)
setFolderTags(response.tags || [])
} catch (error) {
console.error('Failed to load folder tags:', error)
setFolderTags([])
@ -1341,7 +1326,7 @@ function BulkTagDialog({
// Update local state immediately to reflect the change
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase())
if (tag) {
const newTag = { tag_id: tag.id, tag_name: tag.tag_name, linkage_type: 1 }
const newTag = { tag_id: tag.id, tag_name: tag.tag_name }
setFolderTags(prev => {
// Check if tag already exists
if (prev.some(t => t.tag_id === tag.id)) {
@ -1382,12 +1367,12 @@ function BulkTagDialog({
// Filter out pending removals from saved tags
const savedTags = folderTags.filter(t => !pendingRemovalIds.includes(t.tag_id))
// Add pending tags that are bulk (linkage_type = 1)
// Add pending tags
const pendingTagObjs = pendingTagIds
.filter(id => !pendingRemovalIds.includes(id))
.map(id => {
const tag = tags.find(t => t.id === id)
return tag ? { tag_id: id, tag_name: tag.tag_name, linkage_type: 1 } : null
return tag ? { tag_id: id, tag_name: tag.tag_name } : null
})
.filter(Boolean) as any[]
@ -1409,7 +1394,7 @@ function BulkTagDialog({
<>
{showConfirmDialog && (
<ConfirmDialog
message={`Remove ${selectedTagIds.size} selected bulk tag(s) from all photos in this folder? (${selectedTagNames})`}
message={`Remove ${selectedTagIds.size} selected tag(s) from all photos in this folder? (${selectedTagNames})`}
onConfirm={confirmRemoveTags}
onCancel={() => setShowConfirmDialog(false)}
/>
@ -1417,7 +1402,7 @@ function BulkTagDialog({
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col">
<div className="p-4 border-b">
<h2 className="text-xl font-bold">Bulk Link Tags to Folder</h2>
<h2 className="text-xl font-bold">Link Tags to Folder</h2>
{folder && (
<p className="text-sm text-gray-600">
Folder: {folder.folderName} ({folder.photoCount} photos)
@ -1449,14 +1434,12 @@ function BulkTagDialog({
<div className="space-y-2">
{allTags.length === 0 ? (
<p className="text-gray-500 text-sm">No bulk tags linked to this folder</p>
<p className="text-gray-500 text-sm">No tags linked to this folder</p>
) : (
allTags.map(tag => {
if (!folder || folder.photos.length === 0) return null
const firstPhotoId = folder.photos[0].id
const isPending = (pendingTagChanges[firstPhotoId] || []).includes(tag.tag_id)
const linkageType = tag.linkage_type || 1
const canSelect = isPending || linkageType === 1
return (
<div key={tag.tag_id} className="flex items-center gap-2 py-1">
@ -1472,12 +1455,11 @@ function BulkTagDialog({
}
setSelectedTagIds(newSet)
}}
disabled={!canSelect}
className="w-4 h-4"
/>
<span className={`flex-1 ${!canSelect ? 'text-gray-400' : ''}`}>
<span className="flex-1">
{tag.tag_name}
{isPending ? ' (pending)' : ' (saved bulk)'}
{isPending && ' (pending)'}
</span>
</div>
)
@ -1565,7 +1547,6 @@ function TagSelectedPhotosDialog({
await tagsApi.addToPhotos({
photo_ids: selectedPhotoIds,
tag_names: [selectedTagName.trim()],
linkage_type: 0, // Always use single linkage type
})
setSelectedTagName('')
@ -1596,21 +1577,7 @@ function TagSelectedPhotosDialog({
setShowConfirmDialog(false)
if (selectedTagIds.size === 0) return
// Filter to only remove tags that are single (linkage_type = 0) in all photos
// This is a defensive check - commonTags should already only contain single tags
const tagIdsToRemove = Array.from(selectedTagIds).filter(tagId => {
// Check if tag is single in all photos
return selectedPhotoIds.every(photoId => {
const photoTags = photoTagsData[photoId] || []
const tag = photoTags.find(t => t.tag_id === tagId)
return tag && tag.linkage_type === 0
})
})
if (tagIdsToRemove.length === 0) {
alert('No removable tags selected. Bulk tags cannot be removed from this dialog.')
return
}
const tagIdsToRemove = Array.from(selectedTagIds)
for (const tagId of tagIdsToRemove) {
const tag = tags.find(t => t.id === tagId)
@ -1642,7 +1609,7 @@ function TagSelectedPhotosDialog({
setPhotoTagsData(tagsData)
}
// Get common tags across all selected photos (both single and bulk)
// Get common tags across all selected photos
const commonTags = useMemo(() => {
if (photos.length === 0 || selectedPhotoIds.length === 0) return []
@ -1669,30 +1636,15 @@ function TagSelectedPhotosDialog({
})
})
// Convert to tag objects with linkage type info
// Determine if tag is removable (single in all photos) or not (bulk in any photo)
// Convert to tag objects
return commonTagIds
.map(tagId => {
const tagName = tagIdToName.get(tagId)
if (!tagName) return null
// Check if tag is single (linkage_type = 0) in all photos
const isSingleInAll = selectedPhotoIds.every(photoId => {
const photoTags = allPhotoTags[photoId] || []
const tag = photoTags.find(t => t.tag_id === tagId)
return tag && tag.linkage_type === 0
})
// Get linkage type from first photo (all should be the same for common tags)
const firstPhotoTags = allPhotoTags[selectedPhotoIds[0]] || []
const firstTag = firstPhotoTags.find(t => t.tag_id === tagId)
const linkageType = firstTag?.linkage_type || 0
return {
tag_id: tagId,
tag_name: tagName,
linkage_type: linkageType,
isRemovable: isSingleInAll,
}
})
.filter(Boolean) as any[]
@ -1757,14 +1709,12 @@ function TagSelectedPhotosDialog({
<p className="text-gray-500 text-sm">No common tags found</p>
) : (
commonTags.map(tag => {
const isRemovable = tag.isRemovable !== false
return (
<div key={tag.tag_id} className="flex items-center gap-2 py-1">
<input
type="checkbox"
checked={selectedTagIds.has(tag.tag_id)}
onChange={(e) => {
if (!isRemovable) return
const newSet = new Set(selectedTagIds)
if (e.target.checked) {
newSet.add(tag.tag_id)
@ -1773,12 +1723,10 @@ function TagSelectedPhotosDialog({
}
setSelectedTagIds(newSet)
}}
disabled={!isRemovable}
className="w-4 h-4"
/>
<span className={`flex-1 ${!isRemovable ? 'text-gray-400' : ''}`}>
<span className="flex-1">
{tag.tag_name}
{!isRemovable && <span className="text-xs ml-2">(bulk)</span>}
</span>
</div>
)

View File

@ -70,7 +70,7 @@ def add_tags_to_photos_endpoint(
)
photos_updated, tags_added = add_tags_to_photos(
db, request.photo_ids, request.tag_names, request.linkage_type
db, request.photo_ids, request.tag_names
)
return PhotoTagsResponse(
@ -122,8 +122,8 @@ def get_photo_tags_endpoint(
tags_data = get_photo_tags(db, photo_id)
items = [
PhotoTagItem(tag_id=tag_id, tag_name=tag_name, linkage_type=linkage_type)
for tag_id, tag_name, linkage_type in tags_data
PhotoTagItem(tag_id=tag_id, tag_name=tag_name)
for tag_id, tag_name in tags_data
]
return PhotoTagsListResponse(photo_id=photo_id, tags=items, total=len(items))

View File

@ -37,7 +37,6 @@ class PhotoTagsRequest(BaseModel):
photo_ids: List[int] = Field(..., description="Photo IDs")
tag_names: List[str] = Field(..., description="Tag names to add/remove")
linkage_type: int = Field(0, ge=0, le=1, description="Linkage type: 0=single, 1=bulk")
class PhotoTagsResponse(BaseModel):
@ -66,7 +65,6 @@ class PhotoTagItem(BaseModel):
tag_id: int
tag_name: str
linkage_type: int # 0=single, 1=bulk
class PhotoTagsListResponse(BaseModel):

View File

@ -18,28 +18,41 @@ def search_photos_by_name(
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Tuple[Photo, str]], int]:
"""Search photos by person name (partial, case-insensitive).
"""Search photos by person name(s) (partial, case-insensitive).
Supports multiple names separated by commas (OR logic - photos matching any name).
Matches desktop behavior exactly:
- Searches first_name, last_name, middle_name, maiden_name
- Returns (photo, full_name) tuples
- Filters by folder_path if provided
- Multiple names: comma-separated, searches for photos with ANY matching person
"""
search_name = (person_name or "").strip().lower()
search_name = (person_name or "").strip()
if not search_name:
return [], 0
# Find matching people
matching_people = (
db.query(Person)
.filter(
# Split by comma and clean up names
search_names = [name.strip().lower() for name in search_name.split(',') if name.strip()]
if not search_names:
return [], 0
# Build OR conditions for each search name
name_conditions = []
for search_name_lower in search_names:
name_conditions.append(
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),
func.lower(Person.first_name).contains(search_name_lower),
func.lower(Person.last_name).contains(search_name_lower),
func.lower(Person.middle_name).contains(search_name_lower),
func.lower(Person.maiden_name).contains(search_name_lower),
)
)
# Find matching people (any of the search names)
matching_people = (
db.query(Person)
.filter(or_(*name_conditions))
.all()
)

View File

@ -34,14 +34,9 @@ def get_or_create_tag(db: Session, tag_name: str) -> Tag:
def add_tags_to_photos(
db: Session, photo_ids: List[int], tag_names: List[str], linkage_type: int = 0
db: Session, photo_ids: List[int], tag_names: List[str]
) -> tuple[int, int]:
"""Add tags to photos, matching desktop logic exactly.
Desktop logic:
- linkage_type: 0 = single (manually added to individual photo)
- linkage_type: 1 = bulk (applied to all photos in folder)
- Uses INSERT ... ON CONFLICT DO UPDATE to update linkage_type if exists
"""Add tags to photos.
Returns:
Tuple of (photos_updated, tags_added)
@ -67,7 +62,7 @@ def add_tags_to_photos(
tag = get_or_create_tag(db, tag_name)
tag_objs.append(tag)
# Add tags to photos (matching desktop link_photo_tag with linkage_type)
# Add tags to photos
for photo_id in photo_ids:
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
@ -84,17 +79,13 @@ def add_tags_to_photos(
.first()
)
if existing:
# Update linkage_type if different (matching desktop ON CONFLICT DO UPDATE)
if existing.linkage_type != linkage_type:
existing.linkage_type = linkage_type
existing.created_date = datetime.utcnow()
tags_added += 1
# Linkage already exists, skip
continue
else:
# Create new linkage with linkage_type
# Create new linkage
linkage = PhotoTagLinkage(
photo_id=photo_id,
tag_id=tag.id,
linkage_type=linkage_type,
)
db.add(linkage)
tags_added += 1
@ -151,11 +142,11 @@ def remove_tags_from_photos(
return photos_updated, tags_removed
def get_photo_tags(db: Session, photo_id: int) -> List[tuple[int, str, int]]:
"""Get all tags for a photo, matching desktop logic exactly.
def get_photo_tags(db: Session, photo_id: int) -> List[tuple[int, str]]:
"""Get all tags for a photo.
Returns:
List of (tag_id, tag_name, linkage_type) tuples
List of (tag_id, tag_name) tuples
"""
linkages = (
db.query(PhotoTagLinkage, Tag)
@ -166,7 +157,7 @@ def get_photo_tags(db: Session, photo_id: int) -> List[tuple[int, str, int]]:
)
return [
(linkage.tag_id, tag.tag_name, linkage.linkage_type)
(linkage.tag_id, tag.tag_name)
for linkage, tag in linkages
]