feat: folder CRUD with discard-or-delete dialog

The left sidebar can now create, rename, and delete folders. Each
operation is mirrored to disk through the backend.

Backend (folders router):
- POST /folders { name, parent_id } — create a sub-folder under an
  existing Folder row, mkdir on disk, insert the row, return it. Names
  are validated (no separators, no traversal).
- PATCH /folders/{id} extended — still does the display-only rename for
  SourceRoot ids, but for Folder ids it now actually moves the directory
  on disk and rewrites every descendant Folder.path + Photo.filepath
  that lived under the old prefix in a single transaction. Refuses to
  rename the source-root mount itself.
- DELETE /folders/{id}?mode=discard|permanent —
    discard: set is_discarded on every photo whose filepath lives under
             this folder. The folder, descendants, and on-disk dir are
             left intact. Recoverable from the discard pile.
    permanent: unlink each file, remove rows, rmtree the directory.
- Refuses to delete the source-root mount in either mode.

Frontend:
- New DeleteFolderDialog: two-card mode picker (Move to discard pile /
  Permanently delete) with destructive accent on the latter. Esc and
  backdrop click cancel.
- LeftSidebar: hover-revealed kebab menu on every folder row with
  New sub-folder, Rename, and Delete folder…  Inline create input
  appears below the parent row when "New sub-folder" is picked.
  All mutations invalidate ['folders'], ['photos'], and the library
  stats query so the sidebar counts stay live.
- api.ts: sourceFolders.create + sourceFolders.delete wrappers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:51:23 +02:00
parent bd904aca36
commit f05ae77ef0
4 changed files with 699 additions and 20 deletions

View File

