Skip to content

Commit 0d7b0b3

Browse files
phernandezphernandez
andauthored
feat: Add new canvas tool to create json canvas files in obsidian. (#14)
Add new `canvas` tool to create json canvas files in obsidian. --------- Co-authored-by: phernandez <phernandez@basicmachines.co>
1 parent 93cc637 commit 0d7b0b3

11 files changed

Lines changed: 772 additions & 4 deletions

File tree

data/json_canvas_spec_1_0.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
---
2+
title: JSON Canvas Spec
3+
version: 1.0
4+
url: https://raw.githubusercontent.com/obsidianmd/jsoncanvas/refs/heads/main/spec/1.0.md
5+
---
6+
7+
# JSON Canvas Spec
8+
9+
<small>Version 1.0 — 2024-03-11</small>
10+
11+
## Top level
12+
13+
The top level of JSON Canvas contains two arrays:
14+
15+
- `nodes` (optional, array of nodes)
16+
- `edges` (optional, array of edges)
17+
18+
## Nodes
19+
20+
Nodes are objects within the canvas. Nodes may be text, files, links, or groups.
21+
22+
Nodes are placed in the array in ascending order by z-index. The first node in the array should be displayed below all
23+
other nodes, and the last node in the array should be displayed on top of all other nodes.
24+
25+
### Generic node
26+
27+
All nodes include the following attributes:
28+
29+
- `id` (required, string) is a unique ID for the node.
30+
- `type` (required, string) is the node type.
31+
- `text`
32+
- `file`
33+
- `link`
34+
- `group`
35+
- `x` (required, integer) is the `x` position of the node in pixels.
36+
- `y` (required, integer) is the `y` position of the node in pixels.
37+
- `width` (required, integer) is the width of the node in pixels.
38+
- `height` (required, integer) is the height of the node in pixels.
39+
- `color` (optional, `canvasColor`) is the color of the node, see the Color section.
40+
41+
### Text type nodes
42+
43+
Text type nodes store text. Along with generic node attributes, text nodes include the following attribute:
44+
45+
- `text` (required, string) in plain text with Markdown syntax.
46+
47+
### File type nodes
48+
49+
File type nodes reference other files or attachments, such as images, videos, etc. Along with generic node attributes,
50+
file nodes include the following attributes:
51+
52+
- `file` (required, string) is the path to the file within the system.
53+
- `subpath` (optional, string) is a subpath that may link to a heading or a block. Always starts with a `#`.
54+
55+
### Link type nodes
56+
57+
Link type nodes reference a URL. Along with generic node attributes, link nodes include the following attribute:
58+
59+
- `url` (required, string)
60+
61+
### Group type nodes
62+
63+
Group type nodes are used as a visual container for nodes within it. Along with generic node attributes, group nodes
64+
include the following attributes:
65+
66+
- `label` (optional, string) is a text label for the group.
67+
- `background` (optional, string) is the path to the background image.
68+
- `backgroundStyle` (optional, string) is the rendering style of the background image. Valid values:
69+
- `cover` fills the entire width and height of the node.
70+
- `ratio` maintains the aspect ratio of the background image.
71+
- `repeat` repeats the image as a pattern in both x/y directions.
72+
73+
## Edges
74+
75+
Edges are lines that connect one node to another.
76+
77+
- `id` (required, string) is a unique ID for the edge.
78+
- `fromNode` (required, string) is the node `id` where the connection starts.
79+
- `fromSide` (optional, string) is the side where this edge starts. Valid values:
80+
- `top`
81+
- `right`
82+
- `bottom`
83+
- `left`
84+
- `fromEnd` (optional, string) is the shape of the endpoint at the edge start. Defaults to `none` if not specified.
85+
Valid values:
86+
- `none`
87+
- `arrow`
88+
- `toNode` (required, string) is the node `id` where the connection ends.
89+
- `toSide` (optional, string) is the side where this edge ends. Valid values:
90+
- `top`
91+
- `right`
92+
- `bottom`
93+
- `left`
94+
- `toEnd` (optional, string) is the shape of the endpoint at the edge end. Defaults to `arrow` if not specified. Valid
95+
values:
96+
- `none`
97+
- `arrow`
98+
- `color` (optional, `canvasColor`) is the color of the line, see the Color section.
99+
- `label` (optional, string) is a text label for the edge.
100+
101+
## Color
102+
103+
The `canvasColor` type is used to encode color data for nodes and edges. Colors attributes expect a string. Colors can
104+
be specified in hex format e.g. `"#FF0000"`, or using one of the preset colors, e.g. `"1"` for red. Six preset colors
105+
exist, mapped to the following numbers:
106+
107+
- `"1"` red
108+
- `"2"` orange
109+
- `"3"` yellow
110+
- `"4"` green
111+
- `"5"` cyan
112+
- `"6"` purple
113+
114+
Specific values for the preset colors are intentionally not defined so that applications can tailor the presets to their
115+
specific brand colors or color scheme.

src/basic_memory/api/routers/resource_router.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import tempfile
44
from pathlib import Path
5+
from typing import Annotated
56

6-
from fastapi import APIRouter, HTTPException, BackgroundTasks
7-
from fastapi.responses import FileResponse
7+
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
8+
from fastapi.responses import FileResponse, JSONResponse
89
from loguru import logger
910

1011
from basic_memory.deps import (
@@ -13,10 +14,13 @@
1314
SearchServiceDep,
1415
EntityServiceDep,
1516
FileServiceDep,
17+
EntityRepositoryDep,
1618
)
1719
from basic_memory.repository.search_repository import SearchIndexRow
1820
from basic_memory.schemas.memory import normalize_memory_url
1921
from basic_memory.schemas.search import SearchQuery, SearchItemType
22+
from basic_memory.models.knowledge import Entity as EntityModel
23+
from datetime import datetime
2024

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

@@ -122,3 +126,102 @@ def cleanup_temp_file(file_path: str):
122126
logger.debug(f"Temporary file deleted: {file_path}")
123127
except Exception as e: # pragma: no cover
124128
logger.error(f"Error deleting temporary file {file_path}: {e}")
129+
130+
131+
@router.put("/{file_path:path}")
132+
async def write_resource(
133+
config: ProjectConfigDep,
134+
file_service: FileServiceDep,
135+
entity_repository: EntityRepositoryDep,
136+
search_service: SearchServiceDep,
137+
file_path: str,
138+
content: Annotated[str, Body()],
139+
) -> JSONResponse:
140+
"""Write content to a file in the project.
141+
142+
This endpoint allows writing content directly to a file in the project.
143+
Also creates an entity record and indexes the file for search.
144+
145+
Args:
146+
file_path: Path to write to, relative to project root
147+
request: Contains the content to write
148+
149+
Returns:
150+
JSON response with file information
151+
"""
152+
try:
153+
# Get content from request body
154+
155+
# Ensure it's UTF-8 string content
156+
if isinstance(content, bytes): # pragma: no cover
157+
content_str = content.decode("utf-8")
158+
else:
159+
content_str = str(content)
160+
161+
# Get full file path
162+
full_path = Path(f"{config.home}/{file_path}")
163+
164+
# Ensure parent directory exists
165+
full_path.parent.mkdir(parents=True, exist_ok=True)
166+
167+
# Write content to file
168+
checksum = await file_service.write_file(full_path, content_str)
169+
170+
# Get file info
171+
file_stats = file_service.file_stats(full_path)
172+
173+
# Determine file details
174+
file_name = Path(file_path).name
175+
content_type = file_service.content_type(full_path)
176+
177+
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
178+
179+
# Check if entity already exists
180+
existing_entity = await entity_repository.get_by_file_path(file_path)
181+
182+
if existing_entity:
183+
# Update existing entity
184+
entity = await entity_repository.update(
185+
existing_entity.id,
186+
{
187+
"title": file_name,
188+
"entity_type": entity_type,
189+
"content_type": content_type,
190+
"file_path": file_path,
191+
"checksum": checksum,
192+
"updated_at": datetime.fromtimestamp(file_stats.st_mtime),
193+
},
194+
)
195+
assert entity is not None, "Entity should be returned after update"
196+
status_code = 200
197+
else:
198+
# Create a new entity model
199+
entity = EntityModel(
200+
title=file_name,
201+
entity_type=entity_type,
202+
content_type=content_type,
203+
file_path=file_path,
204+
checksum=checksum,
205+
created_at=datetime.fromtimestamp(file_stats.st_ctime),
206+
updated_at=datetime.fromtimestamp(file_stats.st_mtime),
207+
)
208+
entity = await entity_repository.add(entity)
209+
status_code = 201
210+
211+
# Index the file for search
212+
await search_service.index_entity(entity)
213+
214+
# Return success response
215+
return JSONResponse(
216+
status_code=status_code,
217+
content={
218+
"file_path": file_path,
219+
"checksum": checksum,
220+
"size": file_stats.st_size,
221+
"created_at": file_stats.st_ctime,
222+
"modified_at": file_stats.st_mtime,
223+
},
224+
)
225+
except Exception as e: # pragma: no cover
226+
logger.error(f"Error writing resource {file_path}: {e}")
227+
raise HTTPException(status_code=500, detail=f"Failed to write resource: {str(e)}")

src/basic_memory/mcp/tools/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from basic_memory.mcp.tools.memory import build_context, recent_activity
1111
from basic_memory.mcp.tools.notes import read_note, write_note
1212
from basic_memory.mcp.tools.search import search
13+
from basic_memory.mcp.tools.canvas import canvas
1314

1415
from basic_memory.mcp.tools.knowledge import (
1516
delete_entities,
@@ -32,4 +33,6 @@
3233
"write_note",
3334
# files
3435
"read_resource",
36+
# canvas
37+
"canvas",
3538
]
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Canvas creation tool for Basic Memory MCP server.
2+
3+
This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec.
4+
"""
5+
6+
import json
7+
from pathlib import Path
8+
from typing import Dict, List, Any
9+
10+
import logfire
11+
from loguru import logger
12+
13+
from basic_memory.mcp.async_client import client
14+
from basic_memory.mcp.server import mcp
15+
from basic_memory.mcp.tools.utils import call_put
16+
17+
18+
@mcp.resource("spec://canvas")
19+
def canvas_spec() -> str:
20+
"""Static configuration data"""
21+
canvas_spec = Path(__file__).parent.parent.parent.parent.parent / "data/json_canvas_spec_1_0.md"
22+
return canvas_spec.read_text()
23+
24+
25+
@mcp.tool(
26+
description="Create an Obsidian canvas file to visualize concepts and connections.",
27+
)
28+
async def canvas(
29+
nodes: List[Dict[str, Any]],
30+
edges: List[Dict[str, Any]],
31+
title: str,
32+
folder: str,
33+
) -> str:
34+
"""Create an Obsidian canvas file with the provided nodes and edges.
35+
36+
This tool creates a .canvas file compatible with Obsidian's Canvas feature,
37+
allowing visualization of relationships between concepts or documents.
38+
39+
For the full JSON Canvas 1.0 specification, see the 'spec://canvas' resource.
40+
41+
Args:
42+
nodes: List of node objects following JSON Canvas 1.0 spec
43+
edges: List of edge objects following JSON Canvas 1.0 spec
44+
title: The title of the canvas (will be saved as title.canvas)
45+
folder: The folder where the file should be saved
46+
47+
Returns:
48+
A summary of the created canvas file
49+
50+
Important Notes:
51+
- When referencing files, use the exact file path as shown in Obsidian
52+
Example: "folder/Document Name.md" (not permalink format)
53+
- For file nodes, the "file" attribute must reference an existing file
54+
- Nodes require id, type, x, y, width, height properties
55+
- Edges require id, fromNode, toNode properties
56+
- Position nodes in a logical layout (x,y coordinates in pixels)
57+
- Use color attributes ("1"-"6" or hex) for visual organization
58+
59+
Basic Structure:
60+
```json
61+
{
62+
"nodes": [
63+
{
64+
"id": "node1",
65+
"type": "file", // Options: "file", "text", "link", "group"
66+
"file": "folder/Document.md",
67+
"x": 0,
68+
"y": 0,
69+
"width": 400,
70+
"height": 300
71+
}
72+
],
73+
"edges": [
74+
{
75+
"id": "edge1",
76+
"fromNode": "node1",
77+
"toNode": "node2",
78+
"label": "connects to"
79+
}
80+
]
81+
}
82+
```
83+
"""
84+
with logfire.span("Creating canvas", folder=folder, title=title): # type: ignore
85+
# Ensure path has .canvas extension
86+
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
87+
file_path = f"{folder}/{file_title}"
88+
89+
# Create canvas data structure
90+
canvas_data = {"nodes": nodes, "edges": edges}
91+
92+
# Convert to JSON
93+
canvas_json = json.dumps(canvas_data, indent=2)
94+
95+
# Write the file using the resource API
96+
logger.info(f"Creating canvas file: {file_path}")
97+
response = await call_put(client, f"/resource/{file_path}", json=canvas_json)
98+
99+
# Parse response
100+
result = response.json()
101+
logger.debug(result)
102+
103+
# Build summary
104+
action = "Created" if response.status_code == 201 else "Updated"
105+
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
106+
107+
return "\n".join(summary)

src/basic_memory/mcp/tools/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ async def call_put(
7979
timeout=timeout,
8080
extensions=extensions,
8181
)
82+
logger.debug(response)
8283
response.raise_for_status()
8384
return response
8485
except HTTPStatusError as e:

src/basic_memory/schemas/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ class Entity(BaseModel):
159159
@property
160160
def file_path(self):
161161
"""Get the file path for this entity based on its permalink."""
162-
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
162+
if self.content_type == "text/markdown":
163+
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
164+
else:
165+
return f"{self.folder}/{self.title}" if self.folder else self.title
163166

164167
@property
165168
def permalink(self) -> Permalink:

0 commit comments

Comments
 (0)