arcade-mcp/toolkits/notion/arcade_notion_toolkit/utils.py
Eric Gustin 7edaa0a996
Update Notion Toolkit (#309)
1. The Arcade Worker doesn't like it when the package name is different
than the directory name. This PR renames the directory from
`arcade_notion` to `arcade_notion_toolkit`
2. `Notion` is not a well-known provider name in `arcade-ai==1.0.5`,
I've updated the dep to represent this.
2025-03-17 21:15:00 -07:00

225 lines
7.5 KiB
Python

from typing import Any, Optional
import httpx
from arcade.sdk import ToolContext
from arcade_notion_toolkit.constants import ENDPOINTS, NOTION_API_URL, UNTITLED_TITLE
def get_url(endpoint: str, **kwargs: Any) -> str:
"""
Constructs the full URL for a specified notion endpoint.
Args:
endpoint (str): The endpoint key from ENDPOINTS.
**kwargs: Additional parameters to format the URL.
Returns:
str: The complete URL for the specified endpoint.
"""
return f"{NOTION_API_URL}{ENDPOINTS[endpoint].format(**kwargs)}"
def get_headers(context: ToolContext) -> dict[str, str]:
"""
Retrieves the headers for a given context.
Args:
context (ToolContext): The context containing authorization and other information.
Returns:
dict[str, str]: A dictionary containing the headers for the Notion API request.
"""
return {
"Authorization": context.get_auth_token_or_empty(),
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
}
def remove_none_values(payload: dict[str, Any]) -> dict[str, Any]:
"""
Removes all keys with None values from a dictionary.
Args:
payload (dict[str, Any]): The dictionary to remove None values from.
Returns:
dict[str, Any]: A dictionary with all None values removed.
"""
return {k: v for k, v in payload.items() if v is not None}
def extract_title(item: dict) -> str:
"""
Extracts a human-readable title from a page or database, or a block if possible.
Args:
item (dict): The item to extract the title from.
Returns:
str: The human-readable title of the item.
"""
properties: dict = item.get("properties", {})
# Case 1: Extract title from a database object.
if item["object"] == "database" and "title" in item:
return "".join([t.get("plain_text", "") for t in item.get("title", [])])
# Case 2: Extract title from a page object that is parented by the workspace or a page
if item["object"] == "page" and "title" in properties:
return "".join([t["plain_text"] for t in properties["title"].get("title", [])])
# Case 3: Extract title from a page object that is parented a database
elif item["object"] == "page":
for prop in properties.values():
if isinstance(prop, dict) and prop.get("type") == "title":
return "".join([t.get("plain_text", "") for t in prop.get("title", [])])
# Case 4: Extract title from a child page block object
if item.get("object") == "block":
block_type = item.get("type")
if block_type == "child_page":
title: str = item.get("child_page", {}).get("title", UNTITLED_TITLE)
return title
# For text-based blocks, try extracting rich_text.
if block_type in ["paragraph", "heading_1", "heading_2", "heading_3"]:
rich_text = item.get(block_type, {}).get("rich_text", [])
return "".join([t.get("plain_text", "") for t in rich_text]) or block_type
return UNTITLED_TITLE
def simplify_search_result(item: dict) -> dict:
"""
Simplifies a 'search by title' result from the Notion API.
Takes a page object or database object and extracts only the necessary data.
Args:
item (dict): The search result to simplify.
Returns:
dict: A simplified search result
"""
title = extract_title(item)
return {
"id": item.get("id"),
"object": item.get("object"),
"parent": item.get("parent"),
"created_time": item.get("created_time"),
"last_edited_time": item.get("last_edited_time"),
"title": title,
"url": item.get("url"),
"public_url": item.get("public_url"),
}
async def get_next_page(
client: httpx.AsyncClient,
url: str,
headers: dict,
params: Optional[dict] = None,
cursor: Optional[str] = None,
) -> tuple[dict, bool, str]:
"""
Retrieves the next page of results from a Notion API endpoint.
This is a helper function that is useful when paginating through Notion API responses.
Args:
client (httpx.AsyncClient): The HTTP client to use for the request.
url (str): The URL of the endpoint to request.
headers (dict): The headers to use for the request.
params (Optional[dict]): The parameters to use for the request.
cursor (Optional[str]): The cursor to use for the request.
Returns:
tuple[dict, bool, str]: A tuple containing the results, a boolean indicating if there is a
next page, and the next cursor.
"""
params = params or {}
if cursor:
params["start_cursor"] = cursor
elif "start_cursor" in params:
del params["start_cursor"]
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
data = response.json()
return data, data.get("has_more", False), data.get("next_cursor")
async def get_page_url(context: ToolContext, page_id: str) -> str:
"""
Retrieves the URL of a page from the Notion API.
Args:
context (ToolContext): The context containing authorization and other information.
page_id (str): The ID of the page to get the URL of.
Returns:
str: The URL of the page or an empty string if the page's metadata cannot be retrieved.
"""
url = get_url("retrieve_a_page", page_id=page_id)
headers = get_headers(context)
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
if response.status_code != 200:
return ""
data = response.json()
return data.get("url", "") # type: ignore[no-any-return]
def build_workspace_structure(items: list[dict[str, Any]]) -> dict[str, list]:
"""Build a tree structure from a flat list of Notion objects.
Args:
items (list[dict[str, Any]]): A list of Notion objects.
Returns:
dict[str, list]: A tree structure of the workspace.
"""
# For each item, we initialize a children list and then attach it
# under its parent if one exists.
nodes = {}
for item in items:
node = item.copy()
node["children"] = []
nodes[node["id"]] = node
roots = []
for node in nodes.values():
parent = node.get("parent", {})
parent_type = parent.get("type")
if parent_type == "workspace":
# No parent beyond workspace i.e., the node is a root.
roots.append(node)
elif parent_type == "page_id":
parent_id = parent.get("page_id")
if parent_id and parent_id in nodes:
nodes[parent_id]["children"].append(node)
else:
roots.append(node)
elif parent_type == "database_id":
parent_id = parent.get("database_id")
if parent_id and parent_id in nodes:
nodes[parent_id]["children"].append(node)
else:
roots.append(node)
else:
# Fallback: if parent's type is missing or unrecognized, then treat as root.
roots.append(node)
def prune_node(node: dict) -> dict:
"""Get rid of all of the unnecessary fields in a node"""
pruned_node = {
"id": node["id"],
"title": node["title"],
"type": node["object"],
"url": node["url"],
}
if node.get("children"):
pruned_node["children"] = [prune_node(child) for child in node["children"]]
return pruned_node
return {"workspace": [prune_node(root) for root in roots]}