feat: heap convert-to-folder + surface exact-duplicate detection

Two related polish items.

1. Heap convert to folder
   Closes a long-standing TODO from spec §6.10.
   - Backend: POST /heaps/{id}/convert with body
       { target_id, mode: 'move'|'copy', delete_heap: bool }
     target_id resolves either as a Folder id or a SourceRoot id (same
     convention as /photos/move). For each member photo, dispatches
     either shutil.move + photo.folder_id update, or shutil.copy2 +
     a new is_duplicate=true Photo row with all metadata copied. Name
     collisions on copy use the same " (copy N)" suffix scheme as
     /photos/copy. The heap row is optionally deleted on success.
     Per-photo failures are collected into the response instead of
     aborting the batch.
   - Frontend: new HeapConvertDialog with a target-folder dropdown
     (currently from sourceFolders.list, sub-folder picking is a
     follow-up), move/copy radio, and a "delete heap" checkbox.
     HeapsPanel rows get a hover FolderOutput button that opens it.
     Toast on success names the verb + count and notes whether the
     heap was deleted; invalidates heaps + photos + folders queries.

2. Surface exact-duplicate detection
   The scanner already sets Photo.is_duplicate=true when a SHA-256
   match is found, but nothing surfaced it. Now:
   - Backend list_photos accepts an optional is_duplicate query
     param so the frontend can filter duplicates-only views.
   - filterStore gains a duplicates: boolean field with setter, URL
     sync (?duplicates=true), filtersToParams entry, and a
     hasActiveFilters check.
   - LeftSidebar gets a new "Duplicates" library node (Copy icon)
     that clearAllFilters() + setDuplicates(true). isItemActive
     follows the filter so the highlight stays in sync after
     external filter changes.
   - PhotoThumbnail renders a small dark badge with the Copy icon
     bottom-right when photo.is_duplicate. Sits next to the existing
     basket / discard badges so the user can spot duplicates at a
     glance.
   - Photo TS type adds is_duplicate.

Perceptual-hash duplicate detection (re-encoded / resized matches)
is intentionally a follow-up — needs an imagehash dep, a phash
column, a backfill job, and similarity-search endpoint with
hamming-distance grouping. This commit only surfaces what the
scanner already finds via byte-level SHA-256 comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:28:18 +02:00
parent bc1e63095c
commit bed817d274
11 changed files with 421 additions and 6 deletions

View File

@@ -1,16 +1,22 @@
"""
Heaps API router
"""
from typing import Optional
import os
import shutil
import logging
from typing import Optional, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func, update, insert, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Heap
from app.models import Heap, Photo, Folder
from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -29,6 +35,12 @@ class HeapPhotosBody(BaseModel):
photo_ids: list[str]
class HeapConvertBody(BaseModel):
target_id: str # folder id OR source root id
mode: Literal['move', 'copy'] = 'move'
delete_heap: bool = False
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
@@ -180,6 +192,141 @@ async def add_photos_to_heap(
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
@router.post("/{heap_id}/convert")
async def convert_heap_to_folder(
heap_id: str,
body: HeapConvertBody,
db: AsyncSession = Depends(get_db),
):
"""Convert a heap into a folder by moving (or copying) every member
photo into the target directory. Optionally deletes the heap row at
the end.
target_id may be a Folder id or a SourceRoot id (matches the
/photos/move convention so the same dropdown can populate it).
"""
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = heap_result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
# Resolve target_id → (target_dir, target_folder)
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == body.target_id)
)
source_root = sr_check.scalar_one_or_none()
if source_root is not None:
target_dir = source_root.path
from app.tasks.scan import get_or_create_folder
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
else:
folder_check = await db.execute(
select(Folder).where(Folder.id == body.target_id)
)
target_folder = folder_check.scalar_one_or_none()
if target_folder is None:
raise HTTPException(status_code=404, detail="Target folder not found")
target_dir = target_folder.path
if not os.path.isdir(target_dir):
raise HTTPException(
status_code=400,
detail=f"Target directory does not exist: {target_dir}",
)
# Fetch the heap's photos via the join table.
photo_result = await db.execute(
select(Photo)
.join(heap_photos, Photo.id == heap_photos.c.photo_id)
.where(heap_photos.c.heap_id == heap_id)
)
photos = photo_result.scalars().all()
moved = 0
copied = 0
errors: list[dict] = []
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
if not os.path.exists(os.path.join(directory, filename)):
return filename
stem, ext = os.path.splitext(filename)
for i in range(1, 100):
suffix = '' if i == 1 else f' {i}'
candidate = f"{stem} (copy{suffix}){ext}"
if not os.path.exists(os.path.join(directory, candidate)):
return candidate
return None
for photo in photos:
if not os.path.exists(photo.filepath):
errors.append({"id": photo.id, "error": "source file missing"})
continue
if body.mode == 'move':
if photo.folder_id == target_folder.id:
continue # already there
new_path = os.path.join(target_dir, photo.filename)
if os.path.exists(new_path):
errors.append({"id": photo.id, "error": f"name collision: {photo.filename}"})
continue
try:
shutil.move(photo.filepath, new_path)
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue
photo.filepath = new_path
photo.folder_id = target_folder.id
moved += 1
else: # copy
new_name = _unique_target_name(target_dir, photo.filename)
if new_name is None:
errors.append({"id": photo.id, "error": "too many name collisions"})
continue
new_path = os.path.join(target_dir, new_name)
try:
shutil.copy2(photo.filepath, new_path)
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue
new_photo = Photo(
filepath=new_path,
filename=new_name,
folder_id=target_folder.id,
file_hash=photo.file_hash,
media_type=photo.media_type,
original_format=photo.original_format,
width=photo.width,
height=photo.height,
file_size=photo.file_size,
taken_at=photo.taken_at,
taken_at_source=photo.taken_at_source,
user_title=photo.user_title,
user_notes=photo.user_notes,
rating=photo.rating,
color_label=photo.color_label,
exif_json=photo.exif_json,
is_duplicate=True,
processing_status='pending',
)
db.add(new_photo)
copied += 1
if body.delete_heap:
await db.delete(heap)
await db.commit()
return {
"status": "success",
"mode": body.mode,
"moved": moved,
"copied": copied,
"errors": errors,
"heap_deleted": body.delete_heap,
}
@router.delete("/{heap_id}/photos")
async def remove_photos_from_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)

