The heap row used to fan out three small icon buttons (set active, convert
to folder, delete) on hover, which crowded the row and didn't leave room
for new actions. Collapse the destructive / occasional ones into a kebab
menu and add the missing operations.
- Right-aligned action cluster: active indicator → count badge → target
toggle (when not active) → kebab menu, all flex-shrink-0 so the name
truncates first.
- Kebab menu items: Rename, Duplicate, Move to folder…, Delete. Outside
click and Escape close the popover; the trigger has aria-haspopup +
aria-expanded. Delete still confirms via window.confirm.
- Inline rename: double-click a heap row OR pick Rename from the menu
to edit the name in place. Enter commits, Escape cancels. Mirrors the
folder rename pattern in LeftSidebar.
- backend: new POST /heaps/{id}/duplicate creates a copy with the same
membership ("{name} (copy)") via INSERT...SELECT on heap_photos.
Never marks the new heap as active so duplicating doesn't quietly
steal the user's T-key destination.
- api.ts: heaps.duplicate wrapper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
424 lines
14 KiB
Python
424 lines
14 KiB
Python
"""
|
|
Heaps API router
|
|
"""
|
|
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, Photo, Folder
|
|
from app.models.folders import SourceRoot
|
|
from app.models.heaps import heap_photos
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Schemas ───────────────────────────────────────────────────────────────
|
|
|
|
class HeapCreate(BaseModel):
|
|
name: str
|
|
|
|
|
|
class HeapUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
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
|
|
# 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 ─────────────────────────────────────────────────────────────
|
|
|
|
@router.get("")
|
|
async def list_heaps(db: AsyncSession = Depends(get_db)):
|
|
"""List all heaps with photo counts."""
|
|
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
|
count_subq = (
|
|
select(
|
|
heap_photos.c.heap_id,
|
|
func.count(heap_photos.c.photo_id).label("photo_count"),
|
|
)
|
|
.group_by(heap_photos.c.heap_id)
|
|
.subquery()
|
|
)
|
|
|
|
stmt = (
|
|
select(Heap, count_subq.c.photo_count)
|
|
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
|
.order_by(Heap.created_at.asc())
|
|
)
|
|
result = await db.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
"id": h.id,
|
|
"name": h.name,
|
|
"is_active": bool(h.is_active),
|
|
"created_at": h.created_at,
|
|
"updated_at": h.updated_at,
|
|
"photo_count": int(count or 0),
|
|
}
|
|
for h, count in rows
|
|
]
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
|
"""Create a new heap."""
|
|
name = (body.name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Heap name is required")
|
|
heap = Heap(name=name)
|
|
db.add(heap)
|
|
await db.commit()
|
|
await db.refresh(heap)
|
|
return {
|
|
"id": heap.id,
|
|
"name": heap.name,
|
|
"is_active": bool(heap.is_active),
|
|
"created_at": heap.created_at,
|
|
"updated_at": heap.updated_at,
|
|
"photo_count": 0,
|
|
}
|
|
|
|
|
|
@router.patch("/{heap_id}")
|
|
async def update_heap(
|
|
heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Rename a heap and/or toggle active state. Setting is_active=true on
|
|
one heap deactivates all others (single-active invariant)."""
|
|
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
|
heap = result.scalar_one_or_none()
|
|
if not heap:
|
|
raise HTTPException(status_code=404, detail="Heap not found")
|
|
|
|
if body.name is not None:
|
|
name = body.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Heap name is required")
|
|
heap.name = name
|
|
|
|
if body.is_active is not None:
|
|
if body.is_active:
|
|
# Clear active flag on all other heaps in one statement
|
|
await db.execute(update(Heap).values(is_active=False))
|
|
heap.is_active = True
|
|
else:
|
|
heap.is_active = False
|
|
|
|
await db.commit()
|
|
await db.refresh(heap)
|
|
return {
|
|
"id": heap.id,
|
|
"name": heap.name,
|
|
"is_active": bool(heap.is_active),
|
|
"created_at": heap.created_at,
|
|
"updated_at": heap.updated_at,
|
|
}
|
|
|
|
|
|
@router.post("/{heap_id}/duplicate", status_code=201)
|
|
async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
|
"""Create a new heap with the same membership as an existing one. The
|
|
new heap is named "{original} (copy)" and is never the active target —
|
|
duplicating shouldn't quietly steal the user's T-key destination.
|
|
"""
|
|
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
|
source = result.scalar_one_or_none()
|
|
if not source:
|
|
raise HTTPException(status_code=404, detail="Heap not found")
|
|
|
|
new_heap = Heap(name=f"{source.name} (copy)", is_active=False)
|
|
db.add(new_heap)
|
|
await db.flush() # populate new_heap.id without committing yet
|
|
|
|
# Bulk-copy the membership rows. SELECT photo_id FROM heap_photos WHERE
|
|
# heap_id = :src — INSERT each into the new heap. Done as a single
|
|
# INSERT...SELECT to avoid round-tripping ids through Python.
|
|
member_rows = await db.execute(
|
|
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
|
)
|
|
photo_ids = [row[0] for row in member_rows.all()]
|
|
if photo_ids:
|
|
await db.execute(
|
|
insert(heap_photos),
|
|
[{"heap_id": new_heap.id, "photo_id": pid} for pid in photo_ids],
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(new_heap)
|
|
return {
|
|
"id": new_heap.id,
|
|
"name": new_heap.name,
|
|
"is_active": False,
|
|
"photo_count": len(photo_ids),
|
|
"created_at": new_heap.created_at,
|
|
"updated_at": new_heap.updated_at,
|
|
}
|
|
|
|
|
|
@router.delete("/{heap_id}", status_code=204)
|
|
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
|
"""Delete a heap. Photos themselves are unaffected — only the membership
|
|
rows in heap_photos cascade-delete."""
|
|
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
|
heap = result.scalar_one_or_none()
|
|
if not heap:
|
|
raise HTTPException(status_code=404, detail="Heap not found")
|
|
await db.delete(heap)
|
|
await db.commit()
|
|
return None
|
|
|
|
|
|
@router.get("/{heap_id}/photo_ids")
|
|
async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
|
|
"""Return just the photo ids belonging to a heap. Used by the frontend
|
|
to maintain a fast client-side membership lookup for the active heap
|
|
(for the basket affordance on thumbnails) without fetching full photo
|
|
records."""
|
|
result = await db.execute(
|
|
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
|
)
|
|
return [row[0] for row in result.all()]
|
|
|
|
|
|
@router.post("/{heap_id}/photos")
|
|
async def add_photos_to_heap(
|
|
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Add photos to a heap. Idempotent: re-adding existing members is a
|
|
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
|
|
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
|
heap = result.scalar_one_or_none()
|
|
if not heap:
|
|
raise HTTPException(status_code=404, detail="Heap not found")
|
|
|
|
if not body.photo_ids:
|
|
return {"status": "success", "added": 0}
|
|
|
|
# Find which ids are already members so we don't violate the PK.
|
|
existing = await db.execute(
|
|
select(heap_photos.c.photo_id).where(
|
|
heap_photos.c.heap_id == heap_id,
|
|
heap_photos.c.photo_id.in_(body.photo_ids),
|
|
)
|
|
)
|
|
existing_ids = {row[0] for row in existing.all()}
|
|
new_ids = [pid for pid in body.photo_ids if pid not in existing_ids]
|
|
|
|
if new_ids:
|
|
await db.execute(
|
|
insert(heap_photos),
|
|
[{"heap_id": heap_id, "photo_id": pid} for pid in new_ids],
|
|
)
|
|
await db.commit()
|
|
|
|
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:
|
|
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)
|
|
)
|
|
parent_folder = folder_check.scalar_one_or_none()
|
|
if parent_folder is None:
|
|
raise HTTPException(status_code=404, detail="Target folder not found")
|
|
parent_dir = parent_folder.path
|
|
parent_source_root_id = parent_folder.source_root_id
|
|
|
|
if not os.path.isdir(parent_dir):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
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)
|
|
.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)
|
|
):
|
|
"""Remove photos from a heap. Removing a non-member is a no-op."""
|
|
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
|
heap = result.scalar_one_or_none()
|
|
if not heap:
|
|
raise HTTPException(status_code=404, detail="Heap not found")
|
|
|
|
if not body.photo_ids:
|
|
return {"status": "success", "removed": 0}
|
|
|
|
res = await db.execute(
|
|
delete(heap_photos).where(
|
|
heap_photos.c.heap_id == heap_id,
|
|
heap_photos.c.photo_id.in_(body.photo_ids),
|
|
)
|
|
)
|
|
await db.commit()
|
|
return {"status": "success", "removed": res.rowcount or 0}
|