diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..250e503 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +*.egg-info/ +dist/ +build/ + +# Generated workflow visualizations +workflow-visualizations/ + +# Temporary files +/tmp/ +*.tmp +*.bak + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/lib/n8n_validate.py b/lib/n8n_validate.py new file mode 100755 index 0000000..25d9fac --- /dev/null +++ b/lib/n8n_validate.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +n8n-validate command-line tool. +Validates n8n workflow JSON files. +""" +import sys +import argparse +from pathlib import Path +from n8n_validator import N8nWorkflowValidator, validate_workflows_in_directory + +def main(): + parser = argparse.ArgumentParser(description='Validate n8n workflow JSON files') + parser.add_argument('path', help='Path to JSON file or directory to validate') + parser.add_argument('--recursive', '-r', action='store_true', + help='Recursively validate all JSON files in subdirectories') + parser.add_argument('--verbose', '-v', action='store_true', + help='Enable verbose output') + + args = parser.parse_args() + + path = Path(args.path) + + if not path.exists(): + print(f"Error: Path does not exist: {path}", file=sys.stderr) + return 1 + + if path.is_file(): + # Validate single file + validator = N8nWorkflowValidator() + if validator.validate_file(path): + if args.verbose: + print(f"✅ Valid workflow: {path}") + return 0 + else: + print(f"❌ Invalid workflow: {path}", file=sys.stderr) + return 1 + + elif path.is_dir(): + # Validate directory + if validate_workflows_in_directory(path, recursive=args.recursive): + return 0 + else: + return 1 + + else: + print(f"Error: Invalid path type: {path}", file=sys.stderr) + return 1 + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/lib/n8n_validator.py b/lib/n8n_validator.py new file mode 100644 index 0000000..6f642e4 --- /dev/null +++ b/lib/n8n_validator.py @@ -0,0 +1,189 @@ +""" +n8n workflow validation and visualization tools. +""" +import json +import sys +from pathlib import Path +from typing import Dict, Any, List, Optional + +class N8nWorkflowValidator: + """Validates n8n workflow JSON files.""" + + def __init__(self): + self.required_fields = ['nodes'] # Only nodes is truly required + self.node_required_fields = ['id', 'name', 'type', 'position'] + + def validate_workflow(self, workflow_data: Dict[str, Any]) -> bool: + """ + Validate a single n8n workflow. + + Args: + workflow_data: Parsed JSON workflow data + + Returns: + True if valid, False otherwise + """ + try: + # Check for required top-level fields + for field in self.required_fields: + if field not in workflow_data: + print(f"Missing required field: {field}", file=sys.stderr) + return False + + # Validate nodes array + nodes = workflow_data.get('nodes', []) + if not isinstance(nodes, list): + print("'nodes' must be an array", file=sys.stderr) + return False + + if len(nodes) == 0: + print("Workflow must have at least one node", file=sys.stderr) + return False + + # Validate each node + node_ids = set() + for i, node in enumerate(nodes): + if not isinstance(node, dict): + print(f"Node {i} must be an object", file=sys.stderr) + return False + + # Check required node fields + for field in self.node_required_fields: + if field not in node: + print(f"Node {i} missing required field: {field}", file=sys.stderr) + return False + + # Check for duplicate node IDs + node_id = node.get('id') + if node_id in node_ids: + print(f"Duplicate node ID: {node_id}", file=sys.stderr) + return False + node_ids.add(node_id) + + # Validate position array + position = node.get('position', []) + if not isinstance(position, list) or len(position) != 2: + print(f"Node {i} position must be array of 2 numbers", file=sys.stderr) + return False + + if not all(isinstance(p, (int, float)) for p in position): + print(f"Node {i} position values must be numbers", file=sys.stderr) + return False + + # Validate meta field if present + meta = workflow_data.get('meta', {}) + if 'meta' in workflow_data and not isinstance(meta, dict): + print("'meta' must be an object", file=sys.stderr) + return False + + return True + + except Exception as e: + print(f"Validation error: {str(e)}", file=sys.stderr) + return False + + def validate_file(self, file_path: Path) -> bool: + """ + Validate a workflow file. + + Args: + file_path: Path to the workflow JSON file + + Returns: + True if valid, False otherwise + """ + try: + if not file_path.exists(): + print(f"File not found: {file_path}", file=sys.stderr) + return False + + if not file_path.suffix.lower() == '.json': + print(f"Not a JSON file: {file_path}", file=sys.stderr) + return False + + with open(file_path, 'r', encoding='utf-8') as f: + try: + workflow_data = json.load(f) + except json.JSONDecodeError as e: + print(f"Invalid JSON in {file_path}: {str(e)}", file=sys.stderr) + return False + + return self.validate_workflow(workflow_data) + + except Exception as e: + print(f"Error validating {file_path}: {str(e)}", file=sys.stderr) + return False + + +def validate_workflows_in_directory(directory: Path, recursive: bool = True) -> bool: + """ + Validate all n8n workflows in a directory. + + Args: + directory: Directory to search for workflows + recursive: Whether to search subdirectories + + Returns: + True if all workflows are valid, False otherwise + """ + validator = N8nWorkflowValidator() + all_valid = True + workflow_count = 0 + + if recursive: + json_files = list(directory.rglob('*.json')) + else: + json_files = list(directory.glob('*.json')) + + # Filter out obviously non-workflow files + workflow_files = [] + for file_path in json_files: + # Skip files in certain directories that are likely not workflows + skip_dirs = {'node_modules', '.git', 'workflow-visualizations', '__pycache__'} + if any(part in skip_dirs for part in file_path.parts): + continue + + # Skip files that are clearly not n8n workflows based on name patterns + skip_patterns = {'package.json', 'tsconfig.json'} + # Skip index files but not individual templates + if file_path.name.startswith('all_templates.'): + continue + if file_path.name in skip_patterns: + continue + + workflow_files.append(file_path) + + print(f"Found {len(workflow_files)} potential workflow files to validate") + + for file_path in workflow_files: + try: + # Quick check if this looks like an n8n workflow + with open(file_path, 'r', encoding='utf-8') as f: + try: + data = json.load(f) + # Basic heuristic: n8n workflows should have 'nodes' and typically 'meta' + if not isinstance(data, dict) or 'nodes' not in data: + continue # Skip files that don't look like n8n workflows + except json.JSONDecodeError: + continue # Skip invalid JSON files + + workflow_count += 1 + print(f"Validating: {file_path}") + + if not validator.validate_file(file_path): + print(f"❌ Validation failed for: {file_path}") + all_valid = False + else: + print(f"✅ Valid workflow: {file_path}") + + except Exception as e: + print(f"Error processing {file_path}: {str(e)}", file=sys.stderr) + all_valid = False + + print(f"\nValidated {workflow_count} n8n workflows") + if all_valid: + print("✅ All workflows are valid!") + else: + print("❌ Some workflows failed validation") + + return all_valid \ No newline at end of file diff --git a/lib/n8n_visualize.py b/lib/n8n_visualize.py new file mode 100755 index 0000000..0ca2229 --- /dev/null +++ b/lib/n8n_visualize.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +n8n-visualize command-line tool. +Creates visualizations of n8n workflow JSON files. +""" +import sys +import argparse +from pathlib import Path +from n8n_visualizer import N8nWorkflowVisualizer + +def main(): + parser = argparse.ArgumentParser(description='Create visualizations of n8n workflow JSON files') + parser.add_argument('input', help='Path to JSON workflow file') + parser.add_argument('-o', '--output', help='Output image file path (default: same as input with .png extension)') + parser.add_argument('--no-show', action='store_true', help='Do not show the visualization interactively') + parser.add_argument('--width', type=int, default=800, help='Image width in pixels (default: 800)') + parser.add_argument('--height', type=int, default=600, help='Image height in pixels (default: 600)') + parser.add_argument('--format', choices=['png', 'svg', 'pdf'], default='png', + help='Output format (default: png)') + + args = parser.parse_args() + + input_path = Path(args.input) + + if not input_path.exists(): + print(f"Error: Input file does not exist: {input_path}", file=sys.stderr) + return 1 + + if not input_path.is_file(): + print(f"Error: Input path is not a file: {input_path}", file=sys.stderr) + return 1 + + # Determine output path + if args.output: + output_path = Path(args.output) + else: + output_path = input_path.with_suffix(f'.{args.format}') + + # Create visualization + visualizer = N8nWorkflowVisualizer() + result = visualizer.visualize_file( + input_path, + output_path, + show=not args.no_show + ) + + if result: + print(f"Visualization created: {result}") + return 0 + else: + print("Failed to create visualization", file=sys.stderr) + return 1 + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/lib/n8n_visualizer.py b/lib/n8n_visualizer.py new file mode 100644 index 0000000..b02c2d6 --- /dev/null +++ b/lib/n8n_visualizer.py @@ -0,0 +1,304 @@ +""" +n8n workflow visualization tools. +""" +import json +import sys +from pathlib import Path +from typing import Dict, Any, List, Tuple, Optional +import matplotlib +matplotlib.use('Agg') # Use non-interactive backend +import matplotlib.pyplot as plt +import networkx as nx +from PIL import Image, ImageDraw, ImageFont +import io + +class N8nWorkflowVisualizer: + """Creates visualizations of n8n workflows.""" + + def __init__(self): + self.node_colors = { + 'trigger': '#ff6b6b', + 'action': '#4ecdc4', + 'condition': '#ffe66d', + 'function': '#a8e6cf', + 'default': '#95a5a6' + } + + def _get_node_color(self, node_type: str) -> str: + """Get color for a node based on its type.""" + if 'trigger' in node_type.lower(): + return self.node_colors['trigger'] + elif 'if' in node_type.lower() or 'switch' in node_type.lower(): + return self.node_colors['condition'] + elif 'function' in node_type.lower() or 'code' in node_type.lower(): + return self.node_colors['function'] + elif any(term in node_type.lower() for term in ['http', 'webhook', 'email', 'slack', 'telegram']): + return self.node_colors['action'] + else: + return self.node_colors['default'] + + def create_workflow_graph(self, workflow_data: Dict[str, Any]) -> nx.DiGraph: + """ + Create a NetworkX graph from n8n workflow data. + + Args: + workflow_data: Parsed n8n workflow JSON + + Returns: + NetworkX directed graph representing the workflow + """ + G = nx.DiGraph() + + nodes = workflow_data.get('nodes', []) + connections = workflow_data.get('connections', {}) + + # Add nodes + for node in nodes: + node_id = node.get('id') + node_name = node.get('name', 'Unnamed') + node_type = node.get('type', 'unknown') + position = node.get('position', [0, 0]) + + G.add_node( + node_id, + label=node_name, + type=node_type, + pos=(position[0]/100, -position[1]/100), # Scale and invert Y for better layout + color=self._get_node_color(node_type) + ) + + # Add edges based on connections + for source_node, source_outputs in connections.items(): + for output_type, output_connections in source_outputs.items(): + if isinstance(output_connections, list): + for connection_list in output_connections: + if isinstance(connection_list, list): + for connection in connection_list: + if isinstance(connection, dict): + target_node = connection.get('node') + if target_node and G.has_node(source_node) and G.has_node(target_node): + G.add_edge(source_node, target_node) + + return G + + def visualize_workflow_matplotlib( + self, + workflow_data: Dict[str, Any], + output_path: Optional[Path] = None, + figsize: Tuple[int, int] = (12, 8), + show: bool = False + ) -> Optional[Path]: + """ + Create a matplotlib visualization of the workflow. + + Args: + workflow_data: Parsed n8n workflow JSON + output_path: Path to save the visualization + figsize: Figure size tuple + show: Whether to show the plot interactively + + Returns: + Path to saved file if output_path provided, None otherwise + """ + try: + G = self.create_workflow_graph(workflow_data) + + if len(G.nodes()) == 0: + print("No nodes found in workflow", file=sys.stderr) + return None + + plt.figure(figsize=figsize) + + # Use positions from n8n if available, otherwise use spring layout + try: + pos = nx.get_node_attributes(G, 'pos') + if not pos or len(pos) == 0: + pos = nx.spring_layout(G, k=2, iterations=50) + except: + pos = nx.spring_layout(G, k=2, iterations=50) + + # Get node colors + node_colors = [G.nodes[node].get('color', self.node_colors['default']) for node in G.nodes()] + + # Get node labels + labels = {node: G.nodes[node].get('label', node[:8] + '...') if len(G.nodes[node].get('label', node)) > 10 else G.nodes[node].get('label', node) for node in G.nodes()} + + # Draw the graph + nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=2000, alpha=0.9) + nx.draw_networkx_edges(G, pos, edge_color='gray', arrows=True, arrowsize=20, alpha=0.6) + nx.draw_networkx_labels(G, pos, labels, font_size=8, font_weight='bold') + + # Add title + workflow_name = workflow_data.get('name', 'n8n Workflow') + plt.title(f'n8n Workflow: {workflow_name}', fontsize=16, fontweight='bold', pad=20) + + # Remove axes + plt.axis('off') + + # Add legend + legend_elements = [ + plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, + markersize=10, label=node_type.title()) + for node_type, color in self.node_colors.items() if node_type != 'default' + ] + plt.legend(handles=legend_elements, loc='upper left', bbox_to_anchor=(0, 1)) + + plt.tight_layout() + + if output_path: + plt.savefig(output_path, dpi=150, bbox_inches='tight', + facecolor='white', edgecolor='none') + print(f"Visualization saved to: {output_path}") + + if show: + plt.show() + else: + plt.close() + + return output_path + + except Exception as e: + print(f"Error creating visualization: {str(e)}", file=sys.stderr) + return None + + def create_simple_diagram( + self, + workflow_data: Dict[str, Any], + output_path: Path, + width: int = 800, + height: int = 600 + ) -> bool: + """ + Create a simple diagram using PIL when matplotlib fails. + + Args: + workflow_data: Parsed n8n workflow JSON + output_path: Path to save the image + width: Image width + height: Image height + + Returns: + True if successful, False otherwise + """ + try: + # Create a blank image + img = Image.new('RGB', (width, height), color='white') + draw = ImageDraw.Draw(img) + + nodes = workflow_data.get('nodes', []) + if not nodes: + return False + + # Try to load a font, fallback to default if not available + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12) + title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16) + except: + font = ImageFont.load_default() + title_font = font + + # Draw title + workflow_name = workflow_data.get('name', 'n8n Workflow') + title_text = f"n8n Workflow: {workflow_name}" + draw.text((10, 10), title_text, fill='black', font=title_font) + + # Calculate positions for nodes + node_width = 120 + node_height = 40 + margin = 20 + start_y = 60 + + cols = max(1, (width - 2 * margin) // (node_width + margin)) + + for i, node in enumerate(nodes[:20]): # Limit to first 20 nodes + col = i % cols + row = i // cols + + x = margin + col * (node_width + margin) + y = start_y + row * (node_height + margin) + + # Draw node rectangle + node_type = node.get('type', 'unknown') + color = self._get_node_color(node_type) + + # Convert hex color to RGB + if color.startswith('#'): + color = tuple(int(color[j:j+2], 16) for j in (1, 3, 5)) + else: + color = (149, 165, 166) # Default gray + + draw.rectangle([x, y, x + node_width, y + node_height], + fill=color, outline='black', width=2) + + # Draw node name + node_name = node.get('name', 'Unnamed') + if len(node_name) > 15: + node_name = node_name[:12] + '...' + + # Center text in rectangle + text_bbox = draw.textbbox((0, 0), node_name, font=font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + text_x = x + (node_width - text_width) // 2 + text_y = y + (node_height - text_height) // 2 + + draw.text((text_x, text_y), node_name, fill='white', font=font) + + # Add footer info + footer_text = f"Contains {len(nodes)} nodes" + draw.text((10, height - 30), footer_text, fill='gray', font=font) + + img.save(output_path) + print(f"Simple diagram saved to: {output_path}") + return True + + except Exception as e: + print(f"Error creating simple diagram: {str(e)}", file=sys.stderr) + return False + + def visualize_file( + self, + file_path: Path, + output_path: Optional[Path] = None, + show: bool = False + ) -> Optional[Path]: + """ + Create a visualization from a workflow file. + + Args: + file_path: Path to the workflow JSON file + output_path: Path to save the visualization + show: Whether to show the plot interactively + + Returns: + Path to saved file if successful, None otherwise + """ + try: + if not file_path.exists(): + print(f"File not found: {file_path}", file=sys.stderr) + return None + + with open(file_path, 'r', encoding='utf-8') as f: + workflow_data = json.load(f) + + # Generate output path if not provided + if output_path is None: + output_path = file_path.with_suffix('.png') + + # Try matplotlib first, fallback to simple diagram + result = self.visualize_workflow_matplotlib(workflow_data, output_path, show=show) + if result is None: + # Fallback to simple diagram + if self.create_simple_diagram(workflow_data, output_path): + return output_path + else: + return None + + return result + + except json.JSONDecodeError as e: + print(f"Invalid JSON in {file_path}: {str(e)}", file=sys.stderr) + return None + except Exception as e: + print(f"Error visualizing {file_path}: {str(e)}", file=sys.stderr) + return None \ No newline at end of file diff --git a/lib/requirements.txt b/lib/requirements.txt new file mode 100644 index 0000000..4f8b2de --- /dev/null +++ b/lib/requirements.txt @@ -0,0 +1,5 @@ +jsonschema>=4.0.0 +pillow>=9.0.0 +matplotlib>=3.5.0 +networkx>=2.8.0 +requests>=2.28.0 \ No newline at end of file diff --git a/lib/setup.py b/lib/setup.py new file mode 100644 index 0000000..b31a654 --- /dev/null +++ b/lib/setup.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Setup script for n8n workflow validation and visualization tools. +""" +from setuptools import setup, find_packages + +setup( + name="n8n-workflow-tools", + version="1.0.0", + description="Validation and visualization tools for n8n workflows", + author="n8n Community", + py_modules=[ + 'n8n_validator', + 'n8n_visualizer', + 'n8n_validate', + 'n8n_visualize' + ], + install_requires=[ + 'jsonschema>=4.0.0', + 'pillow>=9.0.0', + 'matplotlib>=3.5.0', + 'networkx>=2.8.0', + 'requests>=2.28.0' + ], + entry_points={ + 'console_scripts': [ + 'n8n-validate=n8n_validate:main', + 'n8n-visualize=n8n_visualize:main', + ], + }, + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + ], + python_requires='>=3.8', +) \ No newline at end of file