feat: Add new `canvas` tool to create json canvas files in obsidian. by phernandez · Pull Request #14 · basicmachines-co/basic-memory · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions data/json_canvas_spec_1_0.md
107 changes: 105 additions & 2 deletions src/basic_memory/api/routers/resource_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import tempfile
from pathlib import Path
from typing import Annotated

from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
from fastapi.responses import FileResponse, JSONResponse
from loguru import logger

from basic_memory.deps import (
Expand All @@ -13,10 +14,13 @@
SearchServiceDep,
EntityServiceDep,
FileServiceDep,
EntityRepositoryDep,
)
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.memory import normalize_memory_url
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.models.knowledge import Entity as EntityModel
from datetime import datetime

router = APIRouter(prefix="/resource", tags=["resources"])

Expand Down Expand Up @@ -122,3 +126,102 @@ def cleanup_temp_file(file_path: str):
logger.debug(f"Temporary file deleted: {file_path}")
except Exception as e: # pragma: no cover
logger.error(f"Error deleting temporary file {file_path}: {e}")


@router.put("/{file_path:path}")
async def write_resource(
config: ProjectConfigDep,
file_service: FileServiceDep,
entity_repository: EntityRepositoryDep,
search_service: SearchServiceDep,
file_path: str,
content: Annotated[str, Body()],
) -> JSONResponse:
"""Write content to a file in the project.

This endpoint allows writing content directly to a file in the project.
Also creates an entity record and indexes the file for search.

Args:
file_path: Path to write to, relative to project root
request: Contains the content to write

Returns:
JSON response with file information
"""
try:
# Get content from request body

# Ensure it's UTF-8 string content
if isinstance(content, bytes): # pragma: no cover
content_str = content.decode("utf-8")
else:
content_str = str(content)

# Get full file path
full_path = Path(f"{config.home}/{file_path}")

# Ensure parent directory exists
full_path.parent.mkdir(parents=True, exist_ok=True)

# Write content to file
checksum = await file_service.write_file(full_path, content_str)

# Get file info
file_stats = file_service.file_stats(full_path)

# Determine file details
file_name = Path(file_path).name
content_type = file_service.content_type(full_path)

entity_type = "canvas" if file_path.endswith(".canvas") else "file"

# Check if entity already exists
existing_entity = await entity_repository.get_by_file_path(file_path)

if existing_entity:
# Update existing entity
entity = await entity_repository.update(
existing_entity.id,
{
"title": file_name,
"entity_type": entity_type,
"content_type": content_type,
"file_path": file_path,
"checksum": checksum,
"updated_at": datetime.fromtimestamp(file_stats.st_mtime),
},
)
assert entity is not None, "Entity should be returned after update"
status_code = 200
else:
# Create a new entity model
entity = EntityModel(
title=file_name,
entity_type=entity_type,
content_type=content_type,
file_path=file_path,
checksum=checksum,
created_at=datetime.fromtimestamp(file_stats.st_ctime),
updated_at=datetime.fromtimestamp(file_stats.st_mtime),
)
entity = await entity_repository.add(entity)
status_code = 201

# Index the file for search
await search_service.index_entity(entity)

# Return success response
return JSONResponse(
status_code=status_code,
content={
"file_path": file_path,
"checksum": checksum,
"size": file_stats.st_size,
"created_at": file_stats.st_ctime,
"modified_at": file_stats.st_mtime,
},
)
except Exception as e: # pragma: no cover
logger.error(f"Error writing resource {file_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to write resource: {str(e)}")
3 changes: 3 additions & 0 deletions src/basic_memory/mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from basic_memory.mcp.tools.memory import build_context, recent_activity
from basic_memory.mcp.tools.notes import read_note, write_note
from basic_memory.mcp.tools.search import search
from basic_memory.mcp.tools.canvas import canvas

from basic_memory.mcp.tools.knowledge import (
delete_entities,
Expand All @@ -32,4 +33,6 @@
"write_note",
# files
"read_resource",
# canvas
"canvas",
]
107 changes: 107 additions & 0 deletions src/basic_memory/mcp/tools/canvas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Canvas creation tool for Basic Memory MCP server.

This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec.
"""

import json
from pathlib import Path
from typing import Dict, List, Any

import logfire
from loguru import logger

from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put


@mcp.resource("spec://canvas")
def canvas_spec() -> str:
"""Static configuration data"""
canvas_spec = Path(__file__).parent.parent.parent.parent.parent / "data/json_canvas_spec_1_0.md"
return canvas_spec.read_text()


@mcp.tool(
description="Create an Obsidian canvas file to visualize concepts and connections.",
)
async def canvas(
nodes: List[Dict[str, Any]],
edges: List[Dict[str, Any]],
title: str,
folder: str,
) -> str:
"""Create an Obsidian canvas file with the provided nodes and edges.

This tool creates a .canvas file compatible with Obsidian's Canvas feature,
allowing visualization of relationships between concepts or documents.

For the full JSON Canvas 1.0 specification, see the 'spec://canvas' resource.

Args:
nodes: List of node objects following JSON Canvas 1.0 spec
edges: List of edge objects following JSON Canvas 1.0 spec
title: The title of the canvas (will be saved as title.canvas)
folder: The folder where the file should be saved

Returns:
A summary of the created canvas file

Important Notes:
- When referencing files, use the exact file path as shown in Obsidian
Example: "folder/Document Name.md" (not permalink format)
- For file nodes, the "file" attribute must reference an existing file
- Nodes require id, type, x, y, width, height properties
- Edges require id, fromNode, toNode properties
- Position nodes in a logical layout (x,y coordinates in pixels)
- Use color attributes ("1"-"6" or hex) for visual organization

Basic Structure:
```json
{
"nodes": [
{
"id": "node1",
"type": "file", // Options: "file", "text", "link", "group"
"file": "folder/Document.md",
"x": 0,
"y": 0,
"width": 400,
"height": 300
}
],
"edges": [
{
"id": "edge1",
"fromNode": "node1",
"toNode": "node2",
"label": "connects to"
}
]
}
```
"""
with logfire.span("Creating canvas", folder=folder, title=title): # type: ignore
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
file_path = f"{folder}/{file_title}"

# Create canvas data structure
canvas_data = {"nodes": nodes, "edges": edges}

# Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2)

# Write the file using the resource API
logger.info(f"Creating canvas file: {file_path}")
response = await call_put(client, f"/resource/{file_path}", json=canvas_json)

# Parse response
result = response.json()
logger.debug(result)

# Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]

return "\n".join(summary)
1 change: 1 addition & 0 deletions src/basic_memory/mcp/tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ async def call_put(
timeout=timeout,
extensions=extensions,
)
logger.debug(response)
response.raise_for_status()
return response
except HTTPStatusError as e:
Expand Down
5 changes: 4 additions & 1 deletion src/basic_memory/schemas/base.py
Loading