feat: Add TablesClient and associated tools
This commit is contained in:
@@ -10,6 +10,7 @@ import logging
|
||||
|
||||
from .notes_client import NotesClient
|
||||
from .webdav_client import WebDAVClient
|
||||
from .tables_client import TablesClient
|
||||
from .controllers.notes_search import NotesSearchController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -44,6 +45,7 @@ class NextcloudClient:
|
||||
# Initialize app clients
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
self.tables = TablesClient(self._client, username)
|
||||
|
||||
# Initialize controllers
|
||||
self._notes_search = NotesSearchController()
|
||||
@@ -67,84 +69,11 @@ class NextcloudClient:
|
||||
|
||||
return response.json()
|
||||
|
||||
# Convenience methods that delegate to subclients
|
||||
async def notes_get_settings(self):
|
||||
"""Get Notes app settings."""
|
||||
return await self.notes.get_settings()
|
||||
|
||||
async def notes_get_all(self):
|
||||
"""Get all notes."""
|
||||
return await self.notes.get_all_notes()
|
||||
|
||||
async def notes_get_note(self, *, note_id: int):
|
||||
"""Get a specific note."""
|
||||
return await self.notes.get_note(note_id)
|
||||
|
||||
async def notes_create_note(
|
||||
self,
|
||||
*,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: str | None = None,
|
||||
):
|
||||
"""Create a new note."""
|
||||
return await self.notes.create_note(
|
||||
title=title, content=content, category=category
|
||||
)
|
||||
|
||||
async def notes_update_note(
|
||||
self,
|
||||
*,
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: str | None = None,
|
||||
):
|
||||
"""Update a note."""
|
||||
return await self.notes.update(
|
||||
note_id=note_id, etag=etag, title=title, content=content, category=category
|
||||
)
|
||||
|
||||
async def notes_append_content(self, *, note_id: int, content: str):
|
||||
"""Append content to an existing note with a separator."""
|
||||
return await self.notes.append_content(note_id=note_id, content=content)
|
||||
|
||||
async def notes_search_notes(self, *, query: str):
|
||||
"""Search notes using token-based matching with relevance ranking."""
|
||||
all_notes = await self.notes.get_all_notes()
|
||||
return self._notes_search.search_notes(all_notes, query)
|
||||
|
||||
async def notes_delete_note(self, *, note_id: int):
|
||||
"""Delete a note and its attachments."""
|
||||
return await self.notes.delete_note(note_id)
|
||||
|
||||
async def add_note_attachment(
|
||||
self,
|
||||
*,
|
||||
note_id: int,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
category: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
):
|
||||
"""Add/Update an attachment to a note via WebDAV PUT."""
|
||||
return await self.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=filename,
|
||||
content=content,
|
||||
category=category,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
|
||||
async def get_note_attachment(
|
||||
self, *, note_id: int, filename: str, category: str | None = None
|
||||
):
|
||||
"""Fetch a specific attachment from a note via WebDAV GET."""
|
||||
return await self.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=filename, category=category
|
||||
)
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
@@ -53,21 +53,21 @@ async def notes_get_settings():
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_get_settings()
|
||||
return await client.notes.get_settings()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_get_note(note_id: int, ctx: Context):
|
||||
"""Get user note using note id"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_get_note(note_id=note_id)
|
||||
return await client.notes.get_note(note_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
|
||||
"""Create a new note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_create_note(
|
||||
return await client.notes.create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
@@ -85,7 +85,7 @@ async def nc_notes_update_note(
|
||||
):
|
||||
logger.info("Updating note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_update_note(
|
||||
return await client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
title=title,
|
||||
@@ -99,7 +99,7 @@ async def nc_notes_append_content(note_id: int, content: str, ctx: Context):
|
||||
"""Append content to an existing note with a clear separator"""
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_append_content(note_id=note_id, content=content)
|
||||
return await client.notes.append_content(note_id=note_id, content=content)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -113,7 +113,61 @@ async def nc_notes_search_notes(query: str, ctx: Context):
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context):
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_delete_note(note_id=note_id)
|
||||
return await client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.list_tables()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
ctx: Context = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.delete_row(row_id)
|
||||
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
|
||||
@@ -123,7 +177,7 @@ async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
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 = await client.get_note_attachment(
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
|
||||
from .base_client import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TablesClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
async def list_tables(self) -> List[Dict[str, Any]]:
|
||||
"""List all tables available to the user."""
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/ocs/v2.php/apps/tables/api/2/tables",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
result = response.json()
|
||||
return result["ocs"]["data"]
|
||||
|
||||
async def get_table_schema(self, table_id: int) -> Dict[str, Any]:
|
||||
"""Get the schema/structure of a specific table including columns and views."""
|
||||
# Using v1 API as v2 schema endpoint had issues during testing
|
||||
response = await self._make_request(
|
||||
"GET", f"/index.php/apps/tables/api/1/tables/{table_id}/scheme"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_table_rows(
|
||||
self, table_id: int, limit: Optional[int] = None, offset: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Read rows from a table with optional pagination."""
|
||||
params = {}
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
|
||||
response = await self._make_request(
|
||||
"GET", f"/index.php/apps/tables/api/1/tables/{table_id}/rows", params=params
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_row(self, table_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Args:
|
||||
table_id: ID of the table to insert into
|
||||
data: Dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
# Transform data to API format: {"data": {"1": "text", "2": 42}}
|
||||
api_data = {str(k): v for k, v in data.items()}
|
||||
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/apps/tables/api/2/tables/{table_id}/rows",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
json={"data": api_data},
|
||||
)
|
||||
result = response.json()
|
||||
return result["ocs"]["data"]
|
||||
|
||||
async def update_row(self, row_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Args:
|
||||
row_id: ID of the row to update
|
||||
data: Dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
# Transform data to API format for v1 endpoint
|
||||
api_data = {str(k): v for k, v in data.items()}
|
||||
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/index.php/apps/tables/api/1/rows/{row_id}",
|
||||
json={"data": api_data},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_row(self, row_id: int) -> Dict[str, Any]:
|
||||
"""Delete a row from a table."""
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/index.php/apps/tables/api/1/rows/{row_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def transform_row_data(
|
||||
self, rows: List[Dict[str, Any]], columns: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Transform raw row data into more readable format using column names.
|
||||
|
||||
Args:
|
||||
rows: Raw row data from the API
|
||||
columns: Column definitions from table schema
|
||||
|
||||
Returns:
|
||||
List of rows with column names as keys instead of column IDs
|
||||
"""
|
||||
# Create mapping from column ID to column title
|
||||
column_map = {col["id"]: col["title"] for col in columns}
|
||||
|
||||
transformed_rows = []
|
||||
for row in rows:
|
||||
transformed_row = {
|
||||
"id": row["id"],
|
||||
"tableId": row["tableId"],
|
||||
"createdBy": row["createdBy"],
|
||||
"createdAt": row["createdAt"],
|
||||
"lastEditBy": row["lastEditBy"],
|
||||
"lastEditAt": row["lastEditAt"],
|
||||
"data": {},
|
||||
}
|
||||
|
||||
# Transform data array to column_name: value mapping
|
||||
for item in row["data"]:
|
||||
column_id = item["columnId"]
|
||||
column_name = column_map.get(column_id, f"column_{column_id}")
|
||||
transformed_row["data"][column_name] = item["value"]
|
||||
|
||||
transformed_rows.append(transformed_row)
|
||||
|
||||
return transformed_rows
|
||||
Reference in New Issue
Block a user