From 04e4a8e0a8ce39232ada3c3382891382797d5d9f Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 6 May 2025 02:52:51 +0200 Subject: [PATCH] Add support for attachments in notes --- README.md | 11 + attach_and_embed_image.py | 70 +++++++ attach_image.py | 40 ++++ check_updated_note.py | 26 +++ docs/nextcloud_notes_image_embedding.md | 118 +++++++++++ nextcloud_mcp_server/client.py | 176 +++++++++++++++- nextcloud_mcp_server/server.py | 20 ++ poetry.lock | 102 +++++++++- pyproject.toml | 3 +- retrieved_image.png | Bin 0 -> 1565 bytes sample_image.png | Bin 0 -> 1565 bytes sample_image.py | 11 + test_delete_note_with_attachment.py | 82 ++++++++ tests/test_client.py | 255 ++++++++++++++++++++++++ tests/test_embedded_images.py | 126 ++++++++++++ tests/test_note_attachment_cleanup.py | 100 ++++++++++ tests/test_note_image_integration.py | 133 ++++++++++++ update_content_with_image_reference.py | 53 +++++ update_image_reference.py | 55 +++++ update_webdav_auth.py | 74 +++++++ verify_image_attachment.py | 44 ++++ 21 files changed, 1493 insertions(+), 6 deletions(-) create mode 100644 attach_and_embed_image.py create mode 100644 attach_image.py create mode 100644 check_updated_note.py create mode 100644 docs/nextcloud_notes_image_embedding.md create mode 100644 retrieved_image.png create mode 100644 sample_image.png create mode 100644 sample_image.py create mode 100644 test_delete_note_with_attachment.py create mode 100644 tests/test_embedded_images.py create mode 100644 tests/test_note_attachment_cleanup.py create mode 100644 tests/test_note_image_integration.py create mode 100644 update_content_with_image_reference.py create mode 100644 update_image_reference.py create mode 100644 update_webdav_auth.py create mode 100644 verify_image_attachment.py diff --git a/README.md b/README.md index d45e0b0..af8ad55 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin * `nc_notes_create_note`: Create a new note. * `nc_notes_update_note`: Update an existing note by ID. * `nc_notes_delete_note`: Delete a note by ID. +* `nc_notes_search_notes`: Search notes by title or content. +* `nc_get_note`: Get a specific note by ID. ### Available Resources @@ -20,6 +22,15 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin * `notes://all`: Access all notes. * `notes://settings`: Access note settings. * `nc://capabilities`: Access Nextcloud server capabilities. +* `nc://Notes/{note_id}/attachments/{attachment_filename}`: Access attachments for notes. + +### Note Attachments + +This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments: + +* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app. +* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time. +* WebDAV permissions must be properly configured for attachment operations to work correctly. ## Installation diff --git a/attach_and_embed_image.py b/attach_and_embed_image.py new file mode 100644 index 0000000..930b900 --- /dev/null +++ b/attach_and_embed_image.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +import os +import sys +from nextcloud_mcp_server.client import NextcloudClient + +def main(): + note_id = 487 # ID of the note we just created + + # Create client + client = NextcloudClient.from_env() + + # Check if image exists + image_path = 'sample_image.png' + if not os.path.exists(image_path): + print(f"Error: Image file '{image_path}' not found") + return 1 + + # Read the image + with open(image_path, 'rb') as f: + image_content = f.read() + + print(f"Attaching image to note {note_id}...") + try: + # Attach the image to the note + upload_response = client.add_note_attachment( + note_id=note_id, + filename="sample_image.png", + content=image_content, + mime_type="image/png" + ) + + print(f"Image attached successfully (Status: {upload_response['status_code']}).") + + # Now get the current note to get its etag + note = client.notes_get_note(note_id=note_id) + etag = note["etag"] + + # Update the note content to include the image references + updated_content = f"""# Note with Visible Image Demo + +This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface. + +We'll include the sample red square image we created earlier using both Markdown and HTML methods. + +## Method 1: Markdown Image Syntax +![Sample Red Square Image](.attachments.{note_id}/sample_image.png) + +## Method 2: HTML Image Tag +Sample Red Square Image + +## Image Path Details +The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png` +""" + + # Update the note with the references to the image + updated_note = client.notes_update_note( + note_id=note_id, + etag=etag, + content=updated_content + ) + + print(f"Note updated with image references. You can now view it in the browser.") + print(f"Note URL: /index.php/apps/notes/#/note/{note_id}") + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/attach_image.py b/attach_image.py new file mode 100644 index 0000000..f9d766c --- /dev/null +++ b/attach_image.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +import os +import sys +from nextcloud_mcp_server.client import NextcloudClient + +def main(): + note_id = 420 # ID of the note we created earlier + + # Create client + client = NextcloudClient.from_env() + + # Check if image exists + image_path = 'sample_image.png' + if not os.path.exists(image_path): + print(f"Error: Image file '{image_path}' not found") + return 1 + + # Read the image + with open(image_path, 'rb') as f: + image_content = f.read() + + print(f"Attaching image to note {note_id}...") + try: + # Attach the image to the note + upload_response = client.add_note_attachment( + note_id=note_id, + filename="sample_image.png", + content=image_content, + mime_type="image/png" + ) + + print(f"Image attached successfully (Status: {upload_response['status_code']}).") + print(f"Note URL: /index.php/apps/notes/#/note/{note_id}") + return 0 + except Exception as e: + print(f"Error attaching image: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/check_updated_note.py b/check_updated_note.py new file mode 100644 index 0000000..bcba688 --- /dev/null +++ b/check_updated_note.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +import sys +from nextcloud_mcp_server.client import NextcloudClient + +def main(): + note_id = 420 # ID of the note with the image attachment + + # Create client + client = NextcloudClient.from_env() + + # Get the note again to see the updated content + try: + note = client.notes_get_note(note_id=note_id) + print(f"Retrieved note: {note['title']}") + print("\nCURRENT NOTE CONTENT:") + print("-" * 50) + print(note['content']) + print("-" * 50) + + return 0 + except Exception as e: + print(f"Error retrieving note: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/nextcloud_notes_image_embedding.md b/docs/nextcloud_notes_image_embedding.md new file mode 100644 index 0000000..6fe2e70 --- /dev/null +++ b/docs/nextcloud_notes_image_embedding.md @@ -0,0 +1,118 @@ +# Working with Images in Nextcloud Notes + +This document explains how to properly work with images and attachments in Nextcloud Notes through the MCP server. + +## Adding Image Attachments + +Images and other files can be attached to notes using the WebDAV protocol. The Nextcloud MCP server handles this through the `add_note_attachment` method: + +```python +# Example: Adding an image attachment to a note +client.add_note_attachment( + note_id=123, # The ID of the note + filename="image.png", # The filename for the attachment + content=image_bytes, # The binary content of the image + mime_type="image/png" # The MIME type +) +``` + +## Embedding Images in Notes + +For images to display inline within notes, you must reference them correctly in the note content. There are two methods: + +### 1. Markdown Syntax (Recommended) + +```markdown +![Image Alt Text](.attachments.{note_id}/{filename}) +``` + +For example: +```markdown +![My Screenshot](.attachments.123/screenshot.png) +``` + +### 2. HTML Image Tags + +```html +Image description +``` + +For example: +```html +My Screenshot +``` + +## Storage Location + +Image attachments are stored in a hidden directory structure: + +``` +/Notes/.attachments.{note_id}/{filename} +``` + +This path is accessible via WebDAV, allowing direct file operations. + +## Orphaned Attachments Behavior + +**Important:** When notes are deleted, their attachments remain in the system. This is the expected behavior of the official Nextcloud Notes app, not a bug in the MCP server implementation. + +Consequences: +- Orphaned attachments accumulate over time +- No automatic cleanup of attachment directories +- References to attachments in deleted notes become broken links + +## Examples + +### Complete Example: Creating a Note with Embedded Image + +```python +from nextcloud_mcp_server.client import NextcloudClient +import os + +# Create client +client = NextcloudClient.from_env() + +# 1. Create the note +note = client.notes_create_note( + title="Note with Embedded Image", + content="# Image Example\n\nThis note will have an embedded image.", + category="Documentation" +) +note_id = note["id"] +note_etag = note["etag"] + +# 2. Read image content +with open("example.png", "rb") as f: + image_content = f.read() + +# 3. Upload image as attachment +client.add_note_attachment( + note_id=note_id, + filename="example.png", + content=image_content, + mime_type="image/png" +) + +# 4. Update note content to include image reference +updated_content = f"""# Image Example + +This note has an embedded image below: + +![Example Image](.attachments.{note_id}/example.png) +""" + +# 5. Update the note with image reference +client.notes_update_note( + note_id=note_id, + etag=note_etag, + content=updated_content +) +``` + +## Troubleshooting + +If you encounter issues with attachments: + +1. **401 Unauthorized errors**: Verify WebDAV permissions in Nextcloud +2. **Images not displaying**: Check the exact path format (`.attachments.{note_id}/{filename}`) +3. **Attachment access after note deletion**: This is expected - attachments persist after note deletion diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index f065a43..e143485 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -1,5 +1,7 @@ import os import time # Import time for sleep +import mimetypes +from io import BytesIO from httpx import ( Client, Auth, @@ -32,8 +34,8 @@ def log_response(response: Response): class NextcloudClient: - def __init__(self, base_url: str, auth: Auth | None = None): - + def __init__(self, base_url: str, username: str, auth: Auth | None = None): + self.username = username # Store username self._client = Client( base_url=base_url, auth=auth, @@ -48,7 +50,8 @@ class NextcloudClient: host = os.environ["NEXTCLOUD_HOST"] username = os.environ["NEXTCLOUD_USERNAME"] password = os.environ["NEXTCLOUD_PASSWORD"] - return cls(base_url=host, auth=BasicAuth(username, password)) + # Pass username to constructor + return cls(base_url=host, username=username, auth=BasicAuth(username, password)) def capabilities(self): @@ -155,6 +158,171 @@ class NextcloudClient: return search_results def notes_delete_note(self, *, note_id: int): + # First delete the note through the Notes API response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}") response.raise_for_status() - return response.json() + json_response = response.json() + + # Then try to delete the attachments directory via WebDAV + try: + webdav_base = self._get_webdav_base_path() + attachments_dir = f"{webdav_base}/Notes/.attachments.{note_id}" + logger.info("Deleting attachment directory: %s", attachments_dir) + + delete_response = self._client.request("DELETE", attachments_dir) + # 204 No Content = successful delete, 404 Not Found = already gone (both OK) + if delete_response.status_code not in [204, 404]: + logger.warning( + "Unexpected status code %s when deleting attachments directory for note %s", + delete_response.status_code, + note_id + ) + + # In production, we should not raise an error if the Notes API deletion was successful + # but WebDAV cleanup failed - this would leave the note inaccessible to users. + # Instead, log the issue for admin attention. + if delete_response.status_code == 401: + logger.error( + "Authentication error when trying to delete attachment directory for note %s. " + "Please verify WebDAV permissions.", + note_id + ) + elif delete_response.status_code >= 400: + logger.error( + "Error (HTTP %s) when trying to delete attachment directory for note %s.", + delete_response.status_code, + note_id + ) + except Exception as e: + # Log but don't fail the operation if attachments cleanup fails + logger.error( + "Error cleaning up attachments directory for note %s: %s", + note_id, + e + ) + + return json_response + + # Removed incorrect get_note_attachment method that used Notes API + + def _get_webdav_base_path(self) -> str: + """Helper to get the base WebDAV path for the authenticated user.""" + # Use the stored username + return f"/remote.php/dav/files/{self.username}" + + def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, mime_type: str | None = None): + """Add/Update an attachment to a note via WebDAV PUT.""" + # Attachments are stored in a hidden folder .attachments.{note_id} within the Notes folder + webdav_base = self._get_webdav_base_path() + attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}" + logger.info("Uploading attachment to WebDAV path: %s", attachment_path) + + # Log current auth settings to diagnose the issue + logger.info("WebDAV auth settings - Username: %s, Auth Type: %s", + self.username, type(self._client.auth).__name__) + + if not mime_type: + mime_type, _ = mimetypes.guess_type(filename) + if not mime_type: + mime_type = "application/octet-stream" # Default if guessing fails + + headers = {"Content-Type": mime_type} + try: + # First check if we can access WebDAV at all with current credentials + # by checking the Notes directory + notes_dir_path = f"{webdav_base}/Notes" + logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path) + notes_dir_response = self._client.request("PROPFIND", notes_dir_path, + headers={"Depth": "0"}) + + if notes_dir_response.status_code == 401: + logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.") + raise HTTPStatusError( + f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}", + request=notes_dir_response.request, + response=notes_dir_response + ) + elif notes_dir_response.status_code >= 400: + logger.error("Error accessing WebDAV Notes directory: %s", notes_dir_response.status_code) + notes_dir_response.raise_for_status() + else: + logger.info("Successfully accessed WebDAV Notes directory (Status: %s)", + notes_dir_response.status_code) + + # Ensure the parent directory exists using MKCOL + parent_dir_path = f"{webdav_base}/Notes/.attachments.{note_id}" + logger.info("Creating attachments directory: %s", parent_dir_path) + mkcol_response = self._client.request("MKCOL", parent_dir_path) + # MKCOL should return 201 Created or 405 Method Not Allowed (if exists) + # We can ignore 405, but raise for other errors + if mkcol_response.status_code not in [201, 405]: + logger.warning( + "Unexpected status code %s when creating attachments directory", + mkcol_response.status_code + ) + mkcol_response.raise_for_status() + else: + logger.info("Created/verified directory: %s (Status: %s)", + parent_dir_path, mkcol_response.status_code) + + # Proceed with the PUT request + logger.info("Putting attachment file to: %s", attachment_path) + response = self._client.put( + attachment_path, + content=content, + headers=headers + ) + response.raise_for_status() # Raises for 4xx/5xx status codes + logger.info("Successfully uploaded attachment '%s' to note %s (Status: %s)", filename, note_id, response.status_code) + # PUT typically returns 201 Created or 204 No Content on success + return {"status_code": response.status_code} # Return status or relevant info + + except HTTPStatusError as e: + logger.error( + "HTTP error uploading attachment '%s' to note %s: %s", + filename, + note_id, + e, + ) + raise e + except Exception as e: + logger.error( + "Unexpected error uploading attachment '%s' to note %s: %s", + filename, + note_id, + e, + ) + raise e + + def get_note_attachment(self, *, note_id: int, filename: str): + """Fetch a specific attachment from a note via WebDAV GET.""" + webdav_base = self._get_webdav_base_path() + attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}" + logger.info("Fetching attachment from WebDAV path: %s", attachment_path) + + try: + response = self._client.get(attachment_path) + response.raise_for_status() + + content = response.content + mime_type = response.headers.get("content-type", "application/octet-stream") + + logger.info("Successfully fetched attachment '%s' (%s, %d bytes)", filename, mime_type, len(content)) + return content, mime_type + + except HTTPStatusError as e: + logger.error( + "HTTP error fetching attachment '%s' for note %s: %s", + filename, + note_id, + e, + ) + raise e + except Exception as e: + logger.error( + "Unexpected error fetching attachment '%s' for note %s: %s", + filename, + note_id, + e, + ) + raise e diff --git a/nextcloud_mcp_server/server.py b/nextcloud_mcp_server/server.py index 0e7b142..740fdd2 100644 --- a/nextcloud_mcp_server/server.py +++ b/nextcloud_mcp_server/server.py @@ -108,6 +108,26 @@ def nc_notes_delete_note(note_id: int, ctx: Context): return client.notes_delete_note(note_id=note_id) +@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}") +def nc_notes_get_attachment(note_id: int, attachment_filename: str): + """Get a specific attachment from a note""" + ctx = mcp.get_context() + client: NextcloudClient = ctx.request_context.lifespan_context.client + # Assuming a method get_note_attachment exists in the client + # This method should return the raw content and determine the mime type + content, mime_type = client.get_note_attachment(note_id=note_id, filename=attachment_filename) + return { + "contents": [ + { + # Use uppercase 'Notes' to match the decorator + "uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}", + "mimeType": mime_type, # Client needs to determine this + "data": content, # Return raw bytes/data + } + ] + } + + def run(): mcp.run() diff --git a/poetry.lock b/poetry.lock index 4038539..209e9f0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -478,6 +478,106 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "pillow" +version = "11.2.1" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, + {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, + {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, + {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, + {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, + {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, + {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, + {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, + {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, + {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, + {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, + {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, + {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, + {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, + {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, + {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, + {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, + {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, + {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, + {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, + {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.7" @@ -973,4 +1073,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "9f0b7b38edcfb60fb521fd54a2b43c2a8d8ab2e3bf0c7b5e994a4999fc9d954e" +content-hash = "38328edadd5d23977a5c867229a1445013862796853bc5e8e9de08106ecba60f" diff --git a/pyproject.toml b/pyproject.toml index 59124a0..646e43e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "mcp[cli] (>=1.7,<1.8)", - "httpx (>=0.28.1,<0.29.0)" + "httpx (>=0.28.1,<0.29.0)", + "pillow (>=11.2.1,<12.0.0)" ] [project.scripts] diff --git a/retrieved_image.png b/retrieved_image.png new file mode 100644 index 0000000000000000000000000000000000000000..fd67dba665e8a12681ca538e9d780d4038bd108f GIT binary patch literal 1565 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)OEIV1450;uumf=k4v&-XXOz$3ON@ zpX6{#Y1hPwthu``9Bax*H22`Rlo8nBlJMVQ%Lxu8ja9W0F5ODeR}Q&O5ngo8qHJN) zyWFm@yJZUQg%oK|uEeGhlHlGB0vVKFbb4}5Wn z*r1>jAt0v1!7bLp%6+JbHSu86h6G1^!c+XR$^tLO@8av8r>fFnFIiN9IN3*Ib>GZTe50`pxEKK&;m=kGJEf%B`E9F1mt+aGW&|^Z%=>85ta!uU;i(WV{J|ds|%HuSLYY|JXd+ zlT-NR7p-|SeZ5mx?(IdJH-E@Jb0uQqGKUCL(}`!F@!#Ly@1u6}-ag+v&vyRf%l!jG zO|u@g^BWeYc?1Rt6+e4&+`i6-OGCT6U;cR8*RR>1baoz}GVR#*{N(jn_x8Ma%d2T+ zmGtdR%CG0=AGezt2+Z{P^5w|c*&Ayr9|q5mN}MWIb##i6BrkvV>uXOoYzeV0&&u#P zdi2(w$is&xg~Y9sw-1T4%S!U>?CMgE~xpmZL|t^sX&-zqL1d(yUn}&wT#nyxj8b-P9#}tEZmy z2&&&7wtm`@Ha1s}OMic#yz}c<;h{+-&-?u5hoz_2Em`Wi+;8O?pGn`|dY66BZrv~X zY}>kZ5;8oRTH4A}yXMV%@v2Ky+o|i_yBk|HmtQg%oK|uEeGhlHlGB0vVKFbb4}5Wn z*r1>jAt0v1!7bLp%6+JbHSu86h6G1^!c+XR$^tLO@8av8r>fFnFIiN9IN3*Ib>GZTe50`pxEKK&;m=kGJEf%B`E9F1mt+aGW&|^Z%=>85ta!uU;i(WV{J|ds|%HuSLYY|JXd+ zlT-NR7p-|SeZ5mx?(IdJH-E@Jb0uQqGKUCL(}`!F@!#Ly@1u6}-ag+v&vyRf%l!jG zO|u@g^BWeYc?1Rt6+e4&+`i6-OGCT6U;cR8*RR>1baoz}GVR#*{N(jn_x8Ma%d2T+ zmGtdR%CG0=AGezt2+Z{P^5w|c*&Ayr9|q5mN}MWIb##i6BrkvV>uXOoYzeV0&&u#P zdi2(w$is&xg~Y9sw-1T4%S!U>?CMgE~xpmZL|t^sX&-zqL1d(yUn}&wT#nyxj8b-P9#}tEZmy z2&&&7wtm`@Ha1s}OMic#yz}c<;h{+-&-?u5hoz_2Em`Wi+;8O?pGn`|dY66BZrv~X zY}>kZ5;8oRTH4A}yXMV%@v2Ky+o|i_yBk|Hmt NextcloudClient: + """ + Fixture to create a NextcloudClient instance for integration tests. + """ + assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set" + assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set" + assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set" + return NextcloudClient.from_env() + +@pytest.fixture +def test_image(): + """Generate a test image with embedded text for attachment tests""" + # Create a temporary file to store the test image + fd, image_path = tempfile.mkstemp(suffix='.png') + os.close(fd) + + # Create a test image with text + img = Image.new('RGB', (300, 200), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) + draw.text((50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)) + img.save(image_path) + + try: + yield image_path + finally: + # Clean up the temporary image file + if os.path.exists(image_path): + os.unlink(image_path) + +@pytest.mark.integration +def test_note_with_embedded_image(nc_client: NextcloudClient, test_image): + """ + Test creating a note with an embedded image and verify the process works end-to-end. + This test documents how images should be embedded in Nextcloud Notes. + """ + # Generate a unique identifier for this test run + unique_id = str(uuid.uuid4())[:8] + note_title = f"Embedded Image Test {unique_id}" + initial_content = "# Embedded Image Test\n\nThis note demonstrates how to properly embed images in Nextcloud Notes." + + # Create the note + print(f"Creating test note: {note_title}") + note = nc_client.notes_create_note( + title=note_title, + content=initial_content, + category="Documentation" + ) + note_id = note["id"] + note_etag = note["etag"] + print(f"Note created with ID: {note_id}") + + try: + # Read the test image content + with open(test_image, 'rb') as f: + image_content = f.read() + + # Generate a unique filename for the attachment + attachment_filename = f"test_image_{unique_id}.png" + + # Upload the image as an attachment + print(f"Uploading image attachment '{attachment_filename}' to note {note_id}...") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=image_content, + mime_type="image/png" + ) + print(f"Image uploaded: {upload_response}") + + # Update the note content to include the embedded image using Markdown syntax + # This is the correct syntax for embedding images in Nextcloud Notes + updated_content = f"""# Embedded Image Test + +This note demonstrates how to properly embed images in Nextcloud Notes. + +## Method 1: Markdown Image Syntax +![Test Image](.attachments.{note_id}/{attachment_filename}) + +## Method 2: HTML Image Tag +Test Image HTML + +## Notes on Image Embedding +- Images must be stored in the .attachments.{note_id} directory +- Images are referenced using relative paths +- Both Markdown and HTML image tags work in Nextcloud Notes +- The Nextcloud Notes UI will display these images inline when viewing the note +""" + + # Update the note with the image references + print("Updating note content with image references...") + updated_note = nc_client.notes_update_note( + note_id=note_id, + etag=note_etag, + content=updated_content + ) + + # Verify the updated note has the correct content + retrieved_note = nc_client.notes_get_note(note_id=note_id) + assert ".attachments." in retrieved_note["content"], "Image reference not found in note content" + print("Note updated successfully with image references") + + # Verify we can retrieve the image attachment + retrieved_content, mime_type = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + assert len(retrieved_content) > 0, "Retrieved image content is empty" + assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" + + print("Test completed successfully - image was embedded in the note and can be retrieved") + + finally: + # Clean up - delete the test note + print(f"Cleaning up - deleting test note {note_id}") + nc_client.notes_delete_note(note_id=note_id) diff --git a/tests/test_note_attachment_cleanup.py b/tests/test_note_attachment_cleanup.py new file mode 100644 index 0000000..cf8f2cf --- /dev/null +++ b/tests/test_note_attachment_cleanup.py @@ -0,0 +1,100 @@ +import pytest +import os +import time +import uuid +from httpx import HTTPStatusError +from nextcloud_mcp_server.client import NextcloudClient + +# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set + +@pytest.fixture(scope="module") +def nc_client() -> NextcloudClient: + """ + Fixture to create a NextcloudClient instance for integration tests. + """ + assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set" + assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set" + assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set" + return NextcloudClient.from_env() + +@pytest.mark.integration +def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient): + """ + Test to verify and document that when a note is deleted, its attachments remain + in the system. This is the expected behavior of the Nextcloud Notes app. + """ + # --- Create Note --- + unique_id = str(uuid.uuid4()) + note_title = f"Attachment Cleanup Test {unique_id}" + note_content = f"# Test for attachment cleanup behavior\n\nThis note will be deleted, but attachments should remain." + note_category = "CleanupTests" + + created_note = None + note_id = None + + try: + # Create the note + print(f"Creating note: {note_title}") + created_note = nc_client.notes_create_note( + title=note_title, + content=note_content, + category=note_category + ) + assert created_note and "id" in created_note + note_id = created_note["id"] + print(f"Note created with ID: {note_id}") + time.sleep(1) + + # Create a simple text attachment + attachment_filename = f"orphan_test_{unique_id}.txt" + attachment_content = f"This is a test attachment for note {note_id}".encode('utf-8') + + # Attach the file to the note + print(f"Attaching text file to note {note_id}...") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=attachment_content, + mime_type="text/plain" + ) + + assert upload_response["status_code"] in [201, 204] + print(f"Attachment added successfully (Status: {upload_response['status_code']}).") + time.sleep(1) + + # Verify the attachment exists + content, mime_type = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + + assert content == attachment_content, "Attachment content mismatch" + print("Attachment verified") + + # Now delete the note + print(f"Deleting note ID: {note_id}") + nc_client.notes_delete_note(note_id=note_id) + print(f"Note deleted successfully.") + time.sleep(1) + + # Verify the note is deleted + with pytest.raises(HTTPStatusError) as excinfo: + nc_client.notes_get_note(note_id=note_id) + assert excinfo.value.response.status_code == 404 + print(f"Verified note deletion (404 Not Found)") + + # Now check if the attachment still exists (expected behavior: it should) + print(f"Checking if attachment still exists after note deletion...") + orphaned_content, orphaned_mime = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + + # If we get here without an exception, the attachment still exists + print("CONFIRMED: Attachment still exists after note deletion") + print("This is the expected behavior of the Nextcloud Notes app") + assert orphaned_content == attachment_content, "Orphaned attachment content mismatch" + + finally: + # No cleanup needed since we've already deleted the note + pass diff --git a/tests/test_note_image_integration.py b/tests/test_note_image_integration.py new file mode 100644 index 0000000..26225ce --- /dev/null +++ b/tests/test_note_image_integration.py @@ -0,0 +1,133 @@ +import pytest +import os +import time +import uuid +import tempfile +from httpx import HTTPStatusError +from PIL import Image, ImageDraw +from nextcloud_mcp_server.client import NextcloudClient + +# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set + +@pytest.fixture(scope="module") +def nc_client() -> NextcloudClient: + """ + Fixture to create a NextcloudClient instance for integration tests. + """ + assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set" + assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set" + assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set" + return NextcloudClient.from_env() + +@pytest.fixture +def test_image(): + """Generate a test image for attachment tests""" + # Create a temporary file to store the test image + fd, image_path = tempfile.mkstemp(suffix='.png') + os.close(fd) + + # Create a simple test image + img = Image.new('RGB', (200, 200), color = (255, 255, 255)) + draw = ImageDraw.Draw(img) + draw.rectangle([(20, 20), (180, 180)], fill=(255, 0, 0)) + draw.text((40, 100), "Nextcloud MCP Test", fill=(255, 255, 255)) + img.save(image_path) + + try: + yield image_path + finally: + # Clean up the temporary image file + if os.path.exists(image_path): + os.unlink(image_path) + +@pytest.mark.integration +def test_note_with_image_attachment(nc_client: NextcloudClient, test_image): + """ + Test creating a note with an image attachment and properly embedding it + in the note content using Nextcloud Notes' syntax. + """ + # --- Create Note --- + unique_id = str(uuid.uuid4()) + note_title = f"Note with Embedded Image {unique_id}" + note_content = "# Note with Embedded Image\n\nThis note contains an embedded image." + note_category = "ImageTests" + + created_note = None + note_id = None + + try: + # Create the note + print(f"Creating note: {note_title}") + created_note = nc_client.notes_create_note( + title=note_title, + content=note_content, + category=note_category + ) + assert created_note and "id" in created_note + note_id = created_note["id"] + print(f"Note created with ID: {note_id}") + time.sleep(1) + + # Read the test image + with open(test_image, 'rb') as f: + image_content = f.read() + + # Attach the image to the note + attachment_filename = f"test_image_{unique_id}.png" + print(f"Attaching image to note {note_id}...") + upload_response = nc_client.add_note_attachment( + note_id=note_id, + filename=attachment_filename, + content=image_content, + mime_type="image/png" + ) + + assert upload_response["status_code"] in [201, 204] + print(f"Image attached successfully (Status: {upload_response['status_code']}).") + time.sleep(1) + + # Update the note content to include a reference to the attached image + # Try embedding using Markdown image syntax + updated_content = f"""# Note with Embedded Image + +This note contains an embedded image. + +## Embedded Image (Markdown Syntax) +![Test Image](.attachments.{note_id}/{attachment_filename}) + +## WebDAV URL +Files path: `/Notes/.attachments.{note_id}/{attachment_filename}` +""" + + # Update the note content + print("Updating note content to include image reference...") + updated_note = nc_client.notes_update_note( + note_id=note_id, + etag=created_note["etag"], + content=updated_content + ) + + # Retrieve the note to verify content + retrieved_note = nc_client.notes_get_note(note_id=note_id) + print("Retrieved note content:") + print(retrieved_note["content"]) + + # Verify the image attachment can be retrieved + content, mime_type = nc_client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + + assert content == image_content, "Attachment content mismatch" + assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}" + print("Image attachment verified") + + finally: + # Cleanup + if note_id: + print(f"Cleaning up - deleting note ID: {note_id}") + try: + nc_client.notes_delete_note(note_id=note_id) + print(f"Note {note_id} deleted") + except Exception as e: + print(f"Error during cleanup: {e}") diff --git a/update_content_with_image_reference.py b/update_content_with_image_reference.py new file mode 100644 index 0000000..f5c4efc --- /dev/null +++ b/update_content_with_image_reference.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +import sys +from nextcloud_mcp_server.client import NextcloudClient + +def main(): + note_id = 420 # ID of the note with the image attachment + + # Create client + client = NextcloudClient.from_env() + + # First get the current note + try: + note = client.notes_get_note(note_id=note_id) + print(f"Retrieved note: {note['title']}") + + # Update the note content to include a direct reference to the image + updated_content = f"""# Note with Image Attachment + +This note demonstrates attaching images to Nextcloud Notes. + +An image will be attached to this note as a demonstration. + +## Image Reference + +The image is attached but not displayed inline in the Notes UI. +Attachments in Nextcloud Notes exist as separate files in the .attachments.{note_id} +directory but aren't automatically embedded in the note content. + +You can view the image by going to the Files app and navigating to: +/Notes/.attachments.{note_id}/sample_image.png + +## Orphaned Attachments + +When notes are deleted, their attachments remain in the system. +This is the expected behavior of the official Nextcloud Notes app. +""" + + # Update the note with the new content + updated_note = client.notes_update_note( + note_id=note_id, + etag=note['etag'], + content=updated_content + ) + + print(f"Note updated successfully with image reference information.") + return 0 + + except Exception as e: + print(f"Error updating note: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/update_image_reference.py b/update_image_reference.py new file mode 100644 index 0000000..80f6d84 --- /dev/null +++ b/update_image_reference.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +import os +import sys +from nextcloud_mcp_server.client import NextcloudClient + +def main(): + note_id = 487 # ID of the note with the issue + + # Create client + client = NextcloudClient.from_env() + + try: + # Get the current note to get its etag + note = client.notes_get_note(note_id=note_id) + etag = note["etag"] + + # Update the note content with correct image reference syntax + updated_content = f"""# Note with Visible Image Demo + +This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface. + +We'll include the sample red square image we created earlier using both Markdown and HTML methods. + +## Method 1: Markdown Image Syntax +![Sample Red Square Image](.attachments.{note_id}/sample_image.png) + +## Method 2: HTML Image Tag +Sample Red Square Image + +## Image Path Details +The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png` + +## Note on Image Embedding +In Nextcloud Notes, images must be referenced with a period at the beginning of the path. The correct format is: +`.attachments.{note_id}/filename.png` + +Without the leading period, the image won't display correctly. +""" + + # Update the note with the corrected image references + updated_note = client.notes_update_note( + note_id=note_id, + etag=etag, + content=updated_content + ) + + print(f"Note updated with corrected image references.") + print(f"Note URL: /index.php/apps/notes/#/note/{note_id}") + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/update_webdav_auth.py b/update_webdav_auth.py new file mode 100644 index 0000000..0be4673 --- /dev/null +++ b/update_webdav_auth.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +import sys +import os +import base64 +from nextcloud_mcp_server.client import NextcloudClient +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def test_webdav_auth(): + """ + Test function to verify WebDAV authentication and compare with current implementation. + """ + # Create client using standard method + client = NextcloudClient.from_env() + print("Client authentication type:", type(client._client.auth).__name__) + + # Get WebDAV base path + username = os.environ["NEXTCLOUD_USERNAME"] + password = os.environ["NEXTCLOUD_PASSWORD"] + webdav_base = client._get_webdav_base_path() + + # Test path for Notes directory + notes_path = f"{webdav_base}/Notes" + print(f"Testing WebDAV access to: {notes_path}") + + # 1. Test with existing client auth + try: + print("\nTest 1: Using existing client authentication") + response = client._client.request("PROPFIND", notes_path, headers={"Depth": "0"}) + print(f"Status code: {response.status_code}") + if response.status_code >= 400: + print(f"Error: {response.text}") + else: + print("Success! Current auth method works") + except Exception as e: + print(f"Error: {str(e)}") + + # 2. Test with explicit Authorization header + try: + print("\nTest 2: Using explicit Authorization header") + # Create base64 encoded credentials + auth_string = f"{username}:{password}" + auth_bytes = auth_string.encode('ascii') + base64_bytes = base64.b64encode(auth_bytes) + base64_auth = base64_bytes.decode('ascii') + + # Make request with explicit Authorization header + headers = { + "Depth": "0", + "Authorization": f"Basic {base64_auth}" + } + + # Use client without auth to test explicit header + response = client._client.request( + "PROPFIND", + notes_path, + headers=headers, + auth=None # Override client auth + ) + + print(f"Status code: {response.status_code}") + if response.status_code >= 400: + print(f"Error: {response.text}") + else: + print("Success! Explicit authorization header works") + except Exception as e: + print(f"Error: {str(e)}") + + return 0 + +if __name__ == "__main__": + sys.exit(test_webdav_auth()) diff --git a/verify_image_attachment.py b/verify_image_attachment.py new file mode 100644 index 0000000..281f1b2 --- /dev/null +++ b/verify_image_attachment.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +import sys +from nextcloud_mcp_server.client import NextcloudClient + +def main(): + note_id = 420 # ID of the note we created earlier + + # Create client + client = NextcloudClient.from_env() + + # First verify the note exists + print(f"Retrieving note {note_id}...") + try: + note = client.notes_get_note(note_id=note_id) + print(f"Note retrieved: {note['title']}") + except Exception as e: + print(f"Error retrieving note: {e}") + return 1 + + # Now try to get the image attachment + attachment_filename = "sample_image.png" + print(f"Retrieving attachment '{attachment_filename}' from note {note_id}...") + try: + content, mime_type = client.get_note_attachment( + note_id=note_id, + filename=attachment_filename + ) + print(f"Attachment retrieved successfully!") + print(f"MIME type: {mime_type}") + print(f"Content size: {len(content)} bytes") + + # Save the retrieved image to verify it's the same + output_path = "retrieved_image.png" + with open(output_path, 'wb') as f: + f.write(content) + print(f"Saved retrieved image to: {output_path}") + + return 0 + except Exception as e: + print(f"Error retrieving attachment: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main())