diff --git a/backend/app/routers/folders.py b/backend/app/routers/folders.py index 607148a..4cee944 100644 --- a/backend/app/routers/folders.py +++ b/backend/app/routers/folders.py @@ -1,10 +1,11 @@ """ -Folders API router. Source roots are config-driven (PHOTO_DIRS in .env → -backend bootstrap on startup); this router only exposes read access and a -manual rescan trigger. Adding/removing source roots happens by editing -docker-compose.yml + .env and restarting the stack. +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 @@ -14,6 +15,10 @@ 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""" @@ -39,6 +44,28 @@ async def get_folders(db: AsyncSession = Depends(get_db)): 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""" diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 91f51e5..6f73e41 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -491,6 +491,129 @@ class MoveRequest(BaseModel): target_id: str # folder id OR source root id +class CopyRequest(BaseModel): + photo_ids: list[str] + target_id: str # folder id OR source root id + + +@router.post("/copy") +async def copy_photos( + body: CopyRequest, + db: AsyncSession = Depends(get_db), +): + """Copy photos into a target folder. Same target resolution as /move + (folder id or source root id), but uses shutil.copy2 and creates new + Photo rows for each copied file. Original photos are unaffected. + + Each new row gets is_duplicate=true so the user can spot the + duplicates later. The new file's name is suffixed with " (copy)" if + a name collision would otherwise happen, and " (copy 2)", etc., for + further conflicts. + """ + import shutil + + # 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}", + ) + + if not body.photo_ids: + return {"status": "success", "copied": 0, "errors": []} + + photos_result = await db.execute( + select(Photo).where(Photo.id.in_(body.photo_ids)) + ) + photos_to_copy = photos_result.scalars().all() + + copied = 0 + errors: list[dict] = [] + + def _unique_target_name(directory: str, filename: str) -> Optional[str]: + """Find a non-colliding filename in `directory` based on `filename`, + suffixing " (copy)", " (copy 2)", ... if needed. Gives up after 100 + attempts.""" + if not os.path.exists(os.path.join(directory, filename)): + return filename + stem, ext = os.path.splitext(filename) + for i in range(1, 100): + candidate = f"{stem} (copy{'' if i == 1 else f' {i}'}){ext}" + if not os.path.exists(os.path.join(directory, candidate)): + return candidate + return None + + for photo in photos_to_copy: + if not os.path.exists(photo.filepath): + errors.append({"id": photo.id, "error": "source file missing"}) + continue + + 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 + + # Create a new Photo row pointing at the copy. Most metadata is + # copied verbatim; the file_hash stays so the duplicate flag does + # the right thing across the library. + 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 + + await db.commit() + + return { + "status": "success", + "copied": copied, + "errors": errors, + } + + @router.post("/move") async def move_photos( body: MoveRequest, diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index ce0df34..71c1760 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -361,42 +361,61 @@ def watch_folders(): from watchfiles import watch # Read source roots from the DB instead of the (now-removed) YAML - # config. Synchronous lookup is fine here — this runs once at task - # start, not on every event. - paths: list[str] = [] + # config. We need both the path and the id so we can dispatch + # scan_folder with the source_root_id when an event fires. + roots: list[tuple[str, str]] = [] try: - async def _load_paths(): + async def _load_roots(): async with AsyncSessionLocal() as session: result = await session.execute( select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712 ) return [ - sr.path for sr in result.scalars().all() + (os.path.normpath(sr.path), sr.id) + for sr in result.scalars().all() if os.path.exists(sr.path) ] - paths = asyncio.run(_load_paths()) + roots = asyncio.run(_load_roots()) except Exception as e: logger.error(f"watch_folders could not load source roots: {e}") return - if not paths: + if not roots: logger.warning("No valid source roots to watch") return - + + paths = [p for p, _ in roots] logger.info(f"Starting folder watcher for: {paths}") - + + def find_source_root_for(path: str) -> Optional[str]: + """Return the source_root id whose path contains `path`, or None.""" + normalized = os.path.normpath(path) + for root_path, root_id in roots: + if normalized == root_path or normalized.startswith(root_path + os.sep): + return root_id + return None + for changes in watch(*paths): for change_type, filepath in changes: filepath = str(filepath) - + # Check if it's a supported file type if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS: continue - + if change_type == 'added' or change_type == 'modified': - # Queue scan for the parent folder + # Queue scan for the parent folder, with the source_root_id + # resolved by ancestor lookup so scan_folder doesn't + # auto-create a new SourceRoot for an arbitrary subdir. parent_dir = str(Path(filepath).parent) - scan_folder.delay(parent_dir) + source_root_id = find_source_root_for(parent_dir) + if source_root_id is None: + logger.debug( + f"watcher event for {filepath}: parent {parent_dir} " + f"not under any active source root, ignoring" + ) + continue + scan_folder.delay(parent_dir, source_root_id) logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}") elif change_type == 'deleted': # Handle file deletion diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index a4ea1c7..e2939e7 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -31,6 +31,10 @@ export function LeftSidebar() { const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) const [selectedItem, setSelectedItem] = useState('all-photos') const [isScanning, setIsScanning] = useState(false) + // Inline rename state for source-root rows. Stores the id being edited + // and the draft name. Double-click a folder row to start. + const [renamingId, setRenamingId] = useState(null) + const [renameDraft, setRenameDraft] = useState('') const queryClient = useQueryClient() const clearAllFilters = useFilterStore((s) => s.clearAll) @@ -76,6 +80,28 @@ export function LeftSidebar() { toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'), }) + // Bulk copy mutation — Alt-drag uses this instead of move. + const copyDropMutation = useMutation({ + mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) => + photosApi.copy(photoIds, targetId), + onSuccess: (data) => { + const copied = data?.copied ?? 0 + const errCount = data?.errors?.length ?? 0 + if (copied > 0) { + toast.success( + 'Copied', + `${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}` + ) + } else if (errCount > 0) { + toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`) + } + queryClient.invalidateQueries({ queryKey: ['photos'] }) + queryClient.invalidateQueries({ queryKey: ['folders'] }) + }, + onError: (e: any) => + toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'), + }) + // Reads the dragged ids out of a drop event payload. const readDragIds = (e: React.DragEvent): string[] | null => { const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME) @@ -121,6 +147,16 @@ export function LeftSidebar() { queryFn: sourceFolders.list, }) + const renameMutation = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + sourceFolders.rename(id, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders'] }) + }, + onError: (e: any) => + toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'), + }) + // Mutation for scanning all folders const scanLibraryMutation = useMutation({ mutationFn: library.scan, @@ -199,14 +235,18 @@ export function LeftSidebar() { return id === 'discarded' || id.startsWith('folder-') } - const handleDrop = (id: string, ids: string[]) => { + const handleDrop = (id: string, ids: string[], copy: boolean) => { if (id === 'discarded') { discardDropMutation.mutate(ids) return } if (id.startsWith('folder-')) { const targetId = id.slice('folder-'.length) - moveDropMutation.mutate({ targetId, photoIds: ids }) + if (copy) { + copyDropMutation.mutate({ targetId, photoIds: ids }) + } else { + moveDropMutation.mutate({ targetId, photoIds: ids }) + } } } @@ -230,6 +270,7 @@ export function LeftSidebar() { )} style={{ paddingLeft: `${8 + depth * 16}px` }} onClick={() => { + if (renamingId === item.id) return setSelectedItem(item.id) if (hasChildren) { toggleExpanded(item.id) @@ -237,10 +278,22 @@ export function LeftSidebar() { applyLibraryNode(item.id) } }} + onDoubleClick={ + item.id.startsWith('folder-') + ? (e) => { + e.stopPropagation() + setRenamingId(item.id) + setRenameDraft(item.label) + } + : undefined + } onDragOver={acceptsDrop ? (e) => { if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) { e.preventDefault() - e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move' + // Alt held → copy (only meaningful for folder targets; + // discarding doesn't copy). + const wantCopy = e.altKey && item.id.startsWith('folder-') + e.dataTransfer.dropEffect = wantCopy ? 'copy' : 'move' if (dropTargetId !== item.id) setDropTargetId(item.id) } } : undefined} @@ -253,7 +306,7 @@ export function LeftSidebar() { e.preventDefault() setDropTargetId(null) const ids = readDragIds(e) - if (ids) handleDrop(item.id, ids) + if (ids) handleDrop(item.id, ids, e.altKey) } : undefined} > {/* Expand/Collapse Icon */} @@ -282,8 +335,34 @@ export function LeftSidebar() { )} - {/* Label */} - {item.label} + {/* Label (or inline rename input for folder rows) */} + {renamingId === item.id ? ( + setRenameDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => { + const next = renameDraft.trim() + const id = item.id.slice('folder-'.length) + if (next && next !== item.label) { + renameMutation.mutate({ id, name: next }) + } + setRenamingId(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } else if (e.key === 'Escape') { + setRenamingId(null) + } + }} + className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none" + /> + ) : ( + {item.label} + )} {/* Count Badge */} {item.count !== undefined && item.count > 0 && ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d48a9c8..21ee20d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -10,7 +10,8 @@ const api = axios.create({ }) // Source Folders API. Source roots are config-driven now (PHOTO_DIRS in -// .env → bootstrap on backend startup), so the UI only reads them. +// .env → bootstrap on backend startup), so the UI only reads them and +// optionally renames the display label. export const sourceFolders = { list: async () => { const response = await api.get('/folders') @@ -21,6 +22,13 @@ export const sourceFolders = { const response = await api.post(`/folders/${folderId}/scan`) return response.data }, + + /** Rename the display label only — the on-disk path is controlled by + * the docker mount and cannot be changed from the UI. */ + rename: async (folderId: string, name: string) => { + const response = await api.patch(`/folders/${folderId}`, { name }) + return response.data + }, } // Photos API @@ -84,6 +92,16 @@ export const photos = { return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> } }, + /** Copy photos into a target folder. Originals are unaffected; new + * rows are created with is_duplicate=true. */ + copy: async (photoIds: string[], targetId: string) => { + const response = await api.post('/photos/copy', { + photo_ids: photoIds, + target_id: targetId, + }) + return response.data as { status: string; copied: number; errors: Array<{ id: string; error: string }> } + }, + getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => { return `${API_BASE_URL}/photos/${photoId}/thumb/${size}` },