@@ -1,24 +1,48 @@
"""
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
in .env → backend bootstrap on startup) — adding or removing one is a
docker-compose change. The UI can read the list, trigger a manual rescan,
and rename the display label, but it can't change the on-disk path.
docker-compose change. Sub-folders inside a source root can be created,
renamed, and deleted from the UI; those changes are mirrored to disk.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
import logging
import os
import shutil
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, update as sql_update, delete as sql_delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Folder, SourceRoot, Photo
logger = logging.getLogger(__name__)
router = APIRouter()
class FolderRename(BaseModel):
name: str
class FolderCreate(BaseModel):
name: str
parent_id: str # Folder.id (NOT a SourceRoot id)
def _validate_folder_name(name: str) -> str:
"""Trim + sanity-check a folder name. Rejects names that contain a
path separator or that resolve to a parent traversal — those would
let the user escape the parent directory through this endpoint.
"""
name = (name or '').strip()
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
if '/' in name or '\\' in name or name in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid folder name")
return name
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
"""Get all source folders"""
@@ -161,20 +185,242 @@ async def rename_folder(
body: FolderRename,
db: AsyncSession = Depends(get_db),
):
"""Rename a source root's display label. Does NOT touch the on-disk
path — that's controlled by the docker mount."""
name = (body.name or '').strip()
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
"""Rename a folder. Two cases:
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
source_root = result.scalar_one_or_none()
if not source_root:
raise HTTPException(status_code=404, detail="Source folder not found")
- SourceRoot id → just change the display label. The on-disk path
is owned by the docker mount and never moves.
- Folder id → rename the directory on disk AND update every
descendant Folder.path + Photo.filepath that
lived under the old prefix. Refuses to rename
the source-root folder itself (= the row that
matches the SourceRoot.path) because that would
require renaming the docker mount.
"""
name = _validate_folder_name(body.name)
# Try SourceRoot first (display-only rename).
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id)
)
source_root = sr_result.scalar_one_or_none()
if source_root:
source_root.name = name
await db.commit()
return {
"id": source_root.id,
"name": source_root.name,
"path": source_root.path,
}
# Otherwise it's a Folder row.
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
# Refuse to rename the bare source root mount through here.
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
raise HTTPException(
status_code=400,
detail="Cannot rename the source root mount; rename the docker mount instead.",
)
old_path = os.path.normpath(folder.path).rstrip(os.sep)
parent_dir = os.path.dirname(old_path)
new_path = os.path.join(parent_dir, name)
if os.path.exists(new_path):
raise HTTPException(
status_code=400,
detail=f"A folder named '{name}' already exists here",
)
try:
shutil.move(old_path, new_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
# Update folder paths: this row + every descendant. SQLite REPLACE
# rewrites the prefix; we use the trailing separator on the LIKE
# pattern so a folder named "foo" doesn't accidentally match "foobar".
await db.execute(
sql_update(Folder)
.where(Folder.id == folder.id)
.values(path=new_path, name=name)
)
descendant_prefix = old_path + os.sep
descendants = await db.execute(
select(Folder).where(Folder.path.like(descendant_prefix + '%'))
)
for d in descendants.scalars().all():
d.path = new_path + d.path[len(old_path):]
# Update every photo whose filepath lives under the old prefix.
photos_result = await db.execute(
select(Photo).where(Photo.filepath.like(descendant_prefix + '%'))
)
for p in photos_result.scalars().all():
p.filepath = new_path + p.filepath[len(old_path):]
# Photos directly inside this folder (not in a subdir) won't match
# the descendant_prefix LIKE if their old path was old_path + '/file'
# — actually they DO match, since 'oldpath/file' starts with
# 'oldpath/'. So the loop above already covers them.
source_root.name = name
await db.commit()
return {"id": source_root.id, "name": source_root.name, "path": source_root.path}
return {
"id": folder.id,
"name": folder.name,
"path": folder.path,
}
@router.post("", status_code=201)
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
"""Create a new sub-folder under an existing Folder. Mirrors the
create to disk so the next scan sees it. Body: { name, parent_id }.
parent_id MUST be an existing Folder row id (any descendant of a
source root); creating a brand-new top-level mount is a docker
operation, not a UI one.
"""
name = _validate_folder_name(body.name)
parent_result = await db.execute(
select(Folder).where(Folder.id == body.parent_id)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
new_path = os.path.join(parent.path, name)
if os.path.exists(new_path):
raise HTTPException(
status_code=400,
detail=f"A folder named '{name}' already exists here",
)
try:
os.makedirs(new_path, exist_ok=False)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
new_folder = Folder(
name=name,
path=new_path,
source_root_id=parent.source_root_id,
photo_count=0,
)
db.add(new_folder)
await db.commit()
await db.refresh(new_folder)
return {
"id": new_folder.id,
"name": new_folder.name,
"path": new_folder.path,
"parent_id": parent.id,
}
@router.delete("/{folder_id}")
async def delete_folder(
folder_id: str,
mode: Literal['discard', 'permanent'] = Query('discard'),
db: AsyncSession = Depends(get_db),
):
"""Delete a folder. Behavior depends on mode:
- mode=discard (default): mark every photo whose filepath lives
under this folder as is_discarded=true. The folder row, its
descendant rows, and the on-disk directory are LEFT INTACT —
the user can still recover photos from the discard pile, and
a re-scan won't double-import them.
- mode=permanent: unlink every photo file under this folder,
remove the photo + folder rows from the DB, and rmtree the
on-disk directory. Irreversible.
Refuses to delete the bare source-root mount in either mode (deleting
the docker mount through the UI would be a footgun).
"""
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
raise HTTPException(
status_code=400,
detail="Cannot delete the source root mount through the UI",
)
folder_path = os.path.normpath(folder.path).rstrip(os.sep)
descendant_prefix = folder_path + os.sep
# Collect every photo under this folder OR any descendant. We match
# by filepath prefix instead of folder_id because that catches photos
# in nested subfolders without a recursive folder walk.
photos_result = await db.execute(
select(Photo).where(
(Photo.filepath == folder_path)
| (Photo.filepath.like(descendant_prefix + '%'))
)
)
photos = photos_result.scalars().all()
if mode == 'discard':
from datetime import datetime
now = datetime.utcnow()
for p in photos:
p.is_discarded = True
p.discarded_at = now
await db.commit()
return {
"status": "success",
"mode": "discard",
"discarded": len(photos),
}
# mode == 'permanent'
file_errors = 0
for p in photos:
try:
if p.filepath and os.path.exists(p.filepath):
os.unlink(p.filepath)
except OSError as e:
file_errors += 1
logger.error(f"Failed to unlink {p.filepath}: {e}")
await db.delete(p)
# Delete this folder + every descendant Folder row.
await db.execute(
sql_delete(Folder).where(
(Folder.id == folder.id)
| (Folder.path.like(descendant_prefix + '%'))
)
)
try:
if os.path.isdir(folder_path):
shutil.rmtree(folder_path)
except OSError as e:
logger.error(f"Failed to rmtree {folder_path}: {e}")
# Don't raise — DB rows are already gone, leaving an orphan
# directory is the lesser evil.
await db.commit()
return {
"status": "success",
"mode": "permanent",
"deleted_photos": len(photos),
"file_errors": file_errors,
}
@router.post("/{folder_id}/scan")