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
-
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