Files
mule-image/backend/app/routers/heaps.py
dtoro bed817d274 feat: heap convert-to-folder + surface exact-duplicate detection
Two related polish items.

1. Heap convert to folder
   Closes a long-standing TODO from spec §6.10.
   - Backend: POST /heaps/{id}/convert with body
       { target_id, mode: 'move'|'copy', delete_heap: bool }
     target_id resolves either as a Folder id or a SourceRoot id (same
     convention as /photos/move). For each member photo, dispatches
     either shutil.move + photo.folder_id update, or shutil.copy2 +
     a new is_duplicate=true Photo row with all metadata copied. Name
     collisions on copy use the same " (copy N)" suffix scheme as
     /photos/copy. The heap row is optionally deleted on success.
     Per-photo failures are collected into the response instead of
     aborting the batch.
   - Frontend: new HeapConvertDialog with a target-folder dropdown
     (currently from sourceFolders.list, sub-folder picking is a
     follow-up), move/copy radio, and a "delete heap" checkbox.
     HeapsPanel rows get a hover FolderOutput button that opens it.
     Toast on success names the verb + count and notes whether the
     heap was deleted; invalidates heaps + photos + folders queries.

2. Surface exact-duplicate detection
   The scanner already sets Photo.is_duplicate=true when a SHA-256
   match is found, but nothing surfaced it. Now:
   - Backend list_photos accepts an optional is_duplicate query
     param so the frontend can filter duplicates-only views.
   - filterStore gains a duplicates: boolean field with setter, URL
     sync (?duplicates=true), filtersToParams entry, and a
     hasActiveFilters check.
   - LeftSidebar gets a new "Duplicates" library node (Copy icon)
     that clearAllFilters() + setDuplicates(true). isItemActive
     follows the filter so the highlight stays in sync after
     external filter changes.
   - PhotoThumbnail renders a small dark badge with the Copy icon
     bottom-right when photo.is_duplicate. Sits next to the existing
     basket / discard badges so the user can spot duplicates at a
     glance.
   - Photo TS type adds is_duplicate.

Perceptual-hash duplicate detection (re-encoded / resized matches)
is intentionally a follow-up — needs an imagehash dep, a phash
column, a backfill job, and similarity-search endpoint with
hamming-distance grouping. This commit only surfaces what the
scanner already finds via byte-level SHA-256 comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:28:18 +02:00

351 lines
12 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
# ── 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.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:
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)
):
"""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}