diff --git a/mnemosyne/library/api/workspaces.py b/mnemosyne/library/api/workspaces.py index 4715761..1d91d2b 100644 --- a/mnemosyne/library/api/workspaces.py +++ b/mnemosyne/library/api/workspaces.py @@ -23,6 +23,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from library.content_types import get_library_type_config +from library.services.library_delete import delete_library_cascade from .serializers import WorkspaceCreateSerializer, WorkspaceStatusSerializer @@ -165,74 +166,15 @@ def workspace_detail_or_delete(request, workspace_id): if lib is None: return Response(status=status.HTTP_204_NO_CONTENT) - library_uid = lib.uid - library_name = lib.name - - # Step 1-4: delete chunks, items, collections, then the library itself. - # We collect Item s3_keys first so the caller can clean up S3 - # asynchronously (a future enhancement — for now, the keys are logged). - s3_rows, _ = db.cypher_query( - "MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection)" - "-[:CONTAINS]->(i:Item) RETURN i.uid, i.s3_key", - {"wsid": workspace_id}, - ) - item_s3_keys = [(r[0], r[1]) for r in s3_rows if r[1]] - - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection) - -[:CONTAINS]->(i:Item)-[:HAS_CHUNK]->(c:Chunk) - DETACH DELETE c - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection) - -[:CONTAINS]->(i:Item)-[:HAS_IMAGE]->(img:Image) - OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding) - DETACH DELETE img, emb - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection) - -[:CONTAINS]->(i:Item) - DETACH DELETE i - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(col:Collection) - DETACH DELETE col - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - "MATCH (l:Library {workspace_id: $wsid}) DETACH DELETE l", - {"wsid": workspace_id}, - ) - - # Step 5: orphan Concept garbage collection. - orphan_result, _ = db.cypher_query( - """ - MATCH (con:Concept) - WHERE NOT (con)<-[:REFERENCES]-() AND NOT (con)<-[:MENTIONS]-() - AND NOT (con)<-[:DEPICTS]-() - WITH con - DETACH DELETE con - RETURN count(con) AS deleted - """ - ) - orphans_deleted = orphan_result[0][0] if orphan_result else 0 + # Delete the Library and everything reachable + unique to it, plus + # orphan-Concept GC. Shared with the admin/HTML delete path. + result = delete_library_cascade(lib) logger.info( "Workspace deleted workspace_id=%s library_uid=%s name=%s " "items=%d orphans_deleted=%d", - workspace_id, library_uid, library_name, - len(item_s3_keys), orphans_deleted, + workspace_id, result["library_uid"], result["name"], + result["item_count"], result["orphans_deleted"], ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/mnemosyne/library/services/library_delete.py b/mnemosyne/library/services/library_delete.py new file mode 100644 index 0000000..403f928 --- /dev/null +++ b/mnemosyne/library/services/library_delete.py @@ -0,0 +1,108 @@ +""" +Shared Library deletion cascade. + +Deletes a Library node and everything reachable AND unique to it +(Collections, Items, Chunks, Images + ImageEmbeddings), then garbage-collects +Concepts that are no longer referenced by any other Library. + +Keyed on the Library ``uid`` so it works for *both* global libraries +(``workspace_id`` is null) and workspace-scoped libraries. This is the single +source of truth used by: + + * the Daedalus integration API (``DELETE /library/api/workspaces/{id}/``), and + * the admin/HTML delete view (``library_delete``). + +Concept-safe: orphan-only Concept GC happens at the end. Concepts still +referenced by another library (workspace or global) are preserved. +""" + +import logging + +from neomodel import db + +logger = logging.getLogger(__name__) + + +def delete_library_cascade(lib) -> dict: + """Delete ``lib`` and all content reachable and unique to it. + + :param lib: A ``library.models.Library`` node instance. + :returns: Dict with ``library_uid``, ``name``, ``item_count``, + ``item_s3_keys`` (list of ``(uid, s3_key)`` for async S3 cleanup), + and ``orphans_deleted`` (Concept GC count). + """ + library_uid = lib.uid + library_name = lib.name + + # Collect Item s3_keys first so the caller can clean up S3 asynchronously + # (a future enhancement — for now, the keys are returned/logged). + s3_rows, _ = db.cypher_query( + "MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection)" + "-[:CONTAINS]->(i:Item) RETURN i.uid, i.s3_key", + {"uid": library_uid}, + ) + item_s3_keys = [(r[0], r[1]) for r in s3_rows if r[1]] + + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection) + -[:CONTAINS]->(i:Item)-[:HAS_CHUNK]->(c:Chunk) + DETACH DELETE c + """, + {"uid": library_uid}, + ) + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection) + -[:CONTAINS]->(i:Item)-[:HAS_IMAGE]->(img:Image) + OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding) + DETACH DELETE img, emb + """, + {"uid": library_uid}, + ) + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection) + -[:CONTAINS]->(i:Item) + DETACH DELETE i + """, + {"uid": library_uid}, + ) + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(col:Collection) + DETACH DELETE col + """, + {"uid": library_uid}, + ) + db.cypher_query( + "MATCH (l:Library {uid: $uid}) DETACH DELETE l", + {"uid": library_uid}, + ) + + # Orphan Concept garbage collection: drop Concepts no longer referenced + # by any Item (REFERENCES/MENTIONS) or Image (DEPICTS). + orphan_result, _ = db.cypher_query( + """ + MATCH (con:Concept) + WHERE NOT (con)<-[:REFERENCES]-() AND NOT (con)<-[:MENTIONS]-() + AND NOT (con)<-[:DEPICTS]-() + WITH con + DETACH DELETE con + RETURN count(con) AS deleted + """ + ) + orphans_deleted = orphan_result[0][0] if orphan_result else 0 + + logger.info( + "Library cascade-deleted library_uid=%s name=%s items=%d orphans_deleted=%d", + library_uid, library_name, len(item_s3_keys), orphans_deleted, + ) + + return { + "library_uid": library_uid, + "name": library_name, + "item_count": len(item_s3_keys), + "item_s3_keys": item_s3_keys, + "orphans_deleted": orphans_deleted, + } diff --git a/mnemosyne/library/templates/library/library_confirm_delete.html b/mnemosyne/library/templates/library/library_confirm_delete.html index 5ff8ec9..8474ea9 100644 --- a/mnemosyne/library/templates/library/library_confirm_delete.html +++ b/mnemosyne/library/templates/library/library_confirm_delete.html @@ -12,6 +12,18 @@
{{ library.workspace_id }}).
+ Deleting it here removes its embedded content from Mnemosyne, but the
+ source files still live in Daedalus — it will be recreated and
+ re-embedded on the next Daedalus sync. Use this to clear an
+ orphaned Library that is blocking workspace re-registration.
+
+