From 997e11db78c435cd3e317f3ec503dfaa4d92919f Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Apr 2026 22:54:31 +0200 Subject: [PATCH] refactor: rename trash to discard end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/main.py | 4 +-- backend/app/models/photos.py | 9 +++--- backend/app/routers/{trash.py => discard.py} | 32 +++++++++---------- backend/app/routers/photos.py | 30 ++++++++--------- backend/app/schemas/photos.py | 8 ++--- backend/app/tasks/scan.py | 6 ++-- frontend/src/components/KeyboardHints.tsx | 2 +- frontend/src/components/filter/FilterBar.tsx | 2 +- .../src/components/layout/LeftSidebar.tsx | 2 +- .../src/components/layout/RightSidebar.tsx | 14 ++++---- frontend/src/components/layout/TopBar.tsx | 18 +++++------ .../components/timeline/PhotoThumbnail.tsx | 2 +- frontend/src/hooks/useFilterUrlSync.ts | 2 +- frontend/src/hooks/useKeyboardShortcuts.ts | 14 ++++---- frontend/src/services/api.ts | 12 +++---- frontend/src/store/filterStore.ts | 4 +-- frontend/src/types/photo.ts | 2 +- 17 files changed, 82 insertions(+), 81 deletions(-) rename backend/app/routers/{trash.py => discard.py} (62%) diff --git a/backend/app/main.py b/backend/app/main.py index 6ee03bf..158891a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py index 4f28da4..e7c3791 100644 --- a/backend/app/models/photos.py +++ b/backend/app/models/photos.py @@ -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. diff --git a/backend/app/routers/trash.py b/backend/app/routers/discard.py similarity index 62% rename from backend/app/routers/trash.py rename to backend/app/routers/discard.py index 7f6a54e..8604ecd 100644 --- a/backend/app/routers/trash.py +++ b/backend/app/routers/discard.py @@ -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)} \ No newline at end of file + return {"status": "success", "deleted": len(photos)} diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 4b052fb..0b963b7 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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") diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py index baaf710..70a703b 100644 --- a/backend/app/schemas/photos.py +++ b/backend/app/schemas/photos.py @@ -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) \ No newline at end of file diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 0321348..c21f19f 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -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}") \ No newline at end of file + logger.info(f"Marked photo as discarded: {filepath}") \ No newline at end of file diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx index 6fa2a10..5b19cd3 100644 --- a/frontend/src/components/KeyboardHints.tsx +++ b/frontend/src/components/KeyboardHints.tsx @@ -12,7 +12,7 @@ export function KeyboardHints() { ? [ { key: '1-5', action: 'Rate' }, { key: 'P', action: 'Pick' }, - { key: 'X', action: 'Trash' }, + { key: 'X', action: 'Discard' }, { key: 'E / Space', action: 'Preview' }, { key: 'Esc', action: 'Deselect' }, ] diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index 47b8ed3..1741e70 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -26,7 +26,7 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [ const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [ { value: 'any', label: 'Any' }, { value: 'picked', label: 'Picked' }, - { value: 'trashed', label: 'Trashed' }, + { value: 'discarded', label: 'Discarded' }, { value: 'unflagged', label: 'Unflagged' }, ] diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 48339cb..453add1 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -111,7 +111,7 @@ export function LeftSidebar() { { id: 'by-date', label: 'By Date', icon: }, { id: 'rated', label: 'Rated', icon: , count: 0 }, { id: 'flagged', label: 'Flagged', icon: , count: 0 }, - { id: 'trash', label: 'Trash', icon: , count: 0 }, + { id: 'discarded', label: 'Discarded', icon: , count: 0 }, ], }, { diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 7b72c6e..6207b8a 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -27,7 +27,7 @@ interface PhotoDetails { taken_at: string | null rating: number is_picked: boolean - is_trashed: boolean + is_discarded: boolean exif_json: string | null } @@ -108,7 +108,7 @@ export function RightSidebar() { mutationFn: (data: { rating?: number is_picked?: boolean - is_trashed?: boolean + is_discarded?: boolean }) => photosApi.update(activePhotoId!, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] }) @@ -132,7 +132,7 @@ export function RightSidebar() { const multipleSelected = selectedPhotos.length > 1 const rating = photo?.rating ?? 0 const isPicked = photo?.is_picked ?? false - const isTrashed = photo?.is_trashed ?? false + const isDiscarded = photo?.is_discarded ?? false return (
@@ -187,7 +187,7 @@ export function RightSidebar() { onClick={() => updateMutation.mutate({ is_picked: !isPicked, - is_trashed: false, + is_discarded: false, }) } className={clsx( @@ -203,19 +203,19 @@ export function RightSidebar() {
diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 4831b05..fc73b25 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -58,18 +58,18 @@ export function TopBar() { const selectedCount = selectedPhotos.length const queryClient = useQueryClient() - // Mutation for moving photos to trash - const trashPhotosMutation = useMutation({ + // Mutation for discarding selected photos + const discardPhotosMutation = useMutation({ mutationFn: async () => { - await photos.bulkUpdate(selectedPhotos, { trash: true }) + await photos.bulkUpdate(selectedPhotos, { discard: true }) }, onSuccess: () => { - toast.success('Moved to Trash', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} moved to trash`) + toast.success('Discarded', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} discarded`) clearSelection() queryClient.invalidateQueries({ queryKey: ['photos'] }) }, onError: (error: any) => { - toast.error('Failed to Move to Trash', error.message || 'An error occurred') + toast.error('Failed to discard', error.message || 'An error occurred') }, }) @@ -96,13 +96,13 @@ export function TopBar() { {selectedCount} selected )} diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index 21ac608..8d8ef42 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -171,7 +171,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick {photo.is_picked && ( )} - {photo.is_trashed && ( + {photo.is_discarded && ( )} diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index c9a995a..978ac37 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [ 'blue', 'purple', ] -const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'trashed', 'unflagged'] +const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'discarded', 'unflagged'] function parseUrl(): Partial { const sp = new URLSearchParams(window.location.search) diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 709d244..4b388b2 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -14,7 +14,7 @@ interface KeyboardShortcutsProps { interface PhotoUpdate { rating?: number is_picked?: boolean - is_trashed?: boolean + is_discarded?: boolean color_label?: string | null } @@ -104,24 +104,24 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS) - // Pick / trash / unflag. Trash is the merged "rejected" concept — a soft - // flag that hides the photo from the default timeline view; restore via - // the trash view (or the U shortcut). + // Pick / discard / unflag. Discard is a soft flag that hides the photo + // from the default timeline view; restore via the Discarded view (or the + // U shortcut). useHotkeys( 'p', - () => updateActive({ is_picked: true, is_trashed: false }), + () => updateActive({ is_picked: true, is_discarded: false }), HK_OPTS ) useHotkeys( 'x', - () => updateActive({ is_trashed: true, is_picked: false }), + () => updateActive({ is_discarded: true, is_picked: false }), HK_OPTS ) useHotkeys( 'u', - () => updateActive({ is_picked: false, is_trashed: false }), + () => updateActive({ is_picked: false, is_discarded: false }), HK_OPTS ) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f67e345..be7d72e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -69,7 +69,7 @@ export const photos = { rating?: number flag?: string heap_id?: string - trash?: boolean + discard?: boolean }) => { const response = await api.post('/photos/bulk', { photo_ids: photoIds, @@ -169,22 +169,22 @@ export const tags = { }, } -// Trash API -export const trash = { +// Discard API +export const discard = { list: async () => { - const response = await api.get('/trash') + const response = await api.get('/discard') return response.data }, restore: async (photoIds: string[]) => { - const response = await api.post('/trash/restore', { + const response = await api.post('/discard/restore', { photo_ids: photoIds, }) return response.data }, empty: async () => { - const response = await api.delete('/trash/empty') + const response = await api.delete('/discard/empty') return response.data }, } diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 4feabf9..d295bdf 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' export type MediaType = 'photo' | 'video' | 'raw' | 'heic' export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' -export type FlagFilter = 'any' | 'picked' | 'trashed' | 'unflagged' +export type FlagFilter = 'any' | 'picked' | 'discarded' | 'unflagged' export interface FilterState { q: string @@ -77,7 +77,7 @@ export function filtersToParams(f: FilterState): Record if (f.ratingMin > 0) params.rating_min = f.ratingMin if (f.colorLabel) params.color_label = f.colorLabel if (f.flag === 'picked') params.is_picked = 'true' - else if (f.flag === 'trashed') params.is_trashed = 'true' + else if (f.flag === 'discarded') params.is_discarded = 'true' else if (f.flag === 'unflagged') params.is_picked = 'false' return params } diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index eae9540..e187a34 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -8,7 +8,7 @@ export interface Photo { taken_at: string | null rating: number is_picked: boolean - is_trashed: boolean + is_discarded: boolean file_hash: string thumb_small?: string thumb_medium?: string