refactor: rename trash to discard end-to-end

User-facing labels and code now use "discard" (verb) and "Discarded"
(state/view label) instead of "trash" / "Trashed". The DB column names
stay (is_trashed / trashed_at) so no migration is required — only the
SQLAlchemy attribute names are renamed via Column('old_name', ...).

Backend
- Photo model: is_discarded / discarded_at attributes (DB columns
  unchanged).
- PhotoBase / PhotoResponse / PhotoUpdate schemas use the new field
  names.
- Photos list endpoint: is_discarded query param, filter logic.
- DELETE /photos/{id} now sets is_discarded; success message updated.
- Bulk action 'trash' renamed to 'discard'.
- backend/app/routers/trash.py renamed to discard.py with renamed
  functions and route prefix /api/v1/discard.
- main.py imports and mounts the discard router.
- tasks/scan.py marks missing files as is_discarded.

Frontend
- Photo TS type: is_discarded.
- PhotoThumbnail: shows the trash-can icon when is_discarded.
- RightSidebar: button label "Discard"; mutation field name; local
  variable rename.
- TopBar: discardPhotosMutation and "Discard" button; toast text
  "Discarded".
- LeftSidebar: virtual node id 'discarded' / label "Discarded".
- FilterBar / filterStore / useFilterUrlSync: FlagFilter enum value
  'trashed' → 'discarded'; backend param key is_discarded.
- KeyboardHints: X label "Discard".
- useKeyboardShortcuts: PhotoUpdate field rename, X handler.
- api.ts: /trash routes → /discard, trash export → discard,
  bulkUpdate trash field → discard.

Out of scope (intentional): the docker-compose trash_data volume,
backend/Dockerfile mkdir /data/trash, config.py TrashSettings, and
the spec doc — all unused since soft-discard, and renaming them is
churn for no benefit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 22:54:31 +02:00
parent 2679214cb9
commit 997e11db78
17 changed files with 82 additions and 81 deletions

View File

@@ -11,7 +11,7 @@ import os
from app.config import settings
from app.database import init_db
from app.routers import photos, folders, heaps, tags, trash, library
from app.routers import photos, folders, heaps, tags, discard, library
from app.services.scanner import start_initial_scan
# Configure logging
@@ -64,7 +64,7 @@ app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
app.include_router(trash.router, prefix="/api/v1/trash", tags=["trash"])
app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"])
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
@app.get("/")

View File

@@ -33,9 +33,10 @@ class Photo(Base):
added_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
# Trash status
is_trashed = Column(Boolean, default=False)
trashed_at = Column(DateTime)
# Discard status. The DB column names stay is_trashed/trashed_at to avoid
# a migration; only the Python attribute name reflects the rename.
is_discarded = Column('is_trashed', Boolean, default=False)
discarded_at = Column('trashed_at', DateTime)
# Thumbnail paths
thumb_small = Column(String) # path to 240px thumb
@@ -55,7 +56,7 @@ 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)
# Note: is_rejected was merged into is_trashed (a single soft "trashed"
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
# concept). The DB column may still exist on legacy installs but is no
# longer read or written.

View File

@@ -1,5 +1,5 @@
"""
Trash API router
Discard API router
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, and_
@@ -12,39 +12,39 @@ from app.models import Photo
router = APIRouter()
@router.get("")
async def list_trashed(db: AsyncSession = Depends(get_db)):
"""List trashed photos"""
async def list_discarded(db: AsyncSession = Depends(get_db)):
"""List discarded photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
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 trash"""
"""Restore photos from the discard pile"""
result = await db.execute(
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_trashed == True))
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
)
photos = result.scalars().all()
for photo in photos:
photo.is_trashed = False
photo.trashed_at = None
photo.is_discarded = False
photo.discarded_at = None
await db.commit()
return {"status": "success", "restored": len(photos)}
@router.delete("/empty")
async def empty_trash(db: AsyncSession = Depends(get_db)):
"""Permanently delete all trashed photos"""
async def empty_discard(db: AsyncSession = Depends(get_db)):
"""Permanently delete all discarded photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
select(Photo).where(Photo.is_discarded == True)
)
photos = result.scalars().all()
for photo in photos:
await db.delete(photo)
await db.commit()
return {"status": "success", "deleted": len(photos)}
return {"status": "success", "deleted": len(photos)}

