Adds the destructive-action loop the discard concept needed:
- Click "Discarded" in the left sidebar → activates the discarded
filter; the timeline reloads showing discarded photos.
- DiscardActionBar appears at the top of the timeline only when the
discarded filter is active. Shows the count, a Restore button (when
photos are selected), and an Empty discard pile button.
- Empty action goes through a ConfirmDialog (new tiny reusable modal,
same overlay pattern as AddSourceFolderDialog).
- Restore goes through POST /api/v1/discard/restore.
- DELETE /api/v1/discard/empty now actually os.unlink()s the files
from disk in addition to removing the DB rows. Per-file failures
are logged and reported in the response so a single permission
error doesn't abort the batch.
Other library nodes wired in passing:
- "All Photos" → clearAll()
- "Rated" → setRatingMin(1)
- "Flagged" → setFlag('picked')
- "Discarded" → setFlag('discarded')
- "By Date" left unwired (needs a date-grouping UI)
Single-photo restore via the U keyboard shortcut already worked from
an earlier round, no change needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
69 lines
1.9 KiB
Python
69 lines
1.9 KiB
Python
"""
|
|
Discard API router
|
|
"""
|
|
import os
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models import Photo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("")
|
|
async def list_discarded(db: AsyncSession = Depends(get_db)):
|
|
"""List discarded photos"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.is_discarded == True)
|
|
)
|
|
photos = result.scalars().all()
|
|
return photos
|
|
|
|
@router.post("/restore")
|
|
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
|
|
"""Restore photos from the discard pile"""
|
|
result = await db.execute(
|
|
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
|
|
)
|
|
photos = result.scalars().all()
|
|
|
|
for photo in photos:
|
|
photo.is_discarded = False
|
|
photo.discarded_at = None
|
|
|
|
await db.commit()
|
|
return {"status": "success", "restored": len(photos)}
|
|
|
|
@router.delete("/empty")
|
|
async def empty_discard(db: AsyncSession = Depends(get_db)):
|
|
"""Permanently delete all discarded photos and unlink their files from
|
|
disk. Failures on individual files are logged but don't abort the batch.
|
|
"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.is_discarded == True)
|
|
)
|
|
photos = result.scalars().all()
|
|
|
|
deleted = 0
|
|
file_errors = 0
|
|
for photo in photos:
|
|
try:
|
|
if photo.filepath and os.path.exists(photo.filepath):
|
|
os.unlink(photo.filepath)
|
|
except OSError as e:
|
|
file_errors += 1
|
|
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
|
await db.delete(photo)
|
|
deleted += 1
|
|
|
|
await db.commit()
|
|
return {
|
|
"status": "success",
|
|
"deleted": deleted,
|
|
"file_errors": file_errors,
|
|
}
|