From bed817d2744204d659e4bc381422d03a51fa3250 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 11:28:18 +0200 Subject: [PATCH] feat: heap convert-to-folder + surface exact-duplicate detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/routers/heaps.py | 151 +++++++++++++- backend/app/routers/photos.py | 6 + .../components/heaps/HeapConvertDialog.tsx | 195 ++++++++++++++++++ frontend/src/components/heaps/HeapsPanel.tsx | 20 +- .../src/components/layout/LeftSidebar.tsx | 11 + .../components/timeline/PhotoThumbnail.tsx | 10 +- frontend/src/hooks/useFilterUrlSync.ts | 3 + frontend/src/hooks/usePhotosQuery.ts | 4 +- frontend/src/services/api.ts | 17 ++ frontend/src/store/filterStore.ts | 9 +- frontend/src/types/photo.ts | 1 + 11 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/heaps/HeapConvertDialog.tsx diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py index 49f02c4..bd90cba 100644 --- a/backend/app/routers/heaps.py +++ b/backend/app/routers/heaps.py @@ -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) diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 7996841..ab62f40 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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( diff --git a/frontend/src/components/heaps/HeapConvertDialog.tsx b/frontend/src/components/heaps/HeapConvertDialog.tsx new file mode 100644 index 0000000..b5b135b --- /dev/null +++ b/frontend/src/components/heaps/HeapConvertDialog.tsx @@ -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 ( +
+
+ +
+
+

+ Convert "{heap.name}" to folder +

+ +
+ + {/* Target picker */} +
+ + {folders.length === 0 ? ( +
+ No folders available +
+ ) : ( + + )} + {targetFolder && ( +

+ + {targetFolder.path} +

+ )} +
+ + {/* Mode toggle */} +
+ +
+ + +
+

+ {mode === 'move' + ? 'Files are moved on disk; original photos update their folder.' + : 'Files are copied on disk; new photo records are created.'} +

+
+ + {/* Delete heap toggle */} +
+ 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" + /> + +
+ + {convertMutation.isError && ( +
+ + {(convertMutation.error as any)?.message || 'Conversion failed'} +
+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index 655e0b0..8e280e1 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -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(null) + const [convertingHeap, setConvertingHeap] = useState(null) const invalidate = () => { queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) @@ -277,6 +280,16 @@ export function HeapsPanel() { > +
)} + + setConvertingHeap(null)} + /> ) } diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index e2939e7..549ab7c 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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(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: , count: 0 }, { id: 'rated', label: 'Rated', icon: , count: 0 }, + { id: 'duplicates', label: 'Duplicates', icon: , count: 0 }, { id: 'discarded', label: 'Discarded', icon: , 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 } diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index b9642c5..063c65f 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -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({ )} + {photo.is_duplicate && ( +
+ +
+ )} {photo.is_discarded && ( )} diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index fc5f866..b367664 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -77,6 +77,8 @@ function parseUrl(): Partial { 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) diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 7f61579..0e70b07 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -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({ diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e6fd91a..7142191 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 953d35f..d4ab5e0 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -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((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 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 ) } diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index 9aab62e..2e1de7e 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -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