├── .gitignore ├── LICENSE ├── README.md ├── docs └── PyJSONCanvas.md ├── pyjsoncanvas ├── __init__.py ├── encoder.py ├── exceptions.py ├── jsoncanvas.py ├── models.py ├── utils.py └── validate.py ├── sample.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | dist 3 | build 4 | pyjsoncanvas.egg-info/dependency_links.txt 5 | pyjsoncanvas.egg-info/PKG-INFO 6 | pyjsoncanvas.egg-info/SOURCES.txt 7 | pyjsoncanvas.egg-info/top_level.txt 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Chaitanya Sharma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyJSONCanvas 2 | 3 | PyJSONCanvas is a Python library for working with JSON Canvas (previously known as Obsidian Canvas) files. It provides a simple and intuitive API for creating, editing, and manipulating canvas objects, nodes, and edges. 4 | 5 | ## Features 6 | 7 | - Create, load, and save canvas files in JSON format 8 | - Add, remove, and modify nodes (text, file, link, group) 9 | - Add, remove, and modify edges with various styles and colors 10 | - Validate canvas, nodes, and edges for integrity 11 | - Get connections and adjacent nodes for a given node 12 | - Extensive error handling and helpful exception messages 13 | 14 | ## Installation 15 | 16 | You can install PyJSONCanvas using pip: 17 | 18 | ``` 19 | pip install PyJSONCanvas 20 | ``` 21 | 22 | ## Usage 23 | 24 | Here's a basic example of how to use PyJSONCanvas: 25 | 26 | ```python 27 | from pyjsoncanvas import ( 28 | Canvas, 29 | TextNode, 30 | FileNode, 31 | LinkNode, 32 | GroupNode, 33 | GroupNodeBackgroundStyle, 34 | Edge, 35 | Color, 36 | ) 37 | 38 | # Create a new canvas 39 | canvas = Canvas(nodes=[], edges=[]) 40 | 41 | # Add some nodes 42 | text_node = TextNode(x=100, y=100, width=200, height=100, text="Hello, world!") 43 | canvas.add_node(text_node) 44 | 45 | file_node = FileNode(x=300, y=100, width=100, height=100, file="/path/to/file.png") 46 | canvas.add_node(file_node) 47 | 48 | # Add an edge 49 | edge = Edge( 50 | fromNode=text_node.id, 51 | fromSide="bottom", 52 | toNode=file_node.id, 53 | toSide="top", 54 | color=Color("#FF0000"), 55 | label="Edge 1", 56 | ) 57 | canvas.add_edge(edge) 58 | 59 | # Save the canvas as JSON 60 | json_str = canvas.to_json() 61 | 62 | # Load the canvas from JSON 63 | loaded_canvas = Canvas.from_json(json_str) 64 | 65 | # Get a node 66 | node = loaded_canvas.get_node(text_node.id) 67 | 68 | # Get connections for a node 69 | connections = loaded_canvas.get_connections(text_node.id) 70 | ``` 71 | 72 | ## Documentation 73 | 74 | For full documentation, please see the [PyJSONCanvas Documentation](docs/PyJSONCanvas.md). 75 | 76 | ## Contributing 77 | 78 | We welcome contributions to PyJSONCanvas! If you find any issues or have suggestions for improvements, please open an issue or submit a pull request on the [GitHub repository](https://github.com/cheeksthegeek/PyJSONCanvas). 79 | 80 | ## License 81 | 82 | PyJSONCanvas is released under the [MIT License](https://opensource.org/licenses/MIT). 83 | 84 | ## Support 85 | 86 | If you have any questions or need further assistance, please open an issue on the [GitHub repository](https://github.com/cheeksthegeek/PyJSONCanvas) or contact the maintainers. -------------------------------------------------------------------------------- /docs/PyJSONCanvas.md: -------------------------------------------------------------------------------- 1 | 2 | # PyJSONCanvas Documentation 3 | 4 | PyJSONCanvas is a Python library for working with JSON Canvas (previously known as Obsidian Canvas) files. It provides a simple and intuitive API for creating, editing, and manipulating canvas objects, nodes, and edges. 5 | 6 | ## Table of Contents 7 | 8 | 1. [Installation](#installation) 9 | 2. [Basic Usage](#basic-usage) 10 | 3. [Canvas](#canvas) 11 | 4. [Nodes](#nodes) 12 | 5. [Edges](#edges) 13 | 6. [Colors](#colors) 14 | 7. [Exceptions](#exceptions) 15 | 16 | ## Installation 17 | 18 | You can install PyJSONCanvas using pip: 19 | 20 | ``` 21 | pip install PyJSONCanvas 22 | ``` 23 | 24 | ## Basic Usage 25 | 26 | Here's a basic example of how to use PyJSONCanvas: 27 | 28 | ```python 29 | from pyjsoncanvas import Canvas, TextNode, FileNode, Edge, Color 30 | 31 | # Create a new canvas 32 | canvas = Canvas(nodes=[], edges=[]) 33 | 34 | # Add some nodes 35 | text_node = TextNode(x=100, y=100, width=200, height=100, text="Hello, world!") 36 | canvas.add_node(text_node) 37 | 38 | file_node = FileNode(x=300, y=100, width=100, height=100, file="/path/to/file.png") 39 | canvas.add_node(file_node) 40 | 41 | # Add an edge 42 | edge = Edge( 43 | fromNode=text_node.id, 44 | fromSide="bottom", 45 | toNode=file_node.id, 46 | toSide="top", 47 | color=Color("#FF0000"), 48 | label="Edge 1", 49 | ) 50 | canvas.add_edge(edge) 51 | 52 | # Save the canvas as JSON 53 | json_str = canvas.to_json() 54 | 55 | # Load the canvas from JSON 56 | loaded_canvas = Canvas.from_json(json_str) 57 | 58 | # Get a node 59 | node = loaded_canvas.get_node(text_node.id) 60 | 61 | # Get connections for a node 62 | connections = loaded_canvas.get_connections(text_node.id) 63 | ``` 64 | 65 | ## Canvas 66 | 67 | The `Canvas` class is the main container for nodes and edges. 68 | 69 | ### Methods 70 | 71 | - `to_json()`: Convert the canvas to a JSON string. 72 | - `from_json(json_str)`: Create a canvas from a JSON string. 73 | - `export(file_path)`: Save the canvas to a file. 74 | - `validate()`: Validate the canvas structure. 75 | - `get_node(node_id)`: Get a node by its ID. 76 | - `get_edge(edge_id)`: Get an edge by its ID. 77 | - `add_node(node)`: Add a node to the canvas. 78 | - `add_edge(edge)`: Add an edge to the canvas. 79 | - `remove_node(node_id)`: Remove a node from the canvas. 80 | - `remove_edge(edge_id)`: Remove an edge from the canvas. 81 | - `get_connections(node_id)`: Get all edges connected to a node. 82 | - `get_edge_nodes(edge_id)`: Get the nodes connected by an edge. 83 | - `get_adjacent_nodes(node_id)`: Get all nodes adjacent to a given node. 84 | 85 | ## Nodes 86 | 87 | PyJSONCanvas supports four types of nodes: 88 | 89 | 1. `TextNode`: A node containing text. 90 | 2. `FileNode`: A node representing a file. 91 | 3. `LinkNode`: A node containing a URL. 92 | 4. `GroupNode`: A node that can contain other nodes. 93 | 94 | All node types inherit from the `GenericNode` class and have the following common attributes: 95 | 96 | - `id`: Unique identifier for the node. 97 | - `type`: The type of the node (TextNode, FileNode, LinkNode, or GroupNode). 98 | - `x`: X-coordinate of the node. 99 | - `y`: Y-coordinate of the node. 100 | - `width`: Width of the node. 101 | - `height`: Height of the node. 102 | - `color`: Color of the node (optional). 103 | 104 | ### TextNode 105 | 106 | Additional attributes: 107 | - `text`: The text content of the node. 108 | 109 | ### FileNode 110 | 111 | Additional attributes: 112 | - `file`: The path to the file. 113 | - `subpath`: Subpath within the file (optional). 114 | 115 | ### LinkNode 116 | 117 | Additional attributes: 118 | - `url`: The URL of the link. 119 | 120 | ### GroupNode 121 | 122 | Additional attributes: 123 | - `label`: Label for the group (optional). 124 | - `background`: Background image or color (optional). 125 | - `backgroundStyle`: Style of the background (cover, ratio, or repeat). 126 | 127 | ## Edges 128 | 129 | Edges represent connections between nodes. 130 | 131 | ### Attributes 132 | 133 | - `id`: Unique identifier for the edge. 134 | - `fromNode`: ID of the source node. 135 | - `toNode`: ID of the target node. 136 | - `fromSide`: Side of the source node where the edge starts (top, right, bottom, left). 137 | - `toSide`: Side of the target node where the edge ends (top, right, bottom, left). 138 | - `fromEnd`: Style of the edge start (none or arrow). 139 | - `toEnd`: Style of the edge end (none or arrow). 140 | - `color`: Color of the edge. 141 | - `label`: Label for the edge (optional). 142 | 143 | ## Colors 144 | 145 | Colors can be specified using either hex codes or preset values: 146 | 147 | ```python 148 | color1 = Color("#FF0000") # Red using hex code 149 | color2 = Color("4") # Green using preset value 150 | ``` 151 | 152 | ## Exceptions 153 | 154 | PyJSONCanvas defines several custom exceptions to handle various error scenarios. Here's a complete list of exceptions: 155 | 156 | 1. `JsonCanvasException`: Base exception for all JsonCanvas errors. 157 | 2. `InvalidJsonError`: Raised when the JSON is invalid or malformed. 158 | 3. `NodeNotFoundError`: Raised when a specified node is not found. 159 | 4. `EdgeNotFoundError`: Raised when a specified edge is not found. 160 | 5. `InvalidNodeTypeError`: Raised when a node has an invalid or unsupported type. 161 | 6. `InvalidNodeAttributeError`: Raised when a node attribute is invalid or missing. 162 | 7. `InvalidEdgeConnectionError`: Raised when an edge's connection points are invalid. 163 | 8. `InvalidEdgeAttributeError`: Raised when an edge attribute is invalid or missing. 164 | 9. `NodeIDConflictError`: Raised when two nodes have the same ID. 165 | 10. `EdgeIDConflictError`: Raised when two edges have the same ID. 166 | 11. `InvalidColorValueError`: Raised when a color value is invalid or not supported. 167 | 12. `InvalidPositionError`: Raised when a node's position is outside the allowed range. 168 | 13. `InvalidDimensionError`: Raised when a node's dimensions are invalid. 169 | 14. `InvalidGroupOperationError`: Raised for invalid operations on group nodes. 170 | 15. `OrphanEdgeError`: Raised when an edge refers to a non-existent node. 171 | 16. `UnsupportedFileTypeError`: Raised when a file node points to an unsupported file type. 172 | 17. `FileReadError`: Raised when there is an error reading a file. 173 | 18. `FileWriteError`: Raised when there is an error writing to a file. 174 | 19. `RenderingError`: Raised when there is an error during rendering. 175 | 20. `CanvasValidationError`: Raised when the canvas does not pass validation checks. 176 | 177 | This documentation provides an overview of the PyJSONCanvas library. For more detailed information about specific classes, methods, and attributes, please refer to the docstrings and comments in the source code. -------------------------------------------------------------------------------- /pyjsoncanvas/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py 2 | from .validate import validate_node, validate_edge 3 | from .models import ( 4 | GenericNode, 5 | LinkNode, 6 | GroupNode, 7 | FileNode, 8 | TextNode, 9 | NodeType, 10 | GroupNodeBackgroundStyle, 11 | Edge, 12 | EdgesFromEndValue, 13 | EdgesFromSideValue, 14 | EdgesToSideValue, 15 | EdgesToEndValue, 16 | Color, 17 | ) 18 | from .exceptions import ( 19 | InvalidNodeTypeError, 20 | InvalidEdgeAttributeError, 21 | OrphanEdgeError, 22 | CanvasValidationError, 23 | NodeIDConflictError, 24 | EdgeIDConflictError, 25 | NodeNotFoundError, 26 | EdgeNotFoundError, 27 | InvalidJsonError, 28 | InvalidNodeAttributeError, 29 | InvalidEdgeConnectionError, 30 | ) 31 | from .jsoncanvas import Canvas 32 | -------------------------------------------------------------------------------- /pyjsoncanvas/encoder.py: -------------------------------------------------------------------------------- 1 | # encoder.py 2 | 3 | from json import JSONEncoder 4 | 5 | from .models import ( 6 | NodeType, 7 | GroupNodeBackgroundStyle, 8 | EdgesFromEndValue, 9 | EdgesFromSideValue, 10 | EdgesToEndValue, 11 | EdgesToSideValue, 12 | Edge, 13 | GenericNode, 14 | TextNode, 15 | FileNode, 16 | LinkNode, 17 | GroupNode, 18 | Color, 19 | ) 20 | 21 | 22 | class CustomEncoder(JSONEncoder): 23 | def default(self, obj): 24 | if isinstance( 25 | obj, 26 | ( 27 | NodeType, 28 | GroupNodeBackgroundStyle, 29 | EdgesFromEndValue, 30 | EdgesFromSideValue, 31 | EdgesToEndValue, 32 | EdgesToSideValue, 33 | ), 34 | ): 35 | return obj.value # or however you want to represent NodeType 36 | if isinstance(obj, Color): 37 | return obj.color # Serialize Color instances as their color attribute 38 | if isinstance( 39 | obj, (Edge, GenericNode, TextNode, FileNode, LinkNode, GroupNode) 40 | ): 41 | return obj.to_dict() # Use the to_dict method to serialize these objects 42 | 43 | # Call the base class implementation which takes care of raising exceptions for unsupported types 44 | return JSONEncoder.default(self, obj) 45 | -------------------------------------------------------------------------------- /pyjsoncanvas/exceptions.py: -------------------------------------------------------------------------------- 1 | # exceptions.py 2 | class JsonCanvasException(Exception): 3 | """Base exception for all JsonCanvas errors.""" 4 | 5 | pass 6 | 7 | 8 | class InvalidJsonError(JsonCanvasException): 9 | """Raised when the JSON is invalid or malformed.""" 10 | 11 | pass 12 | 13 | 14 | class NodeNotFoundError(JsonCanvasException): 15 | """Raised when a specified node is not found.""" 16 | 17 | pass 18 | 19 | 20 | class EdgeNotFoundError(JsonCanvasException): 21 | """Raised when a specified edge is not found.""" 22 | 23 | pass 24 | 25 | 26 | class InvalidNodeTypeError(JsonCanvasException): 27 | """Raised when a node has an invalid or unsupported type.""" 28 | 29 | pass 30 | 31 | 32 | class InvalidNodeAttributeError(JsonCanvasException): 33 | """Raised when a node attribute is invalid or missing.""" 34 | 35 | pass 36 | 37 | 38 | class InvalidEdgeConnectionError(JsonCanvasException): 39 | """Raised when an edge's connection points are invalid.""" 40 | 41 | pass 42 | 43 | 44 | class InvalidEdgeAttributeError(JsonCanvasException): 45 | """Raised when an edge attribute is invalid or missing.""" 46 | 47 | pass 48 | 49 | 50 | class NodeIDConflictError(JsonCanvasException): 51 | """Raised when two nodes have the same ID.""" 52 | 53 | pass 54 | 55 | 56 | class EdgeIDConflictError(JsonCanvasException): 57 | """Raised when two edges have the same ID.""" 58 | 59 | pass 60 | 61 | 62 | class InvalidColorValueError(JsonCanvasException): 63 | """Raised when a color value is invalid or not supported.""" 64 | 65 | pass 66 | 67 | 68 | class InvalidPositionError(JsonCanvasException): 69 | """Raised when a node's position is outside the allowed range.""" 70 | 71 | pass 72 | 73 | 74 | class InvalidDimensionError(JsonCanvasException): 75 | """Raised when a node's dimensions are invalid.""" 76 | 77 | pass 78 | 79 | 80 | class InvalidGroupOperationError(JsonCanvasException): 81 | """Raised for invalid operations on group nodes.""" 82 | 83 | pass 84 | 85 | 86 | class OrphanEdgeError(JsonCanvasException): 87 | """Raised when an edge refers to a non-existent node.""" 88 | 89 | pass 90 | 91 | 92 | class UnsupportedFileTypeError(JsonCanvasException): 93 | """Raised when a file node points to an unsupported file type.""" 94 | 95 | pass 96 | 97 | 98 | class FileReadError(JsonCanvasException): 99 | """Raised when there is an error reading a file.""" 100 | 101 | pass 102 | 103 | 104 | class FileWriteError(JsonCanvasException): 105 | """Raised when there is an error writing to a file.""" 106 | 107 | pass 108 | 109 | 110 | class InvalidEdgeConnectionError(JsonCanvasException): 111 | """Raised when an edge's connection points are invalid.""" 112 | 113 | pass 114 | 115 | 116 | class RenderingError(JsonCanvasException): 117 | """Raised when there is an error during rendering.""" 118 | 119 | pass 120 | 121 | 122 | class CanvasValidationError(JsonCanvasException): 123 | """Raised when the canvas does not pass validation checks.""" 124 | 125 | pass 126 | -------------------------------------------------------------------------------- /pyjsoncanvas/jsoncanvas.py: -------------------------------------------------------------------------------- 1 | # jsoncanvas.py 2 | from dataclasses import dataclass 3 | from .models import ( 4 | Edge, 5 | NodeType, 6 | GenericNode, 7 | TextNode, 8 | FileNode, 9 | LinkNode, 10 | GroupNode, 11 | ) 12 | from typing import List, Dict, Any, Tuple 13 | from .exceptions import ( 14 | InvalidNodeTypeError, 15 | InvalidEdgeAttributeError, 16 | OrphanEdgeError, 17 | CanvasValidationError, 18 | NodeIDConflictError, 19 | EdgeIDConflictError, 20 | NodeNotFoundError, 21 | EdgeNotFoundError, 22 | InvalidJsonError, 23 | ) 24 | from json import dumps, loads, JSONDecodeError 25 | from .encoder import CustomEncoder 26 | 27 | from .validate import validate_node, validate_edge 28 | 29 | 30 | @dataclass 31 | class Canvas: 32 | nodes: List[GenericNode] 33 | edges: List[Edge] 34 | 35 | def to_json(self) -> str: 36 | return dumps( 37 | { 38 | "nodes": [node.to_dict() for node in self.nodes], 39 | "edges": [edge.to_dict() for edge in self.edges], 40 | }, 41 | cls=CustomEncoder, 42 | ) 43 | 44 | @staticmethod 45 | def from_json(json_str: str) -> "Canvas": 46 | try: 47 | canvas_dict = loads(json_str) 48 | nodes = [] 49 | edges = [] 50 | for node in canvas_dict["nodes"]: 51 | # if node.type == NodeType.TEXT: 52 | if node["type"] == NodeType.TEXT.value: 53 | nodes.append(TextNode(**node)) 54 | # elif node.type == NodeType.FILE: 55 | elif node["type"] == NodeType.FILE.value: 56 | nodes.append(FileNode(**node)) 57 | # elif node.type == NodeType.LINK: 58 | elif node["type"] == NodeType.LINK.value: 59 | nodes.append(LinkNode(**node)) 60 | # elif node.type == NodeType.GROUP: 61 | elif node["type"] == NodeType.GROUP.value: 62 | nodes.append(GroupNode(**node)) 63 | else: 64 | raise InvalidNodeTypeError( 65 | f"Invalid or unsupported node type.The node {node['id']} has an invalid or unsupported type {node['type']}." 66 | ) 67 | for edge in canvas_dict["edges"]: 68 | edges.append(Edge(**edge)) 69 | return Canvas(nodes=nodes, edges=edges) 70 | except JSONDecodeError as e: 71 | raise InvalidJsonError("Invalid or malformed JSON.") from e 72 | 73 | def export(self, file_path: str) -> None: 74 | with open(file_path, "w") as f: 75 | f.write(self.to_json()) 76 | 77 | def validate(self) -> bool: 78 | try: 79 | if not all( 80 | isinstance(node, (TextNode, FileNode, LinkNode, GroupNode)) 81 | and validate_node(node) 82 | for node in self.nodes 83 | ): 84 | raise InvalidNodeTypeError("Invalid or unsupported node type found.") 85 | if not all(isinstance(edge, Edge) for edge in self.edges): 86 | raise InvalidEdgeAttributeError( 87 | "Invalid or missing edge attribute found." 88 | ) 89 | for edge in self.edges: 90 | validate_edge(edge) 91 | if edge.fromNode not in [ 92 | node.id for node in self.nodes 93 | ] or edge.toNode not in [node.id for node in self.nodes]: 94 | raise OrphanEdgeError("Edge is orphan.") 95 | return True 96 | except (InvalidNodeTypeError, InvalidEdgeAttributeError, OrphanEdgeError) as e: 97 | raise CanvasValidationError("Canvas validation failed.") from e 98 | 99 | def get_node(self, node_id: str) -> GenericNode: 100 | for node in self.nodes: 101 | if node.id == node_id: 102 | return node 103 | else: 104 | raise NodeNotFoundError("Node with id does not exist") 105 | 106 | def get_edge(self, edge_id: str) -> GenericNode: 107 | for edge in self.edges: 108 | if edge.id == edge_id: 109 | return edge 110 | else: 111 | raise EdgeNotFoundError("Edge with id does not exist") 112 | 113 | def add_node(self, node: GenericNode) -> None: 114 | validate_node(node) 115 | if node.id in [n.id for n in self.nodes]: 116 | raise NodeIDConflictError("Node with id already exists") 117 | self.nodes.append(node) 118 | 119 | def add_edge(self, edge: Edge) -> None: 120 | validate_edge(edge) 121 | if edge.id in [e.id for e in self.edges]: 122 | raise EdgeIDConflictError("Edge with id already exists") 123 | self.edges.append(edge) 124 | 125 | def remove_node(self, node_id: str) -> bool: 126 | node_exists = False 127 | nodes = self.nodes 128 | self.nodes = [node for node in self.nodes if node.id != node_id] 129 | if len(self.nodes) != len(nodes): 130 | node_exists = True 131 | self.edges = [ 132 | edge 133 | for edge in self.edges 134 | if edge.fromNode != node_id and edge.toNode != node_id 135 | ] 136 | if not node_exists: 137 | raise NodeNotFoundError(f"Node with id {node_id} does not exist.") 138 | return node_exists 139 | 140 | def remove_edge(self, edge_id: str) -> None: 141 | edge_exists = False 142 | edges = self.edges 143 | self.edges = [edge for edge in self.edges if edge.id != edge_id] 144 | if len(self.edges) != len(edges): 145 | edge_exists = True 146 | if not edge_exists: 147 | raise EdgeNotFoundError(f"Edge with id {edge_id} does not exist.") 148 | 149 | def get_connections(self, node_id: str) -> List[Edge]: 150 | return [ 151 | edge 152 | for edge in self.edges 153 | if edge.fromNode == node_id or edge.toNode == node_id 154 | ] 155 | 156 | def get_edge_nodes(self, edge_id: str) -> Tuple[GenericNode, GenericNode]: 157 | edge = self.get_edge(edge_id) 158 | return [self.get_node(edge.fromNode), self.get_node(edge.toNode)] 159 | 160 | def get_adjacent_nodes(self, node_id: str) -> List[GenericNode]: 161 | return [ 162 | self.get_node(edge.fromNode) 163 | for edge in self.edges 164 | if edge.toNode == node_id 165 | ] + [ 166 | self.get_node(edge.toNode) 167 | for edge in self.edges 168 | if edge.fromNode == node_id 169 | ] 170 | -------------------------------------------------------------------------------- /pyjsoncanvas/models.py: -------------------------------------------------------------------------------- 1 | # models.py 2 | from typing import Dict, Any 3 | from dataclasses import dataclass 4 | from enum import Enum 5 | from .exceptions import InvalidColorValueError 6 | import uuid 7 | from dataclasses import field 8 | from . import validate_node, validate_edge 9 | 10 | 11 | class ColorPreset(Enum): 12 | RED = 1 13 | ORANGE = 2 14 | YELLOW = 3 15 | GREEN = 4 16 | CYAN = 5 17 | PURPLE = 6 18 | 19 | 20 | class PresetOrHex(Enum): 21 | PRESET = 1 22 | HEX = 2 23 | 24 | 25 | def validate_hex_code(hexcode): 26 | return len(hexcode) == 6 and all( 27 | c.isdigit() or c.lower() in "abcdef" for c in hexcode 28 | ) 29 | 30 | 31 | class Color: 32 | def __init__(self, color: str): 33 | if color.startswith("#"): 34 | if not validate_hex_code(color[1:]): 35 | raise InvalidColorValueError("Invalid hex code.") 36 | # check if the color is one of the integer values of the ColorPreset enum 37 | elif color.isdigit(): 38 | if int(color) not in [color.value for color in ColorPreset]: 39 | raise InvalidColorValueError("Invalid color preset.") 40 | else: 41 | raise InvalidColorValueError("Invalid color value.") 42 | self.color = color 43 | self.preset_or_hex = PresetOrHex.PRESET if color.isdigit() else PresetOrHex.HEX 44 | 45 | 46 | class NodeType(Enum): 47 | TEXT = "text" 48 | FILE = "file" 49 | LINK = "link" 50 | GROUP = "group" 51 | 52 | 53 | class EdgesFromSideValue(Enum): 54 | TOP = "top" 55 | RIGHT = "right" 56 | BOTTOM = "bottom" 57 | LEFT = "left" 58 | 59 | 60 | class EdgesFromEndValue(Enum): 61 | NONE = "none" 62 | ARROW = "arrow" 63 | 64 | 65 | class EdgesToSideValue(Enum): 66 | TOP = "top" 67 | RIGHT = "right" 68 | BOTTOM = "bottom" 69 | LEFT = "left" 70 | 71 | 72 | class EdgesToEndValue(Enum): 73 | NONE = "none" 74 | ARROW = "arrow" 75 | 76 | 77 | class GroupNodeBackgroundStyle(Enum): 78 | COVER = "cover" 79 | RATIO = "ratio" 80 | REPEAT = "repeat" 81 | 82 | 83 | @dataclass 84 | class Edge: 85 | fromNode: str 86 | toNode: str 87 | fromSide: EdgesFromSideValue = None 88 | fromEnd: EdgesFromEndValue = None 89 | toSide: EdgesToSideValue = None 90 | toEnd: EdgesToEndValue = None 91 | color: Color = None 92 | label: str = None 93 | id: str = field(default_factory=lambda: uuid.uuid4().hex[:16]) 94 | 95 | def __post_init__(self): 96 | if isinstance(self.fromSide, str): 97 | self.fromSide = EdgesFromSideValue(self.fromSide) 98 | if isinstance(self.fromEnd, str): 99 | self.fromEnd = EdgesFromEndValue(self.fromEnd) 100 | if isinstance(self.toSide, str): 101 | self.toSide = EdgesToSideValue(self.toSide) 102 | if isinstance(self.toEnd, str): 103 | self.toEnd = EdgesToEndValue(self.toEnd) 104 | if isinstance(self.color, str): 105 | self.color = Color(self.color) 106 | validate_edge(self) 107 | 108 | def __eq__(self, other): 109 | if not isinstance(other, Edge): 110 | return False 111 | return self.id == other.id 112 | 113 | def to_dict(self) -> Dict[str, Any]: 114 | return { 115 | "id": self.id, 116 | "fromNode": self.fromNode, 117 | "fromSide": self.fromSide, 118 | "fromEnd": self.fromEnd, 119 | "toNode": self.toNode, 120 | "toSide": self.toSide, 121 | "toEnd": self.toEnd, 122 | "color": self.color, 123 | "label": self.label, 124 | } 125 | 126 | 127 | @dataclass 128 | class GenericNode: 129 | type: NodeType 130 | x: int 131 | y: int 132 | width: int 133 | height: int 134 | color: Color = None 135 | id: str = field(default_factory=lambda: uuid.uuid4().hex[:16]) 136 | 137 | def __post_init__(self): 138 | if isinstance(self.color, str): 139 | self.color = Color(self.color) 140 | 141 | def __eq__(self, other): 142 | if not isinstance(other, GenericNode): 143 | return False 144 | return self.id == other.id 145 | 146 | def to_dict(self) -> Dict[str, Any]: 147 | return { 148 | "type": self.type, 149 | "id": self.id, 150 | "x": self.x, 151 | "y": self.y, 152 | "width": self.width, 153 | "height": self.height, 154 | "color": self.color, 155 | } 156 | 157 | 158 | @dataclass(kw_only=True) 159 | class TextNode(GenericNode): 160 | text: str = field(default="", init=True) 161 | type: NodeType = NodeType.TEXT 162 | 163 | def __post_init__(self): 164 | super().__post_init__() 165 | if isinstance(self.type, str): 166 | self.type = NodeType("text") 167 | validate_node(self) 168 | 169 | def to_dict(self) -> Dict[str, Any]: 170 | return super().to_dict() | {"text": self.text} 171 | 172 | 173 | @dataclass(kw_only=True) 174 | class FileNode(GenericNode): 175 | file: str 176 | type: NodeType = NodeType.FILE 177 | subpath: str = None 178 | 179 | def __post_init__(self): 180 | super().__post_init__() 181 | if isinstance(self.type, str): 182 | self.type = NodeType("file") 183 | validate_node(self) 184 | 185 | def to_dict(self) -> Dict[str, Any]: 186 | return super().to_dict() | {"file": self.file, "subpath": self.subpath} 187 | 188 | 189 | @dataclass(kw_only=True) 190 | class LinkNode(GenericNode): 191 | url: str 192 | type: NodeType = NodeType.LINK 193 | 194 | def __post_init__(self): 195 | super().__post_init__() 196 | if isinstance(self.type, str): 197 | self.type = NodeType("link") 198 | validate_node(self) 199 | 200 | def to_dict(self) -> Dict[str, Any]: 201 | return super().to_dict() | {"url": self.url} 202 | 203 | 204 | @dataclass(kw_only=True) 205 | class GroupNode(GenericNode): 206 | type: NodeType = NodeType.GROUP 207 | label: str = None 208 | background: str = None 209 | backgroundStyle: GroupNodeBackgroundStyle = None 210 | 211 | def __post_init__(self): 212 | super().__post_init__() 213 | if isinstance(self.type, str): 214 | self.type = NodeType("group") 215 | if isinstance(self.backgroundStyle, str): 216 | self.backgroundStyle = GroupNodeBackgroundStyle(self.backgroundStyle) 217 | validate_node(self) 218 | 219 | def to_dict(self) -> Dict[str, Any]: 220 | return super().to_dict() | { 221 | "label": self.label, 222 | "background": self.background, 223 | "backgroundStyle": self.backgroundStyle, 224 | } 225 | -------------------------------------------------------------------------------- /pyjsoncanvas/utils.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CheeksTheGeek/PyJSONCanvas/4f823c58f30c1e33b63c28e35b2b96d338c11747/pyjsoncanvas/utils.py -------------------------------------------------------------------------------- /pyjsoncanvas/validate.py: -------------------------------------------------------------------------------- 1 | # validate.py 2 | 3 | 4 | # second param for no exceptions only return bool 5 | def validate_node(node) -> bool: 6 | from .models import ( 7 | GenericNode, 8 | NodeType, 9 | GroupNodeBackgroundStyle, 10 | GroupNode, 11 | TextNode, 12 | FileNode, 13 | LinkNode, 14 | Color, 15 | ) 16 | from .exceptions import InvalidNodeTypeError, InvalidNodeAttributeError 17 | 18 | """Validates the node, and if is a inherited class from Node, then checks those special attributes.""" 19 | if not isinstance(node, GenericNode): 20 | raise InvalidNodeTypeError("Node is not a valid instance of Node.") 21 | 22 | if not isinstance(node.id, str): 23 | raise InvalidNodeAttributeError("Node ID is invalid or missing.") 24 | 25 | if not isinstance(node.type, NodeType): 26 | raise InvalidNodeTypeError("Node type is invalid or missing.") 27 | 28 | if not isinstance(node.x, int): 29 | raise InvalidNodeAttributeError("Node x is invalid or missing.") 30 | 31 | if not isinstance(node.y, int): 32 | raise InvalidNodeAttributeError("Node y is invalid or missing.") 33 | 34 | if not isinstance(node.width, int): 35 | raise InvalidNodeAttributeError("Node width is invalid or missing.") 36 | 37 | if not isinstance(node.height, int): 38 | raise InvalidNodeAttributeError("Node height is invalid or missing.") 39 | 40 | if node.color is not None and not isinstance(node.color, Color): 41 | raise InvalidNodeAttributeError("Node color is invalid or missing.") 42 | 43 | if isinstance(node, TextNode) and not isinstance(node.text, str): 44 | raise InvalidNodeAttributeError("TextNode text is invalid or missing.") 45 | 46 | if isinstance(node, FileNode): 47 | if not isinstance(node.file, str): 48 | raise InvalidNodeAttributeError("FileNode file is invalid or missing.") 49 | if node.subpath is not None and not isinstance(node.subpath, str): 50 | raise InvalidNodeAttributeError("FileNode subpath is invalid or missing.") 51 | 52 | if isinstance(node, LinkNode) and not isinstance(node.url, str): 53 | raise InvalidNodeAttributeError("LinkNode url is invalid or missing.") 54 | 55 | if isinstance(node, GroupNode): 56 | if node.label is not None and not isinstance(node.label, str): 57 | raise InvalidNodeAttributeError("GroupNode label is invalid or missing.") 58 | 59 | if node.background is not None and not isinstance(node.background, str): 60 | raise InvalidNodeAttributeError( 61 | "GroupNode background is invalid or missing." 62 | ) 63 | 64 | if node.backgroundStyle is not None and not isinstance( 65 | node.backgroundStyle, GroupNodeBackgroundStyle 66 | ): 67 | raise InvalidNodeAttributeError( 68 | "GroupNode backgroundStyle is invalid or missing." 69 | ) 70 | 71 | return True 72 | 73 | 74 | def validate_edge(edge) -> bool: 75 | from .models import ( 76 | Edge, 77 | EdgesFromEndValue, 78 | EdgesFromSideValue, 79 | EdgesToEndValue, 80 | EdgesToSideValue, 81 | Color, 82 | ) 83 | from .exceptions import InvalidEdgeAttributeError, InvalidEdgeConnectionError 84 | 85 | if not isinstance(edge, Edge): 86 | raise InvalidEdgeAttributeError("Edge is not a valid instance of Edge.") 87 | 88 | if not isinstance(edge.id, str): 89 | raise InvalidEdgeAttributeError("Edge ID is invalid or missing.") 90 | 91 | if not isinstance(edge.fromNode, str): 92 | raise InvalidEdgeConnectionError("Edge fromNode is invalid or missing.") 93 | 94 | if edge.fromSide is not None and not isinstance(edge.fromSide, EdgesFromSideValue): 95 | raise InvalidEdgeAttributeError("Edge fromSide is invalid or missing.") 96 | 97 | if edge.fromEnd is not None and not isinstance(edge.fromEnd, EdgesFromEndValue): 98 | raise InvalidEdgeAttributeError("Edge fromEnd is invalid or missing.") 99 | 100 | if not isinstance(edge.toNode, str): 101 | raise InvalidEdgeConnectionError("Edge toNode is invalid or missing.") 102 | 103 | if edge.toSide is not None and not isinstance(edge.toSide, EdgesToSideValue): 104 | raise InvalidEdgeAttributeError("Edge toSide is invalid or missing.") 105 | 106 | if edge.toEnd is not None and not isinstance(edge.toEnd, EdgesToEndValue): 107 | raise InvalidEdgeAttributeError("Edge toEnd is invalid or missing.") 108 | 109 | if edge.color is not None and not isinstance(edge.color, Color): 110 | raise InvalidEdgeAttributeError("Edge color is invalid or missing.") 111 | 112 | if edge.label is not None and not isinstance(edge.label, str): 113 | raise InvalidEdgeAttributeError("Edge label is invalid or missing.") 114 | 115 | return True 116 | -------------------------------------------------------------------------------- /sample.py: -------------------------------------------------------------------------------- 1 | # sample.py 2 | from pyjsoncanvas import ( 3 | Canvas, 4 | TextNode, 5 | FileNode, 6 | LinkNode, 7 | GroupNode, 8 | GroupNodeBackgroundStyle, 9 | Edge, 10 | Color, 11 | InvalidJsonError, 12 | CanvasValidationError, 13 | NodeNotFoundError, 14 | EdgeNotFoundError, 15 | ) 16 | from pprint import pprint 17 | 18 | 19 | # Create a new canvas 20 | canvas = Canvas(nodes=[], edges=[]) 21 | 22 | # Add some nodes 23 | text_node = TextNode(x=100, y=100, width=200, height=100, text="Hello, world!") 24 | canvas.add_node(text_node) 25 | 26 | file_node = FileNode(x=300, y=100, width=100, height=100, file="/path/to/file.png") 27 | canvas.add_node(file_node) 28 | 29 | link_node = LinkNode(x=500, y=100, width=150, height=50, url="https://example.com") 30 | canvas.add_node(link_node) 31 | 32 | group_node = GroupNode( 33 | x=200, 34 | y=300, 35 | width=400, 36 | height=300, 37 | label="My Group", 38 | background="/path/to/background.jpg", 39 | backgroundStyle=GroupNodeBackgroundStyle.COVER, 40 | ) 41 | canvas.add_node(group_node) 42 | 43 | # Add some edges 44 | edge1 = Edge( 45 | fromNode=text_node.id, 46 | fromSide="bottom", 47 | toNode=file_node.id, 48 | toSide="top", 49 | color=Color("#FF0000"), 50 | label="Edge 1", 51 | ) 52 | canvas.add_edge(edge1) 53 | 54 | edge2 = Edge( 55 | fromNode=file_node.id, 56 | fromSide="right", 57 | toNode=link_node.id, 58 | toSide="left", 59 | color=Color("4"), # Green 60 | ) 61 | canvas.add_edge(edge2) 62 | 63 | # Save the canvas as JSON 64 | json_str = canvas.to_json() 65 | # save it to a file 66 | with open("canvas.canvas", "w") as f: 67 | f.write(json_str) 68 | 69 | # Load the canvas from JSON 70 | try: 71 | loaded_canvas = Canvas.from_json(json_str) 72 | except InvalidJsonError as e: 73 | pprint(f"Error loading canvas: {e}") 74 | 75 | # Validate the canvas 76 | try: 77 | loaded_canvas.validate() 78 | except CanvasValidationError as e: 79 | print(f"Canvas validation failed: {e}") 80 | 81 | # Get a node 82 | try: 83 | node = loaded_canvas.get_node(text_node.id) 84 | print(f"Node found: {node}") 85 | except NodeNotFoundError as e: 86 | print(f"Node not found: {e}") 87 | 88 | # Get an edge 89 | try: 90 | edge = loaded_canvas.get_edge(edge1.id) 91 | print(f"Edge found: {edge}") 92 | except EdgeNotFoundError as e: 93 | print(f"Edge not found: {e}") 94 | 95 | # Get connections for a node 96 | connections = loaded_canvas.get_connections(text_node.id) 97 | pprint(f"Connections for {text_node.id}: {connections}") 98 | 99 | # Remove a node 100 | print(loaded_canvas.remove_node(file_node.id)) 101 | 102 | # Get connections for a node 103 | connections = loaded_canvas.get_connections(text_node.id) 104 | pprint(f"Connections for {text_node.id}: {connections}") 105 | 106 | # Get adjacent nodes for a node 107 | adjacent_nodes = loaded_canvas.get_adjacent_nodes(text_node.id) 108 | pprint(f"Adjacent nodes for {text_node.id}: {adjacent_nodes}") 109 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # setup.py 2 | from setuptools import setup 3 | 4 | setup( 5 | name="PyJSONCanvas", 6 | version="1.0.1", 7 | description="A simple library for working with JSON Canvas (previously known as Obsidian Canvas) files.", 8 | author="Chaitanya Sharma", 9 | url="https://github.com/CheeksTheGeek/PyJSONCanvas", 10 | license="MIT", 11 | py_modules=["pyjsoncanvas"], 12 | long_description=open("README.md").read(), 13 | long_description_content_type="text/markdown", 14 | ) 15 | --------------------------------------------------------------------------------