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.'}
+
+
+ {(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() {
>
+