feat: watcher source-root resolution, folder rename, alt-drag copy
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>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user