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:
parent
5ca130f8bd
commit
4f0b72ee5f
@ -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 {
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user