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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user