Admin/HTML library delete previously hard-blocked workspace-scoped (Daedalus-managed) libraries, leaving no way to clear an orphaned Library node — e.g. one left behind when a Daedalus workspace delete failed to propagate. A recreate of that workspace then collides on the global Library.name unique constraint and 500s, freezing ingest. Allow the delete behind the existing confirm warning (low risk: source content lives in Daedalus and is recreated + re-embedded on next sync), and route both the API and HTML delete paths through one shared cascade. - Add library/services/library_delete.delete_library_cascade(lib), keyed on Library uid so it covers global and workspace-scoped libraries. It removes Chunks, Images/ImageEmbeddings, Items, Collections, the Library, then GCs orphan-only Concepts (verbatim from the API view, re-keyed workspace_id->uid). - workspace_detail_or_delete (API) now calls the shared helper. - library_delete (HTML) no longer blocks workspace_id libraries; it calls the cascade instead of a bare lib.delete() (which leaked child nodes — also a latent bug for global libraries with content). - Confirm-delete template shows a caution banner for Daedalus-managed libraries. No migration: Mnemosyne library data is in Neo4j (neomodel); no schema change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
3.4 KiB
Python
109 lines
3.4 KiB
Python
"""
|
|
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,
|
|
}
|