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

@@ -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
)
}