diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts index 4b6e105..c58f3d6 100644 --- a/frontend/src/api/tags.ts +++ b/frontend/src/api/tags.ts @@ -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 { diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 6a6b3c0..d0e2fc9 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -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' && (
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)" />
)} @@ -1019,11 +1007,8 @@ export default function Search() {
-

- Note: Bulk tags cannot be removed from this dialog -

{loadingTags ? (

Loading tags...

) : allPhotoTags.length === 0 ? ( diff --git a/frontend/src/pages/Tags.tsx b/frontend/src/pages/Tags.tsx index 331cf9d..91816fd 100644 --- a/frontend/src/pages/Tags.tsx +++ b/frontend/src/pages/Tags.tsx @@ -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>(() => loadFolderStatesFromStorage()) const [pendingTagChanges, setPendingTagChanges] = useState>({}) const [pendingTagRemovals, setPendingTagRemovals] = useState>({}) - const [pendingLinkageTypes, setPendingLinkageTypes] = useState>>({}) 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})
@@ -558,17 +545,19 @@ export default function Tags() { {photo.processed ? 'Yes' : 'No'} {photo.date_taken || 'Unknown'} - {photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? ( - - ) : ( - {photo.face_count || 0} - )} +
+ {photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? ( + + ) : ( + {photo.face_count || 0} + )} +
@@ -576,9 +565,9 @@ export default function Tags() {
@@ -598,6 +587,18 @@ export default function Tags() { {/* Folder Header */}
+ toggleFolderSelection(folder)} + className="w-4 h-4 cursor-pointer" + title="Select all photos in this folder" + ref={(el) => { + if (el) { + el.indeterminate = isFolderPartiallySelected(folder) + } + }} + />
@@ -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" > - 🔍 + 👤 )} @@ -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 - onRemoveTag: (tagId: number, linkageType: number) => Promise + onAddTag: (tagName: string) => Promise + onRemoveTag: (tagId: number) => Promise getPhotoTags: (photoId: number) => Promise }) { 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 (
@@ -1248,12 +1237,11 @@ function PhotoTagDialog({ } setSelectedTagIds(newSet) }} - disabled={!canSelect} className="w-4 h-4" /> - + {tag.tag_name} - {isPending ? ' (pending)' : ` (saved ${linkageType === 0 ? 'single' : 'bulk'})`} + {isPending && ' (pending)'}
) @@ -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 && ( setShowConfirmDialog(false)} /> @@ -1417,7 +1402,7 @@ function BulkTagDialog({
-

Bulk Link Tags to Folder

+

Link Tags to Folder

{folder && (

Folder: {folder.folderName} ({folder.photoCount} photos) @@ -1449,14 +1434,12 @@ function BulkTagDialog({

{allTags.length === 0 ? ( -

No bulk tags linked to this folder

+

No tags linked to this folder

) : ( 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 (
@@ -1472,12 +1455,11 @@ function BulkTagDialog({ } setSelectedTagIds(newSet) }} - disabled={!canSelect} className="w-4 h-4" /> - + {tag.tag_name} - {isPending ? ' (pending)' : ' (saved bulk)'} + {isPending && ' (pending)'}
) @@ -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({

No common tags found

) : ( commonTags.map(tag => { - const isRemovable = tag.isRemovable !== false return (
{ - 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" /> - + {tag.tag_name} - {!isRemovable && (bulk)}
) diff --git a/src/web/api/tags.py b/src/web/api/tags.py index 21a6e5e..6cc2850 100644 --- a/src/web/api/tags.py +++ b/src/web/api/tags.py @@ -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)) diff --git a/src/web/schemas/tags.py b/src/web/schemas/tags.py index 03354c4..29a2b60 100644 --- a/src/web/schemas/tags.py +++ b/src/web/schemas/tags.py @@ -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): diff --git a/src/web/services/search_service.py b/src/web/services/search_service.py index 2fc7a9d..9d1111e 100644 --- a/src/web/services/search_service.py +++ b/src/web/services/search_service.py @@ -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() ) diff --git a/src/web/services/tag_service.py b/src/web/services/tag_service.py index 93050f2..ed5715c 100644 --- a/src/web/services/tag_service.py +++ b/src/web/services/tag_service.py @@ -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 ]