diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py index bd90cba..3d6dc0d 100644 --- a/backend/app/routers/heaps.py +++ b/backend/app/routers/heaps.py @@ -39,6 +39,10 @@ class HeapConvertBody(BaseModel): target_id: str # folder id OR source root id mode: Literal['move', 'copy'] = 'move' 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 ───────────────────────────────────────────────────────────── @@ -217,24 +221,53 @@ async def convert_heap_to_folder( 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) + parent_dir = source_root.path + parent_source_root_id = 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: + parent_folder = folder_check.scalar_one_or_none() + if parent_folder is None: 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( 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. photo_result = await db.execute( select(Photo) diff --git a/frontend/src/components/heaps/HeapConvertDialog.tsx b/frontend/src/components/heaps/HeapConvertDialog.tsx index b5b135b..d913412 100644 --- a/frontend/src/components/heaps/HeapConvertDialog.tsx +++ b/frontend/src/components/heaps/HeapConvertDialog.tsx @@ -21,6 +21,7 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { const [targetId, setTargetId] = useState('') const [mode, setMode] = useState<'move' | 'copy'>('move') const [deleteHeap, setDeleteHeap] = useState(false) + const [subfolderName, setSubfolderName] = useState('') const { data: foldersData } = useQuery({ queryKey: ['folders'], @@ -36,12 +37,15 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { } }, [folders, targetId]) - // Reset state on close. + // Reset state on close, prefill subfolder name when opened. useEffect(() => { - if (!heap) { + if (heap) { + setSubfolderName(heap.name) + } else { setTargetId('') setMode('move') setDeleteHeap(false) + setSubfolderName('') } }, [heap]) @@ -51,6 +55,9 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { target_id: targetId, mode, 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) => { const total = (data.moved ?? 0) + (data.copied ?? 0) @@ -118,6 +125,25 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { )} + {/* Subfolder name */} +
+ {subfolderName.trim() && targetFolder + ? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.` + : 'Photos go directly into the parent folder.'} +
+