diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts index 262d638..7f19df7 100644 --- a/frontend/src/api/tags.ts +++ b/frontend/src/api/tags.ts @@ -14,6 +14,7 @@ export interface TagsResponse { export interface PhotoTagsRequest { photo_ids: number[] tag_names: string[] + linkage_type?: number // 0=single, 1=bulk } export interface PhotoTagsResponse { @@ -23,6 +24,42 @@ export interface PhotoTagsResponse { tags_removed: number } +export interface PhotoTagItem { + tag_id: number + tag_name: string + linkage_type: number // 0=single, 1=bulk +} + +export interface PhotoTagsListResponse { + photo_id: number + tags: PhotoTagItem[] + total: number +} + +export interface TagUpdateRequest { + tag_name: string +} + +export interface TagDeleteRequest { + tag_ids: number[] +} + +export interface PhotoWithTagsItem { + id: number + filename: string + path: string + processed: boolean + date_taken?: string | null + date_added?: string | null + face_count: number + tags: string // Comma-separated tags string +} + +export interface PhotosWithTagsResponse { + items: PhotoWithTagsItem[] + total: number +} + export const tagsApi = { list: async (): Promise => { const { data } = await apiClient.get('/api/v1/tags') @@ -51,6 +88,29 @@ export const tagsApi = { ) return data }, + getPhotoTags: async (photoId: number): Promise => { + const { data } = await apiClient.get( + `/api/v1/tags/photos/${photoId}` + ) + return data + }, + update: async (tagId: number, tagName: string): Promise => { + const { data } = await apiClient.put(`/api/v1/tags/${tagId}`, { + tag_name: tagName, + }) + return data + }, + delete: async (tagIds: number[]): Promise<{ message: string; deleted_count: number }> => { + const { data } = await apiClient.post<{ message: string; deleted_count: number }>( + '/api/v1/tags/delete', + { tag_ids: tagIds } + ) + return data + }, + getPhotosWithTags: async (): Promise => { + const { data } = await apiClient.get('/api/v1/tags/photos') + return data + }, } export default tagsApi diff --git a/frontend/src/pages/Tags.tsx b/frontend/src/pages/Tags.tsx index 75d3048..3588158 100644 --- a/frontend/src/pages/Tags.tsx +++ b/frontend/src/pages/Tags.tsx @@ -1,11 +1,892 @@ +import React, { useState, useEffect, useMemo } from 'react' +import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags' + +type ViewMode = 'list' | 'icons' | 'compact' + +interface PendingTagChange { + photoId: number + tagIds: number[] + linkageType: number // 0=single, 1=bulk +} + +interface PendingTagRemoval { + photoId: number + tagIds: number[] +} + +interface FolderGroup { + folderPath: string + folderName: string + photos: PhotoWithTagsItem[] + photoCount: number +} + export default function Tags() { + const [viewMode, setViewMode] = useState('list') + const [photos, setPhotos] = useState([]) + const [tags, setTags] = useState([]) + const [folderStates, setFolderStates] = useState>({}) + 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) + const [showTagDialog, setShowTagDialog] = useState(null) + const [showBulkTagDialog, setShowBulkTagDialog] = useState(null) + + // Load photos and tags + useEffect(() => { + loadData() + }, []) + + const loadData = async () => { + setLoading(true) + try { + const [photosRes, tagsRes] = await Promise.all([ + tagsApi.getPhotosWithTags(), + tagsApi.list(), + ]) + setPhotos(photosRes.items) + setTags(tagsRes.items) + } catch (error) { + console.error('Failed to load data:', error) + alert('Failed to load photos and tags') + } finally { + setLoading(false) + } + } + + // Group photos by folder + const folderGroups = useMemo(() => { + const groups: Record = {} + photos.forEach(photo => { + const folderPath = photo.path.substring(0, photo.path.lastIndexOf('/') || photo.path.length) + if (!groups[folderPath]) { + groups[folderPath] = [] + } + groups[folderPath].push(photo) + }) + + const sortedFolders: FolderGroup[] = [] + Object.keys(groups).sort().forEach(folderPath => { + const folderName = folderPath.substring(folderPath.lastIndexOf('/') + 1) || 'Root' + const photosInFolder = groups[folderPath].sort((a, b) => { + const dateA = a.date_taken || '' + const dateB = b.date_taken || '' + return dateB.localeCompare(dateA) + }) + sortedFolders.push({ + folderPath, + folderName, + photos: photosInFolder, + photoCount: photosInFolder.length, + }) + }) + + return sortedFolders + }, [photos]) + + // Get tags for a photo (including pending changes) + const getPhotoTags = (photoId: number): string => { + const photo = photos.find(p => p.id === photoId) + if (!photo) return '' + + const existingTags = photo.tags ? photo.tags.split(',').map(t => t.trim()).filter(t => t) : [] + const pendingTagIds = pendingTagChanges[photoId] || [] + const pendingRemovalIds = pendingTagRemovals[photoId] || [] + + // Get tag names for pending additions + const pendingTagNames = pendingTagIds + .filter(tagId => !pendingRemovalIds.includes(tagId)) + .map(tagId => { + const tag = tags.find(t => t.id === tagId) + return tag ? tag.tag_name : `Unknown ${tagId}` + }) + + // Combine existing and pending tags, remove pending removals + const allTags = [...existingTags, ...pendingTagNames] + const removalTagIds = pendingRemovalIds + const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name])) + const removalTagNames = removalTagIds.map(id => tagIdToName.get(id) || '').filter(Boolean) + + const finalTags = allTags.filter(tag => !removalTagNames.includes(tag)) + return finalTags.join(', ') || 'None' + } + + // Toggle folder expand/collapse + const toggleFolder = (folderPath: string) => { + setFolderStates(prev => ({ + ...prev, + [folderPath]: !prev[folderPath], + })) + } + + // Add tag to photo + const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0) => { + const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim()) + let tagId: number + + if (tag) { + tagId = tag.id + } else { + // Create new tag + try { + const newTag = await tagsApi.create(tagName.trim()) + tagId = newTag.id + setTags(prev => [...prev, newTag].sort((a, b) => a.tag_name.localeCompare(b.tag_name))) + } catch (error) { + console.error('Failed to create tag:', error) + alert('Failed to create tag') + return + } + } + + // Add to pending changes + setPendingTagChanges(prev => ({ + ...prev, + [photoId]: [...(prev[photoId] || []), tagId], + })) + setPendingLinkageTypes(prev => ({ + ...prev, + [photoId]: { + ...(prev[photoId] || {}), + [tagId]: linkageType, + }, + })) + } + + // Remove tag from photo + const removeTagFromPhoto = (photoId: number, tagId: number, linkageType: number) => { + // If it's a pending addition, just remove it + if (pendingTagChanges[photoId]?.includes(tagId)) { + setPendingTagChanges(prev => ({ + ...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 + } + + // Otherwise, add to pending removals + setPendingTagRemovals(prev => ({ + ...prev, + [photoId]: [...(prev[photoId] || []), tagId], + })) + } + + // Save pending changes + const saveChanges = async () => { + const pendingPhotoIds = new Set([ + ...Object.keys(pendingTagChanges).map(Number), + ...Object.keys(pendingTagRemovals).map(Number), + ]) + + if (pendingPhotoIds.size === 0) { + alert('No tag changes to save.') + return + } + + setSaving(true) + try { + // Process additions + for (const photoId of Object.keys(pendingTagChanges).map(Number)) { + const tagIds = pendingTagChanges[photoId] || [] + if (tagIds.length === 0) continue + + const tagNames = tagIds.map(id => { + const tag = tags.find(t => t.id === id) + 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, + }) + } + } + + // Process removals + for (const photoId of Object.keys(pendingTagRemovals).map(Number)) { + const tagIds = pendingTagRemovals[photoId] || [] + if (tagIds.length === 0) continue + + const tagNames = tagIds.map(id => { + const tag = tags.find(t => t.id === id) + return tag ? tag.tag_name : '' + }).filter(Boolean) + + await tagsApi.removeFromPhotos({ + photo_ids: [photoId], + tag_names: tagNames, + }) + } + + // Clear pending changes + setPendingTagChanges({}) + setPendingTagRemovals({}) + setPendingLinkageTypes({}) + + // Reload data + await loadData() + + const additionsCount = Object.keys(pendingTagChanges).length + const removalsCount = Object.keys(pendingTagRemovals).length + let message = `Saved ${additionsCount} tag addition(s)` + if (removalsCount > 0) { + message += ` and ${removalsCount} tag removal(s)` + } + message += '.' + alert(message) + } catch (error) { + console.error('Failed to save changes:', error) + alert('Failed to save tag changes') + } finally { + setSaving(false) + } + } + + // Get pending changes count + const pendingChangesCount = useMemo(() => { + const additions = Object.values(pendingTagChanges).reduce((sum, ids) => sum + ids.length, 0) + const removals = Object.values(pendingTagRemovals).reduce((sum, ids) => sum + ids.length, 0) + return additions + removals + }, [pendingTagChanges, pendingTagRemovals]) + + if (loading) { + return ( +
+

Loading photos and tags...

+
+ ) + } + return ( -
-

Tags

-
-

Tag management coming in Phase 3.

+
+
+

Photo Explorer - Tag Management

+
+
+ +
+ + + +
+
+ +
+
+ +
+ {viewMode === 'list' && ( +
+ + + + + + + + + + + + + + {folderGroups.map(folder => ( + + + + + {folderStates[folder.folderPath] !== false && folder.photos.map(photo => ( + + + + + + + + + + ))} + + ))} + +
IDFilenamePathProcessedDate TakenFacesTags
+
+ + + 📁 {folder.folderName} ({folder.photoCount} photos) + + +
+
{photo.id} + + {photo.filename} + + {photo.path}{photo.processed ? 'Yes' : 'No'}{photo.date_taken || 'Unknown'}{photo.face_count} +
+ {getPhotoTags(photo.id)} + +
+
+
+ )} + + {viewMode === 'icons' && ( +
+

Icons view coming soon...

+
+ )} + + {viewMode === 'compact' && ( +
+

Compact view coming soon...

+
+ )} +
+ +
+ +
+ + {/* Manage Tags Dialog */} + {showManageTags && ( + setShowManageTags(false)} + onTagsChange={setTags} + /> + )} + + {/* Tag Dialog for Individual Photo */} + {showTagDialog !== null && ( + p.id === showTagDialog)} + tags={tags} + pendingTagChanges={pendingTagChanges[showTagDialog] || []} + pendingTagRemovals={pendingTagRemovals[showTagDialog] || []} + onClose={() => setShowTagDialog(null)} + onAddTag={(tagName, linkageType) => addTagToPhoto(showTagDialog, tagName, linkageType)} + onRemoveTag={(tagId, linkageType) => removeTagFromPhoto(showTagDialog, tagId, linkageType)} + getPhotoTags={async (photoId) => { + return await tagsApi.getPhotoTags(photoId) + }} + /> + )} + + {/* Bulk Tag Dialog for Folder */} + {showBulkTagDialog !== null && ( + f.folderPath === showBulkTagDialog)} + tags={tags} + onClose={() => setShowBulkTagDialog(null)} + onAddTag={(tagName) => { + const folder = folderGroups.find(f => f.folderPath === showBulkTagDialog) + if (folder) { + folder.photos.forEach(photo => { + addTagToPhoto(photo.id, tagName, 1) // linkage_type = 1 (bulk) + }) + } + }} + /> + )} +
+ ) +} + +// Manage Tags Dialog Component +function ManageTagsDialog({ + tags, + onClose, + onTagsChange, +}: { + tags: TagResponse[] + onClose: () => void + onTagsChange: (tags: TagResponse[]) => void +}) { + const [newTagName, setNewTagName] = useState('') + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + const [editingTagId, setEditingTagId] = useState(null) + const [editTagName, setEditTagName] = useState('') + + const handleCreateTag = async () => { + if (!newTagName.trim()) return + + try { + const newTag = await tagsApi.create(newTagName.trim()) + onTagsChange([...tags, newTag].sort((a, b) => a.tag_name.localeCompare(b.tag_name))) + setNewTagName('') + } catch (error) { + console.error('Failed to create tag:', error) + alert('Failed to create tag') + } + } + + const handleEditTag = async (tagId: number) => { + if (!editTagName.trim()) return + + try { + const updatedTag = await tagsApi.update(tagId, editTagName.trim()) + onTagsChange(tags.map(t => (t.id === tagId ? updatedTag : t)).sort((a, b) => a.tag_name.localeCompare(b.tag_name))) + setEditingTagId(null) + setEditTagName('') + } catch (error) { + console.error('Failed to update tag:', error) + alert('Failed to update tag') + } + } + + const handleDeleteTags = async () => { + if (selectedTagIds.size === 0) return + + if (!confirm(`Delete ${selectedTagIds.size} selected tag(s)? This will unlink them from photos.`)) { + return + } + + try { + await tagsApi.delete(Array.from(selectedTagIds)) + onTagsChange(tags.filter(t => !selectedTagIds.has(t.id))) + setSelectedTagIds(new Set()) + // Reload photos to reflect tag deletions + window.location.reload() + } catch (error) { + console.error('Failed to delete tags:', error) + alert('Failed to delete tags') + } + } + + return ( +
+
+
+

Manage Tags

+
+
+
+ setNewTagName(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleCreateTag()} + placeholder="Enter new tag name" + className="flex-1 px-3 py-2 border border-gray-300 rounded" + /> + +
+ +
+ Delete + Tag name + Edit +
+ +
+ {tags.map(tag => ( +
+ { + const newSet = new Set(selectedTagIds) + if (e.target.checked) { + newSet.add(tag.id) + } else { + newSet.delete(tag.id) + } + setSelectedTagIds(newSet) + }} + className="w-4 h-4" + /> + {editingTagId === tag.id ? ( +
+ setEditTagName(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleEditTag(tag.id)} + className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm" + /> + + +
+ ) : ( + <> + {tag.tag_name} + + + )} +
+ ))} +
+
+
+ + +
) } +// Photo Tag Dialog Component +function PhotoTagDialog({ + photoId, + photo, + tags, + pendingTagChanges, + pendingTagRemovals, + onClose, + onAddTag, + onRemoveTag, + getPhotoTags, +}: { + photoId: number + photo: PhotoWithTagsItem | undefined + tags: TagResponse[] + pendingTagChanges: number[] + pendingTagRemovals: number[] + onClose: () => void + onAddTag: (tagName: string, linkageType: number) => void + onRemoveTag: (tagId: number, linkageType: number) => void + getPhotoTags: (photoId: number) => Promise +}) { + const [selectedTagName, setSelectedTagName] = useState('') + const [photoTags, setPhotoTags] = useState([]) + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + + useEffect(() => { + loadPhotoTags() + }, [photoId]) + + const loadPhotoTags = async () => { + try { + const response = await getPhotoTags(photoId) + setPhotoTags(response.tags || []) + } catch (error) { + console.error('Failed to load photo tags:', error) + } + } + + const handleAddTag = () => { + if (!selectedTagName.trim()) return + onAddTag(selectedTagName.trim(), 0) // linkage_type = 0 (single) + setSelectedTagName('') + loadPhotoTags() + } + + const handleRemoveTags = () => { + if (selectedTagIds.size === 0) return + selectedTagIds.forEach(tagId => { + const tag = photoTags.find(t => t.tag_id === tagId) + const linkageType = tag?.linkage_type || 0 + onRemoveTag(tagId, linkageType) + }) + setSelectedTagIds(new Set()) + loadPhotoTags() + } + + // Combine saved and pending tags + const allTags = useMemo(() => { + const savedTags = photoTags.filter(t => !pendingTagRemovals.includes(t.tag_id)) + const pendingTagObjs = pendingTagChanges + .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 + }) + .filter(Boolean) as any[] + return [...savedTags, ...pendingTagObjs] + }, [photoTags, pendingTagChanges, pendingTagRemovals, tags]) + + return ( +
+
+
+

