From 4f0b72ee5fe7a841c1ed2351ad812162a436bd6c Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 13 Nov 2025 14:10:55 -0500 Subject: [PATCH] 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. --- frontend/src/api/tags.ts | 2 - frontend/src/pages/Search.tsx | 43 ++--- frontend/src/pages/Tags.tsx | 248 ++++++++++++----------------- src/web/api/tags.py | 6 +- src/web/schemas/tags.py | 2 - src/web/services/search_service.py | 33 ++-- src/web/services/tag_service.py | 29 ++-- 7 files changed, 148 insertions(+), 215 deletions(-) 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 ]