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:
2026-04-11 11:48:55 +02:00
parent 339e1be510
commit 30d03d8d4d
19 changed files with 1281 additions and 26 deletions

View File

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