View File

@@ -33,7 +33,7 @@ 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_trashed: Optional[bool] = False,
is_discarded: Optional[bool] = False,
heap_id: Optional[str] = None,
sort: str = "taken_at",
order: str = "desc",
@@ -93,8 +93,8 @@ async def list_photos(
if is_picked is not None:
filters.append(Photo.is_picked == is_picked)
# Trash filter — defaults to hiding trashed photos
filters.append(Photo.is_trashed == is_trashed)
# Discard filter — defaults to hiding discarded photos
filters.append(Photo.is_discarded == is_discarded)
# Apply all filters
if filters:
@@ -407,13 +407,13 @@ async def update_photo(
return PhotoResponse.from_orm(photo)
@router.delete("/{photo_id}")
async def trash_photo(
async def discard_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Soft-trash a photo: sets is_trashed=true. The file stays on disk so
"""Soft-discard a photo: sets is_discarded=true. The file stays on disk so
restore is just a flag flip. Permanent deletion happens via DELETE
/trash/{id} or DELETE /trash/empty.
/discard/{id} or DELETE /discard/empty.
"""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
@@ -423,11 +423,11 @@ async def trash_photo(
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
photo.is_discarded = True
photo.discarded_at = datetime.utcnow()
await db.commit()
return {"status": "success", "message": "Photo moved to trash"}
return {"status": "success", "message": "Photo discarded"}
@router.post("/bulk")
async def bulk_action(
@@ -445,14 +445,14 @@ async def bulk_action(
raise HTTPException(status_code=404, detail="No photos found")
# Perform action based on type
if action.action == 'trash':
if action.action == 'discard':
for photo in photos:
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
photo.is_discarded = True
photo.discarded_at = datetime.utcnow()
elif action.action == 'restore':
for photo in photos:
photo.is_trashed = False
photo.trashed_at = None
photo.is_discarded = False
photo.discarded_at = None
elif action.action == 'set_rating':
for photo in photos:
photo.rating = action.value
@@ -462,7 +462,7 @@ async def bulk_action(
elif action.action == 'pick':
for photo in photos:
photo.is_picked = True
photo.is_trashed = False
photo.is_discarded = False
else:
raise HTTPException(status_code=400, detail="Invalid action")

View File

@@ -29,8 +29,8 @@ class PhotoResponse(PhotoBase):
file_hash: Optional[str] = None
added_at: datetime
updated_at: Optional[datetime] = None
is_trashed: bool = False
trashed_at: Optional[datetime] = None
is_discarded: bool = False
discarded_at: Optional[datetime] = None
thumb_small: Optional[str] = None
thumb_medium: Optional[str] = None
thumb_large: Optional[str] = None
@@ -52,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_trashed: Optional[bool] = None
is_discarded: Optional[bool] = None
taken_at: Optional[datetime] = None
class PhotoListResponse(BaseModel):
@@ -66,5 +66,5 @@ class PhotoListResponse(BaseModel):
class BulkAction(BaseModel):
"""Bulk action on photos"""
ids: List[str]
action: str # 'trash', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick', 'reject'
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick'
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)

View File

@@ -292,7 +292,7 @@ async def handle_file_deletion(filepath: str):
if photo:
# Mark as missing or delete from database
photo.is_trashed = True
photo.trashed_at = datetime.utcnow()
photo.is_discarded = True
photo.discarded_at = datetime.utcnow()
await session.commit()
logger.info(f"Marked photo as trashed: {filepath}")
logger.info(f"Marked photo as discarded: {filepath}")