Manage Photo Tags

+ {photo &&

{photo.filename}

} +
+
+
+ + +
+ +
+ {allTags.length === 0 ? ( +

No tags linked to this photo

+ ) : ( + allTags.map(tag => { + const isPending = pendingTagChanges.includes(tag.tag_id) + const linkageType = tag.linkage_type || 0 + const canSelect = isPending || linkageType === 0 + + return ( +
+ { + const newSet = new Set(selectedTagIds) + if (e.target.checked) { + newSet.add(tag.tag_id) + } else { + newSet.delete(tag.tag_id) + } + setSelectedTagIds(newSet) + }} + disabled={!canSelect} + className="w-4 h-4" + /> + + {tag.tag_name} + {isPending ? ' (pending)' : ` (saved ${linkageType === 0 ? 'single' : 'bulk'})`} + +
+ ) + }) + )} +
+
+
+ + +
+
+
+ ) +} + +// Bulk Tag Dialog Component +function BulkTagDialog({ + folderPath, + folder, + tags, + onClose, + onAddTag, +}: { + folderPath: string + folder: FolderGroup | undefined + tags: TagResponse[] + onClose: () => void + onAddTag: (tagName: string) => void +}) { + const [selectedTagName, setSelectedTagName] = useState('') + + const handleAddTag = () => { + if (!selectedTagName.trim()) return + onAddTag(selectedTagName.trim()) + setSelectedTagName('') + } + + return ( +
+
+
+

