From f4a03b63f44d5212636b3d95d8558d5b5a997745 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 13:07:28 +0200 Subject: [PATCH] feat(nc-webhook): handle folder rename via NodeRenamedEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NC fires one NodeRenamedEvent on a directory rename — children don't get their own events. The handler bailed on both paths having no supported extension. Now: - New `handle_directory_rename(old, new)` in scan.py does a single transaction of prefix-rewrites against photos.filepath, folders.path, and source_roots.path. Cross-source-root case (Photos/x → Memories/x) is treated as discard-the-old-subtree; scan_folder dispatched by the subsequent NodeWritten/NodeCreated picks up the new root. - Webhook renamed branch checks "both source and target are directories" and calls the helper. File renames keep the existing delete-old + scan-new-parent path. Idempotent: the SQL matches zero rows the second time around. That makes the feedback loop safe — mule's existing PATCH /folders/{id} endpoint already does a WebDAV MOVE + inline DB rewrite for NC paths, and the resulting NodeRenamedEvent now flows back through this handler without re-running the rewrite or leaving rows stale. Trashbin restore (the documented "NC doesn't emit a subscribed event" gap) is unchanged. --- backend/app/routers/nc_webhook.py | 31 ++++++++- backend/app/tasks/scan.py | 106 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/nc_webhook.py b/backend/app/routers/nc_webhook.py index e2636f5..46f7081 100644 --- a/backend/app/routers/nc_webhook.py +++ b/backend/app/routers/nc_webhook.py @@ -130,6 +130,7 @@ async def nc_webhook( scan_folder, handle_file_deletion, handle_directory_deletion, + handle_directory_rename, ) supported = _supported_extensions() @@ -183,9 +184,35 @@ async def nc_webhook( target = event.get("target") or {} old_abs = _nc_path_to_abs(source.get("path") or "") new_abs = _nc_path_to_abs(target.get("path") or "") - if old_abs and Path(old_abs).suffix.lower() in supported: + if not old_abs or not new_abs: + return {"status": "ignored", "reason": "non-user-files path"} + + old_is_dir = Path(old_abs).suffix.lower() not in supported + new_is_dir = Path(new_abs).suffix.lower() not in supported + + # Directory rename: NC fires one event for the directory; the + # children's paths change implicitly. Prefix-rewrite in mule. + # Same handler covers the feedback case where the PATCH + # /folders/{id}/rename endpoint already updated the DB — the + # SQL UPDATE matches zero rows the second time around. + if old_is_dir and new_is_dir: + result = await handle_directory_rename(old_abs, new_abs) + logger.info( + "nc-webhook renamed (dir): %s -> %s : %s", + old_abs, new_abs, result, + ) + return { + "status": "applied", + "action": "rename_subtree", + "from": old_abs, + "to": new_abs, + **result, + } + + # File rename (existing logic). + if Path(old_abs).suffix.lower() in supported: await handle_file_deletion(old_abs) - if new_abs and Path(new_abs).suffix.lower() in supported: + if Path(new_abs).suffix.lower() in supported: parent_dir = str(Path(new_abs).parent) source_root_id = await _source_root_for(parent_dir) if source_root_id is not None: diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 7c3befe..b7a8e91 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -552,6 +552,112 @@ async def handle_directory_deletion(dirpath: str) -> int: return n +async def handle_directory_rename(old_dirpath: str, new_dirpath: str) -> dict: + """Reflect a Nextcloud-side folder rename in mule's DB. + + NC emits a single NodeRenamedEvent on the directory — children + don't get their own events. We mirror the same prefix-rewrite the + PATCH /folders/{id} endpoint does inline, so heaps, tags, ratings, + and other state keyed on Photo.id survive intact. + + Same-source-root case (the common one): prefix-rewrite filepath / + path on photos, folders, source_roots in one transaction. + + Cross-source-root case (folder moved between two registered roots, + e.g. Photos/x → Memories/x): discard the old subtree and rely on + the scan_folder dispatched by a NodeWritten/NodeCreated event (or + the 30-min reconcile sweep) to add fresh Photo rows under the new + root. Mirrors the "different boundary, different identity" model + mule has elsewhere. + + Idempotent: re-running with the same args is a no-op because no + row matches `LIKE old_prefix||'/%'` after the first pass. That + makes the feedback loop (mule PATCH → WebDAV MOVE → NC webhook → + handler) safe. + """ + from sqlalchemy import or_, text, update + from app.models.folders import SourceRoot + + old_prefix = old_dirpath.rstrip("/") + new_prefix = new_dirpath.rstrip("/") + if not old_prefix or not new_prefix or old_prefix == new_prefix: + return {"status": "noop"} + + async def _source_root_id_for(session, path: str) -> Optional[str]: + """Find the active SourceRoot whose path is a prefix of `path`.""" + roots = (await session.execute( + select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712 + )).scalars().all() + norm = os.path.normpath(path) + for sr in roots: + root = os.path.normpath(sr.path) + if norm == root or norm.startswith(root + os.sep): + return sr.id + return None + + async with AsyncSessionLocal() as session: + old_root_id = await _source_root_id_for(session, old_prefix) + new_root_id = await _source_root_id_for(session, new_prefix) + + # Cross-root rename: discard old subtree; let webhook-dispatched + # scan_folder add fresh rows under the new root. + if old_root_id and new_root_id and old_root_id != new_root_id: + result = await session.execute( + update(Photo) + .where( + Photo.filepath.like(old_prefix + "/%"), + Photo.is_discarded.is_(False), + ) + .values(is_discarded=True, discarded_at=datetime.utcnow()) + ) + await session.commit() + n = result.rowcount or 0 + logger.info( + f"Cross-root rename {old_prefix} -> {new_prefix}: " + f"discarded {n} photos in old root" + ) + return {"status": "cross_root", "discarded": n} + + # Same-root: prefix-rewrite. Use parameterised raw SQL so the + # SUBSTRING + concat happens server-side in one shot; iterating + # in Python would mean N row updates. + params = { + "new_prefix": new_prefix, + "old_prefix": old_prefix, + "off": len(old_prefix) + 1, + "old_pat": old_prefix + "/%", + } + photos_res = await session.execute( + text( + "UPDATE photos SET filepath = :new_prefix || SUBSTRING(filepath FROM :off) " + "WHERE filepath LIKE :old_pat" + ), + params, + ) + folders_res = await session.execute( + text( + "UPDATE folders SET path = CASE " + "WHEN path = :old_prefix THEN :new_prefix " + "ELSE :new_prefix || SUBSTRING(path FROM :off) END " + "WHERE path = :old_prefix OR path LIKE :old_pat" + ), + params, + ) + source_roots_res = await session.execute( + text( + "UPDATE source_roots SET path = :new_prefix WHERE path = :old_prefix" + ), + params, + ) + await session.commit() + return { + "status": "renamed", + "photos": photos_res.rowcount or 0, + "folders": folders_res.rowcount or 0, + "source_roots": source_roots_res.rowcount or 0, + } + + @shared_task(name='backfill_gps') def backfill_gps(): """Re-run metadata extraction on every non-discarded photo that is