View File

@@ -37,6 +37,7 @@ async def list_photos(
rating_max: Optional[int] = Query(None, ge=0, le=5),
color_label: Optional[str] = None,
is_discarded: Optional[bool] = False,
is_duplicate: Optional[bool] = None,
heap_id: Optional[str] = None,
sort: str = "taken_at",
order: str = "desc",
@@ -113,6 +114,11 @@ async def list_photos(
# Discard filter — defaults to hiding discarded photos
filters.append(Photo.is_discarded == is_discarded)
# Duplicate filter — only applied when explicitly set, so the default
# view shows everything regardless of duplicate status.
if is_duplicate is not None:
filters.append(Photo.is_duplicate == is_duplicate)
# Heap membership filter — restrict to photos that belong to the heap.
if heap_id:
filters.append(

View File

@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, heaps as heapsApi, type Heap } from '../../services/api'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { toast } from '../ToastContainer'
interface HeapConvertDialogProps {
heap: Heap | null
onClose: () => void
}
/**
* Modal that converts a heap into a folder. The user picks a target folder
* (any source root, today — sub-folder picking is a follow-up), chooses
* move vs copy semantics, and optionally has the heap deleted on success.
*/
export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const queryClient = useQueryClient()
const [targetId, setTargetId] = useState('')
const [mode, setMode] = useState<'move' | 'copy'>('move')
const [deleteHeap, setDeleteHeap] = useState(false)
const { data: foldersData } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
enabled: !!heap,
})
const folders = foldersData?.folders ?? []
// Default to the first folder when the dialog opens or folders load.
useEffect(() => {
if (!targetId && folders.length > 0) {
setTargetId(folders[0].id)
}
}, [folders, targetId])
// Reset state on close.
useEffect(() => {
if (!heap) {
setTargetId('')
setMode('move')
setDeleteHeap(false)
}
}, [heap])
const convertMutation = useMutation({
mutationFn: () =>
heapsApi.convert(heap!.id, {
target_id: targetId,
mode,
delete_heap: deleteHeap,
}),
onSuccess: (data) => {
const total = (data.moved ?? 0) + (data.copied ?? 0)
const verb = data.mode === 'move' ? 'Moved' : 'Copied'
toast.success(
`${verb} ${total} photo${total === 1 ? '' : 's'}`,
data.heap_deleted ? `Heap "${heap?.name}" deleted` : undefined
)
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
onClose()
},
onError: (e: any) =>
toast.error('Convert failed', e?.response?.data?.detail || e.message),
})
if (!heap) return null
const targetFolder = folders.find((f: any) => f.id === targetId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">
Convert "{heap.name}" to folder
</h2>
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Target picker */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
>
{folders.map((f: any) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
{targetFolder && (
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
</p>
)}
</div>
{/* Mode toggle */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Mode</label>
<div className="flex gap-2">
<button
onClick={() => setMode('move')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'move'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Move
</button>
<button
onClick={() => setMode('copy')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'copy'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Copy
</button>
</div>
<p className="mt-1 text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap toggle */}
<div className="mb-4 flex items-center gap-2">
<input
id="delete-heap"
type="checkbox"
checked={deleteHeap}
onChange={(e) => setDeleteHeap(e.target.checked)}
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
/>
<label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</label>
</div>
{convertMutation.isError && (
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => convertMutation.mutate()}
disabled={!targetId || convertMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{convertMutation.isPending ? 'Converting…' : 'Convert'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -6,14 +6,16 @@ import {
X,
ChevronDown,
ChevronRight,
FolderOutput,
} from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { heaps as heapsApi } from '../../services/api'
import { heaps as heapsApi, type Heap } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { HeapConvertDialog } from './HeapConvertDialog'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
@@ -37,6 +39,7 @@ export function HeapsPanel() {
// Which heap row is currently being hovered with a drag — used to render
// the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -277,6 +280,16 @@ export function HeapsPanel() {
>
<Target className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConvertingHeap(heap)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="Convert to folder…"
>
<FolderOutput className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
@@ -294,6 +307,11 @@ export function HeapsPanel() {
})}
</div>
)}
<HeapConvertDialog
heap={convertingHeap}
onClose={() => setConvertingHeap(null)}
/>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import {
MoreHorizontal,
HardDrive,
RefreshCw,
Copy,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi } from '../../services/api'
@@ -41,7 +42,9 @@ export function LeftSidebar() {
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setFlag = useFilterStore((s) => s.setFlag)
const setFolderId = useFilterStore((s) => s.setFolderId)
const setDuplicates = useFilterStore((s) => s.setDuplicates)
const filterFolderId = useFilterStore((s) => s.folderId)
const filterDuplicates = useFilterStore((s) => s.duplicates)
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
@@ -129,6 +132,10 @@ export function LeftSidebar() {
clearAllFilters()
setFlag('discarded')
break
case 'duplicates':
clearAllFilters()
setDuplicates(true)
break
default:
if (id.startsWith('folder-')) {
// Folder rows: filter to that folder, clear other filters that
@@ -199,6 +206,7 @@ export function LeftSidebar() {
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
},
@@ -227,6 +235,9 @@ export function LeftSidebar() {
if (id === 'all-photos') {
return filterFolderId === null && selectedItem === 'all-photos'
}
if (id === 'duplicates') {
return filterDuplicates
}
return selectedItem === id
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check } from 'lucide-react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check, Copy } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
@@ -207,6 +207,14 @@ export function PhotoThumbnail({
<ShoppingBasket className="h-3 w-3" />
</div>
)}
{photo.is_duplicate && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white shadow-md"
title="Duplicate (matches another photo's hash)"
>
<Copy className="h-3 w-3" />
</div>
)}
{photo.is_discarded && (
<Trash2 className="h-4 w-4 text-reject" />
)}

View File

@@ -77,6 +77,8 @@ function parseUrl(): Partial<FilterState> {
if (ids.length > 0) out.tagIds = ids
}
if (sp.get('duplicates') === 'true') out.duplicates = true
const sortBy = sp.get('sort')
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
out.sortBy = sortBy as SortField
@@ -102,6 +104,7 @@ function writeUrl(f: FilterState) {
if (f.heapId) sp.set('heap_id', f.heapId)
if (f.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.duplicates) sp.set('duplicates', 'true')
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)

View File

@@ -21,6 +21,7 @@ export function usePhotosQuery() {
const heapId = useFilterStore((s) => s.heapId)
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -37,10 +38,11 @@ export function usePhotosQuery() {
heapId,
folderId,
tagIds,
duplicates,
sortBy,
sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, sortBy, sortOrder]
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, sortBy, sortOrder]
)
return useQuery({

View File

@@ -188,6 +188,23 @@ export const heaps = {
})
return response.data
},
/** Convert a heap into a folder by moving (or copying) every member
* photo into the target directory. */
convert: async (
heapId: string,
body: { target_id: string; mode: 'move' | 'copy'; delete_heap: boolean }
) => {
const response = await api.post(`/heaps/${heapId}/convert`, body)
return response.data as {
status: string
mode: 'move' | 'copy'
moved: number
copied: number
errors: Array<{ id: string; error: string }>
heap_deleted: boolean
}
},
}
// Tags API

View File

@@ -26,6 +26,8 @@ export interface FilterState {
folderId: string | null
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
tagIds: string[]
/** When true, restrict to photos flagged as duplicates by the scanner. */
duplicates: boolean
sortBy: SortField
sortOrder: SortOrder
}
@@ -44,6 +46,7 @@ interface FilterStore extends FilterState {
setFolderId: (id: string | null) => void
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setDuplicates: (v: boolean) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
toggleSortOrder: () => void
@@ -66,6 +69,7 @@ export const INITIAL_FILTERS: FilterState = {
heapId: null,
folderId: null,
tagIds: [],
duplicates: false,
sortBy: 'taken_at',
sortOrder: 'desc',
}
@@ -95,6 +99,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
? s.tagIds.filter((t) => t !== id)
: [...s.tagIds, id],
})),
setDuplicates: (duplicates) => set({ duplicates }),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
toggleSortOrder: () =>
@@ -121,6 +126,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.heapId) params.heap_id = f.heapId
if (f.folderId) params.folder_id = f.folderId
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
if (f.duplicates) params.is_duplicate = 'true'
params.sort = f.sortBy
params.order = f.sortOrder
return params
@@ -138,6 +144,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.flag !== 'any' ||
f.heapId !== null ||
f.folderId !== null ||
f.tagIds.length > 0
f.tagIds.length > 0 ||
f.duplicates
)
}

View File

@@ -8,6 +8,7 @@ export interface Photo {
taken_at: string | null
rating: number
is_discarded: boolean
is_duplicate: boolean
file_hash: string
thumb_small?: string
thumb_medium?: string