"""CRUD de documents de Metabase (feature reciente, Metabase 0.57+). Un document es un texto editable en Metabase (tipo Notion) serializado como ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite embeber cards, smart links y flex containers. NODOS QUE RENDERIZAN (whitelist TipTap v0.59): doc, paragraph, text, heading (level 1-6), bulletList, orderedList, listItem, blockquote, codeBlock (attrs.language), horizontalRule, hardBreak, cardEmbed (attrs.id), flexContainer, smartLink (attrs.entityId), resizeNode, mention. NODOS QUE LA API ACEPTA PERO EL FRONTEND IGNORA (doc queda vacio): callout, taskList, taskItem, details, table, tableRow, tableCell, image, iframe. MARKS QUE RENDERIZAN: bold, italic, strike, code, link (attrs.href). MARKS IGNORADOS: underline, highlight, subscript, textStyle. IMPORTANTE — cardEmbed: Un cardEmbed desnudo renderiza pero queda muy pequeño (alto ~50px). Para que se vea correctamente, envolver en un resizeNode: {"type": "resizeNode", "attrs": {"height": 400, "minHeight": 280}, "content": [ {"type": "cardEmbed", "attrs": {"id": }} ]} Usar el helper `prosemirror_card_embed(card_id)` para generar esto automaticamente. """ import uuid from .client import MetabaseClient def prosemirror_card_embed(card_id: int, height: int = 400) -> dict: """Genera un nodo cardEmbed envuelto en resizeNode listo para ProseMirror. Un cardEmbed desnudo renderiza pero queda muy pequeño (~50px). Metabase espera que vaya dentro de un resizeNode con height/minHeight para que se vea con tamaño adecuado. Args: card_id: ID de la card/pregunta de Metabase a embeber. height: Altura en pixeles del embed (default 400). minHeight se fija en 280 (lo que usa la UI de Metabase). Returns: Dict ProseMirror: resizeNode > cardEmbed, insertable directamente en el array content de un document. Example: >>> node = prosemirror_card_embed(7711, height=500) >>> doc = {"type": "doc", "content": [ ... {"type": "heading", "attrs": {"level": 1}, ... "content": [{"type": "text", "text": "Mi reporte"}]}, ... node, ... ]} """ return { "type": "resizeNode", "attrs": {"height": height, "minHeight": 280}, "content": [ { "type": "cardEmbed", "attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())}, } ], } def _validate_before_send(name: str, document: dict | str) -> None: """Valida el payload del document antes de enviarlo. Raises ValueError.""" if not document or document == "": return if not isinstance(document, dict): return from .validation import metabase_validate_document_payload issues = metabase_validate_document_payload({"name": name, "document": document}) if issues: raise ValueError( f"Document no renderizará correctamente en Metabase " f"({len(issues)} issues):\n" + "\n".join(f" - {i}" for i in issues) ) def metabase_list_documents(client: MetabaseClient) -> list[dict]: """Lista documents de Metabase. Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API. Args: client: Cliente autenticado. Returns: Lista de dicts con: id, name, collection_id, archived, created_at, updated_at, creator_id, content_type, entity_id. Example: >>> docs = metabase_list_documents(client) >>> for d in docs: ... print(d["id"], d["name"]) """ resp = client.request("GET", "/api/document") if isinstance(resp, dict) and "items" in resp: return resp["items"] return resp or [] def metabase_get_document(client: MetabaseClient, document_id: int) -> dict: """Obtiene un document completo con su contenido ProseMirror. Endpoint: GET /api/document/:id. Args: client: Cliente autenticado. document_id: ID del document. Returns: Dict con: id, name, document (ProseMirror tree), collection_id, archived, creator, created_at, updated_at, entity_id, content_type. Example: >>> doc = metabase_get_document(client, 1) >>> print(doc["name"]) >>> tree = doc["document"] # {"type": "doc", "content": [...]} """ return client.request("GET", f"/api/document/{document_id}") def metabase_create_document( client: MetabaseClient, name: str, document: dict, collection_id: int = 0, *, validate: bool = True, ) -> dict: """Crea un document nuevo. Endpoint: POST /api/document. Valida el arbol ProseMirror ANTES de enviar (por defecto). Si el documento contiene nodos que la API acepta pero el frontend ignora (callout, taskList, image, etc.), lanza ValueError con los issues. Pasar validate=False para desactivar (uso bajo tu riesgo). Para embeber cards, usar prosemirror_card_embed(card_id) que genera el nodo resizeNode > cardEmbed con la altura correcta. Args: client: Cliente autenticado. name: Titulo del document (1-254 chars, no blank). document: Arbol ProseMirror JSON. Formato minimo: {"type": "doc", "content": [{"type": "paragraph", "content": [...]}]} O cadena vacia "" si se quiere arrancar en blanco. collection_id: ID de coleccion destino. 0 = root. validate: Si True (default), valida el ProseMirror antes de enviar. Returns: Document creado con su id asignado. Raises: ValueError: Si validate=True y el arbol ProseMirror contiene nodos o marks que el frontend de Metabase no renderiza. Example: >>> from metabase.documents import prosemirror_card_embed >>> doc = metabase_create_document(client, "Reporte", { ... "type": "doc", ... "content": [ ... {"type": "heading", "attrs": {"level": 1}, ... "content": [{"type": "text", "text": "KPIs"}]}, ... prosemirror_card_embed(42, height=450), ... ] ... }) """ if validate: _validate_before_send(name, document) body: dict = {"name": name, "document": document} if collection_id > 0: body["collection_id"] = collection_id return client.request("POST", "/api/document", json=body) def metabase_update_document( client: MetabaseClient, document_id: int, *, validate: bool = True, **fields, ) -> dict: """Actualiza un document. Solo se envian los campos pasados. Endpoint: PUT /api/document/:id. Si se pasa el campo 'document', valida el arbol ProseMirror antes de enviar (por defecto). Pasar validate=False para desactivar. Campos tipicos: name, document, collection_id, archived. Args: client: Cliente autenticado. document_id: ID del document a actualizar. validate: Si True (default), valida el ProseMirror antes de enviar. **fields: Campos a modificar. Returns: Document actualizado. Raises: ValueError: Si validate=True y el campo 'document' contiene nodos o marks no soportados por el frontend de Metabase. Example: >>> metabase_update_document(client, 1, name="Nuevo titulo") >>> metabase_update_document(client, 1, document={"type":"doc","content":[...]}) """ if validate and "document" in fields: name = fields.get("name", f"document_{document_id}") _validate_before_send(name, fields["document"]) return client.request("PUT", f"/api/document/{document_id}", json=fields) def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict: """Archiva un document (equivalente a PUT con archived=True). Metabase exige archive previo para poder eliminar. Args: client: Cliente autenticado. document_id: ID del document. Returns: Document con archived=True. """ return client.request("PUT", f"/api/document/{document_id}", json={"archived": True}) def metabase_list_document_comments( client: MetabaseClient, document_id: int, *, include_resolved: bool = True, include_deleted: bool = False, ) -> list[dict]: """Lista los comentarios de un document. Endpoint: GET /api/comment?target_type=document&target_id=:id. Un comentario puede estar anclado a un bloque especifico via `child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al document entero si `child_target_id` es null. Las respuestas tipo thread cuelgan via `parent_comment_id`. Args: client: Cliente autenticado. document_id: ID del document. include_resolved: si False, filtra comentarios con is_resolved=True. include_deleted: si False, filtra comentarios con deleted_at no null. Returns: Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id, target_type, target_id, child_target_id, parent_comment_id, is_resolved, deleted_at, reactions, created_at, updated_at. Example: >>> comments = metabase_list_document_comments(client, 29) >>> for c in comments: ... text = _comment_plaintext(c["content"]) ... print(f'{c["creator"]["common_name"]}: {text}') """ resp = client.request( "GET", "/api/comment", params={"target_type": "document", "target_id": document_id}, ) items = resp.get("comments", []) if isinstance(resp, dict) else [] if not include_resolved: items = [c for c in items if not c.get("is_resolved")] if not include_deleted: items = [c for c in items if c.get("deleted_at") is None] return items def metabase_create_document_comment( client: MetabaseClient, document_id: int, content: dict, *, child_target_id: str | None = None, parent_comment_id: int | None = None, ) -> dict: """Crea un comentario en un document. Endpoint: POST /api/comment. Args: client: Cliente autenticado. document_id: ID del document donde comentar. content: Arbol ProseMirror del comentario. Ejemplo minimo: {"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"mi comentario"}]}]} child_target_id: UUID del bloque del document al que se ancla el comentario (matchea `attrs._id` de un parrafo). None = a nivel doc. parent_comment_id: id del comentario al que se responde. None = top-level. Returns: Comentario creado. """ body: dict = { "target_type": "document", "target_id": document_id, "content": content, } if child_target_id is not None: body["child_target_id"] = child_target_id if parent_comment_id is not None: body["parent_comment_id"] = parent_comment_id return client.request("POST", "/api/comment", json=body) def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict: """Marca un comentario como resuelto (is_resolved=True). Endpoint: PUT /api/comment/:id. """ return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True}) def metabase_delete_document(client: MetabaseClient, document_id: int) -> None: """Elimina un document. REQUIERE archivarlo primero. Endpoint: DELETE /api/document/:id. Si el document no esta archivado, Metabase responde: "Document must be archived before it can be deleted." Args: client: Cliente autenticado. document_id: ID del document a eliminar. """ client.request("DELETE", f"/api/document/{document_id}") def metabase_move_document( client: MetabaseClient, document_id: int, collection_id: int | None, ) -> dict: """Mueve un document a otra coleccion. Thin wrapper sobre metabase_update_document: envia solo collection_id. Endpoint: PUT /api/document/:id con {collection_id: ...}. Args: client: Cliente autenticado. document_id: ID del document a mover. collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics"). Returns: Document actualizado con el nuevo collection_id. Example: >>> doc = metabase_move_document(client, 42, collection_id=7) >>> print(doc["collection_id"]) # 7 >>> # Mover a raiz: >>> doc = metabase_move_document(client, 42, collection_id=None) """ return client.request( "PUT", f"/api/document/{document_id}", json={"collection_id": collection_id}, ) def metabase_copy_document( client: MetabaseClient, document_id: int, name: str | None = None, collection_id: int | None = None, ) -> dict: """Copia un document usando el endpoint nativo de Metabase. Endpoint: POST /api/document/:id/copy. Args: client: Cliente autenticado. document_id: ID del document a copiar. name: Nombre del nuevo document. None = Metabase asigna nombre automatico. collection_id: Coleccion destino. None = misma coleccion del original. Returns: Document nuevo recien creado con su id asignado. Example: >>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5) >>> print(copy["id"]) """ body: dict = {} if name is not None: body["name"] = name if collection_id is not None: body["collection_id"] = collection_id return client.request("POST", f"/api/document/{document_id}/copy", json=body)