feat: heap convert can create a subfolder under the target

The previous /heaps/{id}/convert dropped photos directly into a chosen
source root, which is rarely what you want — Lightroom-style behaviour
is "make a folder named after the collection inside the library".
Now the dialog lets you do that.

Backend
- HeapConvertBody gains an optional subfolder_name field. Path
  separators and dot-segments are rejected. When set, the handler
  joins it onto the resolved parent_dir, mkdir's it if missing, and
  uses the resulting path as the move/copy destination. Otherwise
  the parent_dir itself is used (unchanged behaviour).
- The Folder DB row for the destination is created via the existing
  scanner get_or_create_folder helper so dedupe + path normalization
  stay consistent across the codebase.
- The target source root id is propagated through both the source-
  root and folder branches so the new Folder row is correctly
  parented when subfolder_name is set on a folder target too.

Frontend
- HeapConvertDialog grows a "Subfolder name" input that prefills
  with the heap name when the dialog opens. Trimmed empty value
  drops directly into the parent. A live hint below the input
  shows exactly which path will be created (or that the parent
  will be used).
- api.ts heaps.convert() signature accepts an optional
  subfolder_name field; the dialog sends it via mutationFn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:31:14 +02:00
parent bed817d274
commit bb7c2b12d6
3 changed files with 77 additions and 12 deletions

View File

@@ -39,6 +39,10 @@ class HeapConvertBody(BaseModel):
target_id: str # folder id OR source root id target_id: str # folder id OR source root id
mode: Literal['move', 'copy'] = 'move' mode: Literal['move', 'copy'] = 'move'
delete_heap: bool = False delete_heap: bool = False
# Optional subfolder name to create inside the target. If provided, the
# actual destination is target_dir/subfolder_name (created if missing).
# Path separators and dot-segments are rejected.
subfolder_name: Optional[str] = None
# ── Endpoints ───────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────
@@ -217,24 +221,53 @@ async def convert_heap_to_folder(
source_root = sr_check.scalar_one_or_none() source_root = sr_check.scalar_one_or_none()
if source_root is not None: if source_root is not None:
target_dir = source_root.path parent_dir = source_root.path
from app.tasks.scan import get_or_create_folder parent_source_root_id = source_root.id
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
else: else:
folder_check = await db.execute( folder_check = await db.execute(
select(Folder).where(Folder.id == body.target_id) select(Folder).where(Folder.id == body.target_id)
) )
target_folder = folder_check.scalar_one_or_none() parent_folder = folder_check.scalar_one_or_none()
if target_folder is None: if parent_folder is None:
raise HTTPException(status_code=404, detail="Target folder not found") raise HTTPException(status_code=404, detail="Target folder not found")
target_dir = target_folder.path parent_dir = parent_folder.path
parent_source_root_id = parent_folder.source_root_id
if not os.path.isdir(target_dir): if not os.path.isdir(parent_dir):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Target directory does not exist: {target_dir}", detail=f"Target parent does not exist: {parent_dir}",
) )
# Resolve target_dir, creating an optional subfolder if requested.
if body.subfolder_name is not None:
sub = body.subfolder_name.strip()
if not sub:
raise HTTPException(status_code=400, detail="Subfolder name cannot be empty")
if '/' in sub or '\\' in sub or sub in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid subfolder name")
target_dir = os.path.join(parent_dir, sub)
if not os.path.exists(target_dir):
try:
os.makedirs(target_dir)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to create subfolder: {e}",
)
elif not os.path.isdir(target_dir):
raise HTTPException(
status_code=400,
detail=f"{target_dir} exists but is not a directory",
)
else:
target_dir = parent_dir
# Ensure a Folder row for the target, reusing the scanner helper so
# path normalization + dedupe stay consistent.
from app.tasks.scan import get_or_create_folder
target_folder = await get_or_create_folder(db, target_dir, parent_source_root_id)
# Fetch the heap's photos via the join table. # Fetch the heap's photos via the join table.
photo_result = await db.execute( photo_result = await db.execute(
select(Photo) select(Photo)

View File

@@ -21,6 +21,7 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const [targetId, setTargetId] = useState('') const [targetId, setTargetId] = useState('')
const [mode, setMode] = useState<'move' | 'copy'>('move') const [mode, setMode] = useState<'move' | 'copy'>('move')
const [deleteHeap, setDeleteHeap] = useState(false) const [deleteHeap, setDeleteHeap] = useState(false)
const [subfolderName, setSubfolderName] = useState('')
const { data: foldersData } = useQuery({ const { data: foldersData } = useQuery({
queryKey: ['folders'], queryKey: ['folders'],
@@ -36,12 +37,15 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
} }
}, [folders, targetId]) }, [folders, targetId])
// Reset state on close. // Reset state on close, prefill subfolder name when opened.
useEffect(() => { useEffect(() => {
if (!heap) { if (heap) {
setSubfolderName(heap.name)
} else {
setTargetId('') setTargetId('')
setMode('move') setMode('move')
setDeleteHeap(false) setDeleteHeap(false)
setSubfolderName('')
} }
}, [heap]) }, [heap])
@@ -51,6 +55,9 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
target_id: targetId, target_id: targetId,
mode, mode,
delete_heap: deleteHeap, delete_heap: deleteHeap,
// Empty subfolder = drop directly into the parent. Trim and only
// send if the user kept it populated.
subfolder_name: subfolderName.trim() || null,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
const total = (data.moved ?? 0) + (data.copied ?? 0) const total = (data.moved ?? 0) + (data.copied ?? 0)
@@ -118,6 +125,25 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
)} )}
</div> </div>
{/* Subfolder name */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">
Subfolder name
</label>
<input
type="text"
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="(none — use parent directly)"
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
<p className="mt-1 text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode toggle */} {/* Mode toggle */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Mode</label> <label className="mb-1 block text-xs text-text-muted">Mode</label>

View File

@@ -190,10 +190,16 @@ export const heaps = {
}, },
/** Convert a heap into a folder by moving (or copying) every member /** Convert a heap into a folder by moving (or copying) every member
* photo into the target directory. */ * photo into the target directory. Optionally creates a subfolder
* inside the target by name. */
convert: async ( convert: async (
heapId: string, heapId: string,
body: { target_id: string; mode: 'move' | 'copy'; delete_heap: boolean } body: {
target_id: string
mode: 'move' | 'copy'
delete_heap: boolean
subfolder_name?: string | null
}
) => { ) => {
const response = await api.post(`/heaps/${heapId}/convert`, body) const response = await api.post(`/heaps/${heapId}/convert`, body)
return response.data as { return response.data as {