diff --git a/frontend/src/pages/Tags.tsx b/frontend/src/pages/Tags.tsx index 6b84e13..cf5bb45 100644 --- a/frontend/src/pages/Tags.tsx +++ b/frontend/src/pages/Tags.tsx @@ -1423,7 +1423,7 @@ function TagSelectedPhotosDialog({ setPhotoTagsData(tagsData) } - // Get common tags across all selected photos (only single linkage type tags can be removed) + // Get common tags across all selected photos (both single and bulk) const commonTags = useMemo(() => { if (photos.length === 0 || selectedPhotoIds.length === 0) return [] @@ -1433,8 +1433,6 @@ function TagSelectedPhotosDialog({ allPhotoTags[photoId] = photoTagsData[photoId] || [] }) - // Find tags that exist in all selected photos with single linkage type (linkage_type = 0) - // Only tags that are single in ALL photos can be removed const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name])) // Get all unique tag IDs from all photos @@ -1443,34 +1441,40 @@ function TagSelectedPhotosDialog({ photoTags.forEach(tag => allTagIds.add(tag.tag_id)) }) - // Find tags that are: - // 1. Present in all selected photos - // 2. Have linkage_type = 0 (single) in all photos - const removableTags = Array.from(allTagIds).filter(tagId => { + // Find tags that are present in all selected photos + const commonTagIds = Array.from(allTagIds).filter(tagId => { // Check if tag exists in all photos - const existsInAll = selectedPhotoIds.every(photoId => { + return selectedPhotoIds.every(photoId => { const photoTags = allPhotoTags[photoId] || [] return photoTags.some(t => t.tag_id === tagId) }) - - if (!existsInAll) return false - - // 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 - }) - - return isSingleInAll }) - // Convert to tag objects - return removableTags + // Convert to tag objects with linkage type info + // Determine if tag is removable (single in all photos) or not (bulk in any photo) + return commonTagIds .map(tagId => { const tagName = tagIdToName.get(tagId) if (!tagName) return null - return { tag_id: tagId, tag_name: tagName, linkage_type: 0 } + + // 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[] }, [photos, tags, selectedPhotoIds, photoTagsData]) @@ -1528,33 +1532,38 @@ function TagSelectedPhotosDialog({

- Removable Tags (single tags present in all selected photos): + {selectedPhotoIds.length === 1 ? 'Tags:' : 'Common Tags:'}

-

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

{commonTags.length === 0 ? (

No common tags found

) : ( - commonTags.map(tag => ( -
- { - const newSet = new Set(selectedTagIds) - if (e.target.checked) { - newSet.add(tag.tag_id) - } else { - newSet.delete(tag.tag_id) - } - setSelectedTagIds(newSet) - }} - className="w-4 h-4" - /> - {tag.tag_name} -
- )) + 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) + } else { + newSet.delete(tag.tag_id) + } + setSelectedTagIds(newSet) + }} + disabled={!isRemovable} + className="w-4 h-4" + /> + + {tag.tag_name} + {!isRemovable && (bulk)} + +
+ ) + }) )}
diff --git a/src/core/photo_management.py b/src/core/photo_management.py index 099334c..3bb8c36 100644 --- a/src/core/photo_management.py +++ b/src/core/photo_management.py @@ -23,16 +23,22 @@ class PhotoManager: self.verbose = verbose def extract_photo_date(self, photo_path: str) -> Optional[str]: - """Extract date taken from photo EXIF data""" + """Extract date taken from photo with fallback to file modification time. + + Tries in order: + 1. EXIF date tags (DateTimeOriginal, DateTimeDigitized, DateTime) + 2. File modification time (as fallback) + """ + # First try EXIF date extraction try: with Image.open(photo_path) as image: exifdata = image.getexif() # Look for date taken in EXIF tags date_tags = [ - 306, # DateTime - 36867, # DateTimeOriginal - 36868, # DateTimeDigitized + 36867, # DateTimeOriginal - when photo was actually taken (highest priority) + 36868, # DateTimeDigitized - when photo was digitized + 306, # DateTime - file modification date (lowest priority) ] for tag_id in date_tags: @@ -50,12 +56,21 @@ class PhotoManager: return date_obj.strftime('%Y-%m-%d') except ValueError: continue - - return None except Exception as e: if self.verbose >= 2: - print(f" ⚠️ Could not extract date from {os.path.basename(photo_path)}: {e}") - return None + print(f" ⚠️ Could not extract EXIF date from {os.path.basename(photo_path)}: {e}") + + # Fallback to file modification time + try: + if os.path.exists(photo_path): + mtime = os.path.getmtime(photo_path) + mtime_date = datetime.fromtimestamp(mtime) + return mtime_date.strftime('%Y-%m-%d') + except Exception as e: + if self.verbose >= 2: + print(f" ⚠️ Could not get file modification time from {os.path.basename(photo_path)}: {e}") + + return None def scan_folder(self, folder_path: str, recursive: bool = True) -> int: """Scan folder for photos and add to database""" diff --git a/src/web/services/photo_service.py b/src/web/services/photo_service.py index 8e49668..b5ec05c 100644 --- a/src/web/services/photo_service.py +++ b/src/web/services/photo_service.py @@ -102,6 +102,36 @@ def extract_exif_date(image_path: str) -> Optional[date]: return None +def extract_photo_date(image_path: str) -> Optional[date]: + """Extract date taken from photo with fallback to file modification time. + + Tries in order: + 1. EXIF date tags (DateTimeOriginal, DateTimeDigitized, DateTime) + 2. File modification time (as fallback) + + Returns: + Date object or None if no date can be determined + """ + # First try EXIF date extraction + date_taken = extract_exif_date(image_path) + if date_taken: + return date_taken + + # Fallback to file modification time + try: + if os.path.exists(image_path): + mtime = os.path.getmtime(image_path) + mtime_date = datetime.fromtimestamp(mtime).date() + return mtime_date + except Exception as e: + # Log error for debugging (but don't fail the import) + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Failed to get file modification time from {image_path}: {e}") + + return None + + def find_photos_in_folder(folder_path: str, recursive: bool = True) -> list[str]: """Find all photo files in a folder.""" folder_path = os.path.abspath(folder_path) @@ -142,10 +172,17 @@ def import_photo_from_path( # Check if photo already exists by path existing = db.query(Photo).filter(Photo.path == photo_path).first() if existing: + # If existing photo doesn't have date_taken, try to update it + if existing.date_taken is None: + date_taken = extract_photo_date(photo_path) + if date_taken: + existing.date_taken = date_taken + db.commit() + db.refresh(existing) return existing, False - # Extract date taken (returns Date to match desktop schema) - date_taken = extract_exif_date(photo_path) + # Extract date taken with fallback to file modification time + date_taken = extract_photo_date(photo_path) # Create new photo record - match desktop schema exactly # Desktop schema: id, path, filename, date_added, date_taken (DATE), processed