Three small phase-11 follow-ups in one commit since they all touch the
same surface area.
1. Watcher source-root resolution
The watch_folders task previously called scan_folder.delay(parent_dir)
when files arrived, with no source_root_id. scan_folder would then
auto-create a fresh SourceRoot for that arbitrary subdir, polluting
the source_root list. Now the watcher loads (path, id) pairs at
startup, defines find_source_root_for() that walks the parent chain,
and dispatches with the resolved id. Events under no known root are
logged at debug and ignored instead of creating stale rows.
2. Folder rename via UI
- Backend: PATCH /folders/{id} accepts { name } and updates the
SourceRoot display label only. The on-disk path is controlled by
the docker mount and intentionally not editable from the UI.
- Frontend: double-click a folder row in the LeftSidebar to start
editing; Enter or blur commits, Esc reverts. New renamingId /
renameDraft local state and a renameMutation that invalidates
['folders']. The click handler ignores clicks while the row is
in edit mode so it doesn't navigate.
- api.ts: new sourceFolders.rename(id, name) helper.
3. Bulk copy via Alt-drag onto folder
- Backend: new POST /photos/copy that mirrors /photos/move but uses
shutil.copy2 and creates fresh Photo rows with is_duplicate=true.
Name collisions are resolved by appending " (copy)", " (copy 2)",
etc., up to 100 tries before erroring. Same target_id resolution
as /move (folder id or source root id).
- Frontend: photos.copy(ids, targetId) helper. LeftSidebar's
handleDrop now takes a `copy` flag derived from e.altKey on the
drop event; folder targets dispatch copyDropMutation when held,
moveDropMutation otherwise. The drop-effect cursor flips to
'copy' on dragover when Alt is pressed so the user gets visual
confirmation. Discard target ignores the modifier.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
82 lines
2.9 KiB
Python
82 lines
2.9 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.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} |