feat(nc-webhook): handle folder rename via NodeRenamedEvent
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.
This commit is contained in:
@@ -130,6 +130,7 @@ async def nc_webhook(
|
|||||||
scan_folder,
|
scan_folder,
|
||||||
handle_file_deletion,
|
handle_file_deletion,
|
||||||
handle_directory_deletion,
|
handle_directory_deletion,
|
||||||
|
handle_directory_rename,
|
||||||
)
|
)
|
||||||
|
|
||||||
supported = _supported_extensions()
|
supported = _supported_extensions()
|
||||||
@@ -183,9 +184,35 @@ async def nc_webhook(
|
|||||||
target = event.get("target") or {}
|
target = event.get("target") or {}
|
||||||
old_abs = _nc_path_to_abs(source.get("path") or "")
|
old_abs = _nc_path_to_abs(source.get("path") or "")
|
||||||
new_abs = _nc_path_to_abs(target.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)
|
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)
|
parent_dir = str(Path(new_abs).parent)
|
||||||
source_root_id = await _source_root_for(parent_dir)
|
source_root_id = await _source_root_for(parent_dir)
|
||||||
if source_root_id is not None:
|
if source_root_id is not None:
|
||||||
|
|||||||
@@ -552,6 +552,112 @@ async def handle_directory_deletion(dirpath: str) -> int:
|
|||||||
return n
|
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')
|
@shared_task(name='backfill_gps')
|
||||||
def backfill_gps():
|
def backfill_gps():
|
||||||
"""Re-run metadata extraction on every non-discarded photo that is
|
"""Re-run metadata extraction on every non-discarded photo that is
|
||||||
|
|||||||
Reference in New Issue
Block a user