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:
@@ -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("/")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export function LeftSidebar() {
|
||||
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
@@ -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() {
|
||||
<button
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
is_trashed: !isTrashed,
|
||||
is_discarded: !isDiscarded,
|
||||
is_picked: false,
|
||||
})
|
||||
}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||
isTrashed
|
||||
isDiscarded
|
||||
? 'bg-reject/20 text-reject'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Trash
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
<button
|
||||
onClick={() => trashPhotosMutation.mutate()}
|
||||
disabled={trashPhotosMutation.isPending}
|
||||
onClick={() => discardPhotosMutation.mutate()}
|
||||
disabled={discardPhotosMutation.isPending}
|
||||
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||
title="Move to trash"
|
||||
title="Discard"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Trash
|
||||
Discard
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
|
||||
{photo.is_picked && (
|
||||
<Check className="h-4 w-4 text-pick" />
|
||||
)}
|
||||
{photo.is_trashed && (
|
||||
{photo.is_discarded && (
|
||||
<Trash2 className="h-4 w-4 text-reject" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<FilterState> {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string, string | number>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user