feat: editable taken_at + folder-based date repair and filter
Lets operators fix corrupted capture dates at scale. Adds an editable Date Taken field with a folder/filename-derived suggestion hint, a bulk Date Taken section in the multi-select sidebar that either applies one date to the whole selection or infers a per-photo date from each path, a warning badge on thumbnails whose stored date disagrees with the path, and a "Date issues" filter pill so suspicious photos can be surfaced and fixed as a group. Edits are written back to EXIF on disk so rescans don't clobber the fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ from app.models.folders import SourceRoot
|
||||
from app.models.heaps import heap_photos
|
||||
from app.models.tags import photo_tags
|
||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||
from app.services.exif_writer import ExifWriteError, write_taken_at
|
||||
from app.services.date_guess import has_date_warning as compute_date_warning
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
@@ -39,6 +41,7 @@ async def list_photos(
|
||||
color_label: Optional[str] = None,
|
||||
is_discarded: Optional[bool] = False,
|
||||
is_duplicate: Optional[bool] = None,
|
||||
has_date_warning: Optional[bool] = None,
|
||||
heap_id: Optional[str] = None,
|
||||
sort: str = "taken_at",
|
||||
order: str = "desc",
|
||||
@@ -149,6 +152,8 @@ async def list_photos(
|
||||
# view shows everything regardless of duplicate status.
|
||||
if is_duplicate is not None:
|
||||
filters.append(Photo.is_duplicate == is_duplicate)
|
||||
if has_date_warning is not None:
|
||||
filters.append(Photo.has_date_warning == has_date_warning)
|
||||
|
||||
# Heap membership filter — restrict to photos that belong to the heap.
|
||||
if heap_id:
|
||||
@@ -642,6 +647,28 @@ async def update_photo(
|
||||
photo.filename = new_name
|
||||
photo.filepath = new_path
|
||||
|
||||
# taken_at edits write EXIF first, DB second — we'd rather surface a
|
||||
# failure than leave the DB ahead of the file on disk. On success the
|
||||
# source flips to 'manual' so the UI can render a badge and the next
|
||||
# rescan knows not to overwrite it.
|
||||
if 'taken_at' in update_data:
|
||||
new_dt = update_data.pop('taken_at')
|
||||
if new_dt is not None:
|
||||
try:
|
||||
await write_taken_at(photo.filepath, new_dt)
|
||||
except ExifWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to write EXIF: {exc}",
|
||||
)
|
||||
photo.taken_at = new_dt
|
||||
photo.taken_at_source = 'manual'
|
||||
# Recompute warning: a manual edit usually clears it (user just
|
||||
# told us the right date), but if they set it to something that
|
||||
# still disagrees with the folder path we'd rather keep the
|
||||
# flag up than pretend the problem's gone.
|
||||
photo.has_date_warning = compute_date_warning(photo.filepath, new_dt)
|
||||
|
||||
# Apply remaining updates
|
||||
for field, value in update_data.items():
|
||||
setattr(photo, field, value)
|
||||
@@ -921,6 +948,64 @@ async def bulk_action(
|
||||
elif action.action == 'set_color':
|
||||
for photo in photos:
|
||||
photo.color_label = action.value
|
||||
elif action.action in ('set_taken_at', 'set_taken_at_map'):
|
||||
# Two shapes share one code path:
|
||||
# set_taken_at → value is one ISO datetime, applied to every id
|
||||
# set_taken_at_map → value is {photo_id: iso datetime}, per-photo
|
||||
# The per-photo variant is what the "guess from folder" bulk flow
|
||||
# uses when every selected photo gets a different date.
|
||||
if action.action == 'set_taken_at':
|
||||
if not isinstance(action.value, str) or not action.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="set_taken_at requires an ISO datetime string",
|
||||
)
|
||||
try:
|
||||
uniform_dt = datetime.fromisoformat(action.value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid ISO datetime for set_taken_at",
|
||||
)
|
||||
date_map = {p.id: uniform_dt for p in photos}
|
||||
else:
|
||||
if not isinstance(action.value, dict) or not action.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="set_taken_at_map requires a {id: iso} mapping",
|
||||
)
|
||||
date_map = {}
|
||||
for pid, raw in action.value.items():
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
try:
|
||||
date_map[pid] = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
updated = 0
|
||||
errors: list[dict[str, str]] = []
|
||||
for photo in photos:
|
||||
new_dt = date_map.get(photo.id)
|
||||
if new_dt is None:
|
||||
continue
|
||||
try:
|
||||
await write_taken_at(photo.filepath, new_dt)
|
||||
except ExifWriteError as exc:
|
||||
errors.append({"id": photo.id, "message": str(exc)})
|
||||
continue
|
||||
photo.taken_at = new_dt
|
||||
photo.taken_at_source = 'manual'
|
||||
photo.has_date_warning = compute_date_warning(photo.filepath, new_dt)
|
||||
updated += 1
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"updated": updated,
|
||||
"skipped": len(photos) - updated - len(errors),
|
||||
"errors": errors,
|
||||
}
|
||||
elif action.action == 'add_tags':
|
||||
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
|
||||
# rows for every (photo, tag) combination that doesn't already
|
||||
|
||||
Reference in New Issue
Block a user