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>
439 lines
16 KiB
Python
439 lines
16 KiB
Python
"""
|
|
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. Sub-folders inside a source root can be created,
|
|
renamed, and deleted from the UI; those changes are mirrored to disk.
|
|
"""
|
|
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"""
|
|
# Get source roots instead of regular folders
|
|
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
|
|
source_roots = result.scalars().all()
|
|
|
|
folders_list = []
|
|
for root in source_roots:
|
|
# Get photo count for this source root
|
|
folder_result = await db.execute(
|
|
select(Folder).where(Folder.source_root_id == root.id)
|
|
)
|
|
folders = folder_result.scalars().all()
|
|
photo_count = sum(f.photo_count for f in folders)
|
|
|
|
folders_list.append({
|
|
"id": root.id,
|
|
"name": root.name or os.path.basename(root.path),
|
|
"path": root.path,
|
|
"photo_count": photo_count
|
|
})
|
|
|
|
return {"folders": folders_list}
|
|
|
|
@router.get("/tree")
|
|
async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
|
"""Recursive folder tree, one root per active SourceRoot. The tree
|
|
starts at the Folder row matching the SourceRoot.path (the scanner
|
|
creates one for every walked directory), with the SourceRoot's
|
|
display name overlaid so the top-level entry reads as "Library"
|
|
instead of "/photos".
|
|
|
|
Returns a list of root nodes; each node has:
|
|
{ id, name, path, photo_count, children: [...] }
|
|
|
|
photo_count is **recursive** — every node reports the total non-
|
|
discarded photos in its own subtree, so the badge matches what the
|
|
user sees when they click the row (which also filters recursively).
|
|
|
|
The stored Folder.photo_count column is intentionally NOT trusted;
|
|
the scanner's bookkeeping for that field has historically been
|
|
wrong (it leaks the global total into whichever folder os.walk
|
|
visited last). We compute counts here from the photos table.
|
|
|
|
Sub-folders that physically belong to the same source root but
|
|
weren't created on disk (e.g. the / row the scanner sometimes
|
|
creates as a parent walk) are skipped via path-prefix filtering.
|
|
"""
|
|
sr_result = await db.execute(
|
|
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
|
)
|
|
source_roots = sr_result.scalars().all()
|
|
|
|
out = []
|
|
for sr in source_roots:
|
|
# Folders physically inside this source root, by path prefix.
|
|
prefix = os.path.normpath(sr.path).rstrip(os.sep)
|
|
f_result = await db.execute(
|
|
select(Folder).where(
|
|
Folder.source_root_id == sr.id,
|
|
# Either the folder IS the source root, or it sits beneath it.
|
|
(Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%'))
|
|
)
|
|
)
|
|
folders = f_result.scalars().all()
|
|
if not folders:
|
|
continue
|
|
|
|
# Direct (non-recursive) photo counts per folder, computed from
|
|
# the photos table. Excludes discarded.
|
|
folder_ids = [f.id for f in folders]
|
|
direct_counts: dict[str, int] = {}
|
|
if folder_ids:
|
|
count_result = await db.execute(
|
|
select(Photo.folder_id, func.count(Photo.id))
|
|
.where(
|
|
Photo.is_discarded == False, # noqa: E712
|
|
Photo.folder_id.in_(folder_ids),
|
|
)
|
|
.group_by(Photo.folder_id)
|
|
)
|
|
direct_counts = {row[0]: int(row[1]) for row in count_result.all()}
|
|
|
|
# Build a path → node map so we can attach children regardless of
|
|
# parent_id consistency. We populate photo_count with the direct
|
|
# count first, then accumulate descendants in a post-order pass.
|
|
nodes = {
|
|
f.path: {
|
|
"id": f.id,
|
|
"name": f.name or os.path.basename(f.path),
|
|
"path": f.path,
|
|
"photo_count": direct_counts.get(f.id, 0),
|
|
"children": [],
|
|
}
|
|
for f in folders
|
|
}
|
|
|
|
root_node = None
|
|
for f in folders:
|
|
node = nodes[f.path]
|
|
if f.path == prefix:
|
|
root_node = node
|
|
# Override the display name with the source root's label.
|
|
node["name"] = sr.name or node["name"]
|
|
continue
|
|
parent_path = os.path.normpath(os.path.dirname(f.path))
|
|
parent = nodes.get(parent_path)
|
|
if parent is not None:
|
|
parent["children"].append(node)
|
|
# If parent isn't in the set (orphan from a partial scan), drop
|
|
# the node — it can't be rendered consistently.
|
|
|
|
if root_node is not None:
|
|
# Sort children alphabetically at every level.
|
|
def sort_recursive(n):
|
|
n["children"].sort(key=lambda c: c["name"].lower())
|
|
for c in n["children"]:
|
|
sort_recursive(c)
|
|
sort_recursive(root_node)
|
|
|
|
# Post-order: each node's recursive count is its own direct
|
|
# count plus the sum of every descendant's recursive count.
|
|
def accumulate(n) -> int:
|
|
total = n["photo_count"]
|
|
for c in n["children"]:
|
|
total += accumulate(c)
|
|
n["photo_count"] = total
|
|
return total
|
|
accumulate(root_node)
|
|
|
|
out.append(root_node)
|
|
|
|
return out
|
|
|
|
|
|
@router.patch("/{folder_id}")
|
|
async def rename_folder(
|
|
folder_id: str,
|
|
body: FolderRename,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Rename a folder. Two cases:
|
|
|
|
- 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.
|
|
|
|
await db.commit()
|
|
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")
|
|
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
|
"""Trigger manual re-scan of source root folder"""
|
|
from app.tasks.celery import celery_app
|
|
|
|
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")
|
|
|
|
# Queue scan task using the task name defined in the decorator
|
|
task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
|
|
return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id} |