feat: Enhance tag management in TagSelectedPhotosDialog with improved logic for removable tags
This commit updates the TagSelectedPhotosDialog to allow both single and bulk tags to be managed more effectively. The logic for determining removable tags has been refined, ensuring that users can see which tags are common across selected photos and whether they can be removed. Additionally, the photo date extraction method in PhotoManager has been improved to include a fallback to file modification time, enhancing reliability. The photo service now also utilizes this updated method for date extraction, ensuring consistency across the application. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
f7accb925d
commit
52344febad
@ -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({
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm text-gray-700 mb-2">
|
||||
Removable Tags (single tags present in all selected photos):
|
||||
{selectedPhotoIds.length === 1 ? 'Tags:' : 'Common Tags:'}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Note: Bulk tags cannot be removed from this dialog
|
||||
</p>
|
||||
{commonTags.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No common tags found</p>
|
||||
) : (
|
||||
commonTags.map(tag => (
|
||||
<div key={tag.tag_id} className="flex items-center gap-2 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTagIds.has(tag.tag_id)}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className="flex-1">{tag.tag_name}</span>
|
||||
</div>
|
||||
))
|
||||
commonTags.map(tag => {
|
||||
const isRemovable = tag.isRemovable !== false
|
||||
return (
|
||||
<div key={tag.tag_id} className="flex items-center gap-2 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTagIds.has(tag.tag_id)}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className={`flex-1 ${!isRemovable ? 'text-gray-400' : ''}`}>
|
||||
{tag.tag_name}
|
||||
{!isRemovable && <span className="text-xs ml-2">(bulk)</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user