Bulk Link Tags to Folder

+ {folder && ( +

+ Folder: {folder.folderName} ({folder.photoCount} photos) +

+ )} +
+
+
+ +
+ + +
+
+
+
+ +
+
+
+ ) +} diff --git a/src/web/api/tags.py b/src/web/api/tags.py index 61da2bf..93df7eb 100644 --- a/src/web/api/tags.py +++ b/src/web/api/tags.py @@ -14,13 +14,24 @@ from src.web.schemas.tags import ( TagCreateRequest, TagResponse, TagsResponse, + TagUpdateRequest, + TagDeleteRequest, + PhotoTagsListResponse, + PhotoTagItem, + PhotosWithTagsResponse, + PhotoWithTagsItem, ) from src.web.services.tag_service import ( add_tags_to_photos, get_or_create_tag, list_tags, remove_tags_from_photos, + get_photo_tags, + update_tag, + delete_tags, + get_photos_with_tags, ) +from src.web.db.models import Photo router = APIRouter(prefix="/tags", tags=["tags"]) @@ -59,7 +70,7 @@ def add_tags_to_photos_endpoint( ) photos_updated, tags_added = add_tags_to_photos( - db, request.photo_ids, request.tag_names + db, request.photo_ids, request.tag_names, request.linkage_type ) return PhotoTagsResponse( @@ -95,3 +106,84 @@ def remove_tags_from_photos_endpoint( tags_removed=tags_removed, ) + +@router.get("/photos/{photo_id}", response_model=PhotoTagsListResponse) +def get_photo_tags_endpoint( + photo_id: int, db: Session = Depends(get_db) +) -> PhotoTagsListResponse: + """Get all tags for a specific photo.""" + # Validate photo exists + photo = db.query(Photo).filter(Photo.id == photo_id).first() + if not photo: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"Photo {photo_id} not found" + ) + + 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 + ] + + return PhotoTagsListResponse(photo_id=photo_id, tags=items, total=len(items)) + + +@router.put("/{tag_id}", response_model=TagResponse) +def update_tag_endpoint( + tag_id: int, request: TagUpdateRequest, db: Session = Depends(get_db) +) -> TagResponse: + """Update a tag name.""" + try: + tag = update_tag(db, tag_id, request.tag_name) + return TagResponse.model_validate(tag) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) + ) + + +@router.post("/delete", response_model=dict) +def delete_tags_endpoint( + request: TagDeleteRequest, db: Session = Depends(get_db) +) -> dict: + """Delete tags and all their linkages.""" + if not request.tag_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="tag_ids list cannot be empty", + ) + + deleted_count = delete_tags(db, request.tag_ids) + + return { + "message": f"Deleted {deleted_count} tag(s)", + "deleted_count": deleted_count, + } + + +@router.get("/photos", response_model=PhotosWithTagsResponse) +def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTagsResponse: + """Get all photos with tags and face counts, matching desktop tag manager query exactly. + + Returns all photos with their tags (comma-separated) and face counts, + ordered by date_taken DESC, filename. + """ + photos_data = get_photos_with_tags(db) + + items = [ + PhotoWithTagsItem( + id=p['id'], + filename=p['filename'], + path=p['path'], + processed=p['processed'], + date_taken=p['date_taken'], + date_added=p['date_added'], + face_count=p['face_count'], + tags=p['tags'], + ) + for p in photos_data + ] + + return PhotosWithTagsResponse(items=items, total=len(items)) + diff --git a/src/web/schemas/tags.py b/src/web/schemas/tags.py index a0d64e5..4bed98d 100644 --- a/src/web/schemas/tags.py +++ b/src/web/schemas/tags.py @@ -37,6 +37,7 @@ 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): @@ -47,3 +48,51 @@ class PhotoTagsResponse(BaseModel): tags_added: int tags_removed: int + +class TagUpdateRequest(BaseModel): + """Request to update a tag name.""" + + tag_name: str = Field(..., description="New tag name") + + +class TagDeleteRequest(BaseModel): + """Request to delete tags.""" + + tag_ids: List[int] = Field(..., description="Tag IDs to delete") + + +class PhotoTagItem(BaseModel): + """Tag item for a photo.""" + + tag_id: int + tag_name: str + linkage_type: int # 0=single, 1=bulk + + +class PhotoTagsListResponse(BaseModel): + """Response for listing tags on a photo.""" + + photo_id: int + tags: List[PhotoTagItem] + total: int + + +class PhotoWithTagsItem(BaseModel): + """Photo item with tags for tag manager.""" + + id: int + filename: str + path: str + processed: bool + date_taken: Optional[str] = None + date_added: Optional[str] = None + face_count: int + tags: str # Comma-separated tags string (matching desktop) + + +class PhotosWithTagsResponse(BaseModel): + """Response for listing photos with tags.""" + + items: List[PhotoWithTagsItem] + total: int + diff --git a/src/web/services/tag_service.py b/src/web/services/tag_service.py index cd8599c..ff6aea4 100644 --- a/src/web/services/tag_service.py +++ b/src/web/services/tag_service.py @@ -3,10 +3,11 @@ from __future__ import annotations from typing import List, Optional +from datetime import datetime from sqlalchemy.orm import Session -from src.web.db.models import Photo, PhotoTagLinkage, Tag +from src.web.db.models import Photo, PhotoTagLinkage, Tag, Face def list_tags(db: Session) -> List[Tag]: @@ -33,9 +34,14 @@ 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] + db: Session, photo_ids: List[int], tag_names: List[str], linkage_type: int = 0 ) -> tuple[int, int]: - """Add tags to photos. + """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 Returns: Tuple of (photos_updated, tags_added) @@ -43,7 +49,7 @@ def add_tags_to_photos( photos_updated = 0 tags_added = 0 - # Deduplicate tag names (case-insensitive) + # Deduplicate tag names (case-insensitive) - matching desktop deduplicate_tags seen_tags = set() unique_tags = [] for tag_name in tag_names: @@ -55,13 +61,13 @@ def add_tags_to_photos( if not unique_tags: return 0, 0 - # Get or create tags + # Get or create tags (matching desktop add_tag) tag_objs = [] for tag_name in unique_tags: tag = get_or_create_tag(db, tag_name) tag_objs.append(tag) - # Add tags to photos + # Add tags to photos (matching desktop link_photo_tag with linkage_type) for photo_id in photo_ids: photo = db.query(Photo).filter(Photo.id == photo_id).first() if not photo: @@ -77,8 +83,19 @@ def add_tags_to_photos( ) .first() ) - if not existing: - linkage = PhotoTagLinkage(photo_id=photo_id, tag_id=tag.id) + 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 + else: + # Create new linkage with linkage_type + linkage = PhotoTagLinkage( + photo_id=photo_id, + tag_id=tag.id, + linkage_type=linkage_type, + ) db.add(linkage) tags_added += 1 @@ -133,3 +150,135 @@ def remove_tags_from_photos( db.commit() 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. + + Returns: + List of (tag_id, tag_name, linkage_type) tuples + """ + linkages = ( + db.query(PhotoTagLinkage, Tag) + .join(Tag, PhotoTagLinkage.tag_id == Tag.id) + .filter(PhotoTagLinkage.photo_id == photo_id) + .order_by(Tag.tag_name) + .all() + ) + + return [ + (linkage.tag_id, tag.tag_name, linkage.linkage_type) + for linkage, tag in linkages + ] + + +def update_tag(db: Session, tag_id: int, new_tag_name: str) -> Tag: + """Update a tag name, matching desktop logic exactly. + + Desktop logic: + - Updates tag_name in tags table + - Case-insensitive check for duplicates + """ + tag = db.query(Tag).filter(Tag.id == tag_id).first() + if not tag: + raise ValueError(f"Tag {tag_id} not found") + + # Check if new name already exists (case-insensitive) + existing = ( + db.query(Tag) + .filter(Tag.tag_name.ilike(new_tag_name.strip())) + .filter(Tag.id != tag_id) + .first() + ) + if existing: + raise ValueError(f"Tag name '{new_tag_name}' already exists") + + tag.tag_name = new_tag_name.strip() + db.commit() + db.refresh(tag) + return tag + + +def delete_tags(db: Session, tag_ids: List[int]) -> int: + """Delete tags and all their linkages, matching desktop logic exactly. + + Desktop logic: + - Deletes all phototaglinkage entries for these tags + - Deletes the tags themselves + + Returns: + Number of tags deleted + """ + if not tag_ids: + return 0 + + # Delete all linkages for these tags (matching desktop) + db.query(PhotoTagLinkage).filter(PhotoTagLinkage.tag_id.in_(tag_ids)).delete( + synchronize_session=False + ) + + # Delete the tags themselves + deleted_count = db.query(Tag).filter(Tag.id.in_(tag_ids)).delete( + synchronize_session=False + ) + + db.commit() + return deleted_count + + +def get_photos_with_tags(db: Session) -> List[dict]: + """Get all photos with tags and face counts, matching desktop query exactly. + + Desktop query: + SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added, + (SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count, + (SELECT GROUP_CONCAT(DISTINCT t.tag_name) + FROM phototaglinkage ptl + JOIN tags t ON t.id = ptl.tag_id + WHERE ptl.photo_id = p.id) as tags + FROM photos p + ORDER BY p.date_taken DESC, p.filename + + Returns: + List of dicts with photo info, face_count, and tags (comma-separated string) + """ + from sqlalchemy import func + + # Get all photos with face counts and tags + photos = ( + db.query(Photo) + .order_by(Photo.date_taken.desc().nullslast(), Photo.filename) + .all() + ) + + result = [] + for photo in photos: + # Get face count + face_count = ( + db.query(func.count(Face.id)) + .filter(Face.photo_id == photo.id) + .scalar() or 0 + ) + + # Get tags as comma-separated string (matching desktop GROUP_CONCAT) + tags_query = ( + db.query(Tag.tag_name) + .join(PhotoTagLinkage, Tag.id == PhotoTagLinkage.tag_id) + .filter(PhotoTagLinkage.photo_id == photo.id) + .order_by(Tag.tag_name) + .all() + ) + tags = ", ".join([t[0] for t in tags_query]) if tags_query else "" + + result.append({ + 'id': photo.id, + 'filename': photo.filename, + 'path': photo.path, + 'processed': photo.processed, + 'date_taken': photo.date_taken.isoformat() if photo.date_taken else None, + 'date_added': photo.date_added.isoformat() if photo.date_added else None, + 'face_count': face_count, + 'tags': tags, + }) + + return result +