refactor: merge reject into trash as a single soft-trash concept
The Photo model previously had two near-identical "negative culling"
states: is_rejected (a flag) and is_trashed (a flag plus a file move).
Lightroom users typically use one or the other, never both, and the
file-move semantics of the old trash made it harder to undo. Merging
into a single soft is_trashed flag — file stays on disk, restore is a
flag flip, permanent deletion still happens via DELETE /trash/empty.
Backend
- Drop is_rejected from PhotoBase, PhotoResponse, PhotoUpdate, the
list endpoint filter, and the bulk-action 'reject' branch.
- Add is_trashed to PhotoUpdate so the PATCH path can set it.
- Drop is_rejected Column declaration from the SQLAlchemy model. The
legacy DB column may persist on existing installs but is no longer
read or written; SQLAlchemy ignores extra columns.
- Rewrite DELETE /photos/{id} as a soft trash: just sets is_trashed=
true and trashed_at=now, no shutil.move. Permanent deletion still
goes through the trash router.
Frontend
- Photo TS type drops is_rejected, gains is_trashed.
- X keyboard shortcut now sets is_trashed=true (was is_rejected); U
clears both is_picked and is_trashed.
- RightSidebar Reject button → Trash button (Trash2 icon).
- PhotoThumbnail flag overlay shows Trash2 icon for trashed photos
instead of an X for rejected.
- KeyboardHints relabels X from "Reject" to "Trash".
- filterStore FlagFilter renames 'rejected' → 'trashed'; the params
builder now sends is_trashed=true for the trashed filter (the list
endpoint defaults to hiding trashed photos otherwise).
- FilterBar dropdown / URL sync allow-list updated accordingly.
No data migration: existing rejected photos remain as-is (flag stale)
and effectively become unflagged in the new model. Re-trash from the
UI to bring them into the new state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,9 @@ class Photo(Base):
|
||||
rating = Column(Integer, default=0) # 0-5 stars
|
||||
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
||||
is_picked = Column(Boolean, default=False)
|
||||
is_rejected = Column(Boolean, default=False)
|
||||
# Note: is_rejected was merged into is_trashed (a single soft "trashed"
|
||||
# concept). The DB column may still exist on legacy installs but is no
|
||||
# longer read or written.
|
||||
|
||||
# Duplicate detection
|
||||
is_duplicate = Column(Boolean, default=False)
|
||||
|
||||
@@ -33,7 +33,6 @@ async def list_photos(
|
||||
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
||||
color_label: Optional[str] = None,
|
||||
is_picked: Optional[bool] = None,
|
||||
is_rejected: Optional[bool] = None,
|
||||
is_trashed: Optional[bool] = False,
|
||||
heap_id: Optional[str] = None,
|
||||
sort: str = "taken_at",
|
||||
@@ -93,10 +92,8 @@ async def list_photos(
|
||||
# Flag filters
|
||||
if is_picked is not None:
|
||||
filters.append(Photo.is_picked == is_picked)
|
||||
if is_rejected is not None:
|
||||
filters.append(Photo.is_rejected == is_rejected)
|
||||
|
||||
# Trash filter
|
||||
|
||||
# Trash filter — defaults to hiding trashed photos
|
||||
filters.append(Photo.is_trashed == is_trashed)
|
||||
|
||||
# Apply all filters
|
||||
@@ -414,33 +411,22 @@ async def trash_photo(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Move photo to trash"""
|
||||
"""Soft-trash a photo: sets is_trashed=true. The file stays on disk so
|
||||
restore is just a flag flip. Permanent deletion happens via DELETE
|
||||
/trash/{id} or DELETE /trash/empty.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
# Move file to trash directory
|
||||
import shutil
|
||||
trash_dir = f"{settings.trash.path}/{photo_id}"
|
||||
os.makedirs(trash_dir, exist_ok=True)
|
||||
|
||||
trash_path = f"{trash_dir}/original{Path(photo.filepath).suffix}"
|
||||
|
||||
try:
|
||||
shutil.move(photo.filepath, trash_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to move file: {e}")
|
||||
|
||||
# Update database
|
||||
|
||||
photo.is_trashed = True
|
||||
photo.trashed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
return {"status": "success", "message": "Photo moved to trash"}
|
||||
|
||||
@router.post("/bulk")
|
||||
@@ -476,11 +462,7 @@ async def bulk_action(
|
||||
elif action.action == 'pick':
|
||||
for photo in photos:
|
||||
photo.is_picked = True
|
||||
photo.is_rejected = False
|
||||
elif action.action == 'reject':
|
||||
for photo in photos:
|
||||
photo.is_rejected = True
|
||||
photo.is_picked = False
|
||||
photo.is_trashed = False
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ class PhotoBase(BaseModel):
|
||||
rating: int = 0
|
||||
color_label: Optional[str] = None
|
||||
is_picked: bool = False
|
||||
is_rejected: bool = False
|
||||
|
||||
class PhotoResponse(PhotoBase):
|
||||
"""Photo response schema"""
|
||||
@@ -53,7 +52,7 @@ class PhotoUpdate(BaseModel):
|
||||
rating: Optional[int] = Field(None, ge=0, le=5)
|
||||
color_label: Optional[str] = None
|
||||
is_picked: Optional[bool] = None
|
||||
is_rejected: Optional[bool] = None
|
||||
is_trashed: Optional[bool] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
|
||||
class PhotoListResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user