The Folders section in the LeftSidebar previously rendered the flat
list of source roots — actual subdirectories were invisible. Now it
shows the full nested tree, click any node to filter, drop targets
work at every depth.
Backend
- New GET /folders/tree returning a list of root nodes (one per
active SourceRoot). Each node is { id, name, path, photo_count,
children: [...] } with children sorted alphabetically at every
level. Walks Folder rows whose source_root_id matches and whose
path is at or beneath the source root, then attaches them by
parent path so partial scans don't break the tree.
- The source root's display label is overlaid on the root folder
node so the top-level entry reads as "Library" instead of
"/photos".
- list_photos folder_id filter now does descendant matching: when
a Folder id is given, it includes the folder itself and every
Folder whose path is a sep-prefixed descendant. Matches the
Lightroom mental model: clicking "Library" or any parent folder
shows everything beneath it. The existing source-root-id branch
is unchanged.
Frontend
- New types/api.ts FolderTreeNode interface and sourceFolders.tree()
helper.
- New hooks/useFolderTreeQuery.ts with a 30s staleTime and a
findFolderInTree() walker for id-based name lookups.
- LeftSidebar drops the flat foldersData list and uses the tree
query. folderNodeToTreeItem recursively maps backend nodes into
the existing TreeItem shape; renderTreeItem already knew how to
recurse into children, so the tree just works at any depth.
Drop targets, drag-to-move, drag-to-copy, double-click rename,
and active-state highlighting all carry over to nested folders.
- The renameMutation now also invalidates ['folders', 'tree'] so a
source-root rename refreshes the tree label immediately.
- ActiveFilterChips switches to the tree query and uses the new
findFolderInTree walker so the chip label resolves correctly for
sub-folder filters too — not just top-level source roots.
- The "Scan all folders" button visibility now keys off the tree
length instead of the flat folders length.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
5.7 KiB
Python
157 lines
5.7 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. The UI can read the list, trigger a manual rescan,
|
|
and rename the display label, but it can't change the on-disk path.
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
import os
|
|
|
|
from app.database import get_db
|
|
from app.models import Folder, SourceRoot
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class FolderRename(BaseModel):
|
|
name: str
|
|
|
|
@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: [...] }
|
|
|
|
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
|
|
|
|
# Build a path → node map so we can attach children regardless of
|
|
# parent_id consistency.
|
|
nodes = {
|
|
f.path: {
|
|
"id": f.id,
|
|
"name": f.name or os.path.basename(f.path),
|
|
"path": f.path,
|
|
"photo_count": f.photo_count or 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)
|
|
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 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")
|
|
|
|
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")
|
|
|
|
source_root.name = name
|
|
await db.commit()
|
|
return {"id": source_root.id, "name": source_root.name, "path": source_root.path}
|
|
|
|
|
|
@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} |