#!/usr/bin/env python3 """ Visual HTML Editor Router Provides visual HTML editing endpoints as a FastAPI router that can be included in other applications. """ import os import re from typing import Optional, Dict, Any from pathlib import Path from fastapi import APIRouter, HTTPException from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from bs4 import BeautifulSoup, NavigableString, Comment # Create router router = APIRouter(prefix="/api/html", tags=["visual-editor"]) # Use /workspace as the default workspace directory workspace_dir = "/workspace" # All text elements that should be editable TEXT_ELEMENTS = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', # Headings 'p', # Paragraphs 'span', 'strong', 'em', 'b', 'i', 'u', # Inline text formatting 'small', 'mark', 'del', 'ins', 'sub', 'sup', # Text modifications 'code', 'kbd', 'samp', 'var', 'pre', # Code and preformatted text 'blockquote', 'cite', 'q', # Quotes and citations 'abbr', 'dfn', 'time', 'data', # Semantic text 'address', 'figcaption', 'caption', # Descriptive text 'th', 'td', # Table cells 'dt', 'dd', # Definition lists 'li', # List items 'label', 'legend', # Form text ] class EditTextRequest(BaseModel): file_path: str element_selector: str # CSS selector to identify element new_text: str class DeleteElementRequest(BaseModel): file_path: str element_selector: str class SaveContentRequest(BaseModel): file_path: str html_content: str class GetEditableElementsResponse(BaseModel): elements: list[Dict[str, Any]] @router.get("/{file_path:path}/editable-elements") async def get_editable_elements(file_path: str): """Get all editable text elements from an HTML file""" try: full_path = os.path.join(workspace_dir, file_path) if not os.path.exists(full_path): raise HTTPException(status_code=404, detail="File not found") with open(full_path, 'r', encoding='utf-8') as f: content = f.read() soup = BeautifulSoup(content, 'html.parser') elements = [] editable_counter = 0 # Find all elements that could contain text all_elements = soup.find_all(TEXT_ELEMENTS + ['div']) # Filter out elements that only contain comments filtered_elements = [] for element in all_elements: # Check if element only contains comments only_comments = True for child in element.children: if isinstance(child, Comment): continue if isinstance(child, NavigableString) and not child.strip(): continue only_comments = False break if not only_comments: filtered_elements.append(element) all_elements = filtered_elements for element in all_elements: # Strategy 1: Elements with ONLY text content (no child elements) if element.string and element.string.strip(): element_id = f"editable-{editable_counter}" element['data-editable-id'] = element_id element['class'] = element.get('class', []) + ['editable-element'] elements.append({ 'id': element_id, 'tag': element.name, 'text': element.string.strip(), 'selector': f'[data-editable-id="{element_id}"]', 'innerHTML': element.string.strip() }) editable_counter += 1 # Strategy 2: Elements with mixed content - wrap raw text nodes individually elif element.contents: has_mixed_content = False # Process each child node for child in list(element.contents): # Use list() to avoid modification during iteration # Skip comment nodes (Comments are a subclass of NavigableString) if isinstance(child, Comment): continue # Check if it's a NavigableString (raw text) with actual content if (isinstance(child, NavigableString) and child.strip()): # This is a raw text node with content text_content = child.strip() if text_content: # Create a wrapper span for the raw text wrapper_span = soup.new_tag('span') wrapper_span['data-editable-id'] = f"editable-{editable_counter}" wrapper_span['class'] = 'editable-element raw-text-wrapper' wrapper_span.string = text_content # Replace the text node with the wrapped span child.replace_with(wrapper_span) elements.append({ 'id': f"editable-{editable_counter}", 'tag': 'text-node', 'text': text_content, 'selector': f'[data-editable-id="editable-{editable_counter}"]', 'innerHTML': text_content }) editable_counter += 1 has_mixed_content = True # If this element has no mixed content but has text, make the whole element editable if not has_mixed_content and element.get_text(strip=True): element_id = f"editable-{editable_counter}" element['data-editable-id'] = element_id element['class'] = element.get('class', []) + ['editable-element'] elements.append({ 'id': element_id, 'tag': element.name, 'text': element.get_text(strip=True), 'selector': f'[data-editable-id="{element_id}"]', 'innerHTML': str(element.decode_contents()) if element.contents else element.get_text(strip=True) }) editable_counter += 1 return {"elements": elements} except Exception as e: print(f"Error getting editable elements: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/edit-text") async def edit_text(request: EditTextRequest): """Edit text content of an element in an HTML file""" try: full_path = os.path.join(workspace_dir, request.file_path) if not os.path.exists(full_path): raise HTTPException(status_code=404, detail="File not found") with open(full_path, 'r', encoding='utf-8') as f: content = f.read() soup = BeautifulSoup(content, 'html.parser') # Extract element ID from selector element_id = request.element_selector.replace('[data-editable-id="', '').replace('"]', '') # Find the specific editable element by its data-editable-id target_element = soup.find(attrs={'data-editable-id': element_id}) if not target_element: raise HTTPException(status_code=404, detail=f"Element with ID {element_id} not found") print(f"đŸŽ¯ Found element: {target_element.name} with ID {element_id} - '{target_element.get_text()[:50]}...'") # Simple replacement - whether it's a regular element or a wrapped text node if target_element.string: target_element.string.replace_with(request.new_text) else: # Clear content and add new text target_element.clear() target_element.string = request.new_text # Write back to file with open(full_path, 'w', encoding='utf-8') as f: f.write(str(soup)) print(f"✅ Successfully updated text in {request.file_path}: '{request.new_text}'") return {"success": True, "message": "Text updated successfully"} except Exception as e: print(f"❌ Error editing text: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/delete-element") async def delete_element(request: DeleteElementRequest): """Delete an element from an HTML file""" try: full_path = os.path.join(workspace_dir, request.file_path) if not os.path.exists(full_path): raise HTTPException(status_code=404, detail="File not found") with open(full_path, 'r', encoding='utf-8') as f: content = f.read() soup = BeautifulSoup(content, 'html.parser') # Handle both editable elements and removable divs if '[data-editable-id="' in request.element_selector: # Text element deletion element_id = request.element_selector.replace('[data-editable-id="', '').replace('"]', '') # Find the specific editable element by its data-editable-id target_element = soup.find(attrs={'data-editable-id': element_id}) if not target_element: raise HTTPException(status_code=404, detail=f"Element with ID {element_id} not found") elif '[data-removable-id="' in request.element_selector: # Div removal element_id = request.element_selector.replace('[data-removable-id="', '').replace('"]', '') # Find the specific removable element by its data-removable-id target_element = soup.find(attrs={'data-removable-id': element_id}) if not target_element: raise HTTPException(status_code=404, detail=f"Element with ID {element_id} not found") else: raise HTTPException(status_code=400, detail="Invalid element selector") print(f"đŸ—‘ī¸ Deleting element: {target_element.name} - '{target_element.get_text()[:50]}...'") # Remove element target_element.decompose() # Write back to file with open(full_path, 'w', encoding='utf-8') as f: f.write(str(soup)) print(f"đŸ—‘ī¸ Successfully deleted element from {request.file_path}") return {"success": True, "message": "Element deleted successfully"} except Exception as e: print(f"❌ Error deleting element: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/save-content") async def save_content(request: SaveContentRequest): """Save the entire HTML content to file""" try: full_path = os.path.join(workspace_dir, request.file_path) if not os.path.exists(full_path): raise HTTPException(status_code=404, detail="File not found") # Clean up the HTML content by removing editor-specific classes and attributes soup = BeautifulSoup(request.html_content, 'html.parser') # Remove editor-specific elements and attributes for element in soup.find_all(): # Remove editor classes if element.get('class'): classes = element['class'] classes = [cls for cls in classes if cls not in ['editable-element', 'removable-element', 'raw-text-wrapper', 'selected', 'editing', 'element-modified', 'element-deleted']] if classes: element['class'] = classes else: del element['class'] # Remove editor data attributes if element.get('data-editable-id'): del element['data-editable-id'] if element.get('data-removable-id'): del element['data-removable-id'] if element.get('data-original-text'): del element['data-original-text'] # Remove editor controls for control in soup.find_all(['div'], class_=['edit-controls', 'remove-controls']): control.decompose() # Remove editor header for header in soup.find_all(['div'], class_='editor-header'): header.decompose() # Remove editor CSS and JS for style in soup.find_all('style'): if 'Visual Editor Styles' in style.get_text(): style.decompose() for script in soup.find_all('script'): if 'VisualHtmlEditor' in script.get_text(): script.decompose() # Write the cleaned HTML back to file with open(full_path, 'w', encoding='utf-8') as f: f.write(str(soup)) print(f"💾 Successfully saved content to {request.file_path}") return {"success": True, "message": "Content saved successfully"} except Exception as e: print(f"❌ Error saving content: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/{file_path:path}/editor") async def get_html_editor(file_path: str): """Serve the visual editor for an HTML file""" try: full_path = os.path.join(workspace_dir, file_path) if not os.path.exists(full_path): raise HTTPException(status_code=404, detail="File not found") with open(full_path, 'r', encoding='utf-8') as f: content = f.read() # Inject editor functionality into the HTML editor_html = inject_editor_functionality(content, file_path) return HTMLResponse(content=editor_html) except Exception as e: print(f"❌ Error serving editor: {e}") raise HTTPException(status_code=500, detail=str(e)) def inject_editor_functionality(html_content: str, file_path: str) -> str: """Inject visual editor functionality into existing HTML""" # Parse the HTML soup = BeautifulSoup(html_content, 'html.parser') # Apply the same transformation as the API endpoint editable_counter = 0 # Find all elements that could contain text all_elements = soup.find_all(TEXT_ELEMENTS + ['div']) # Filter out elements that only contain comments filtered_elements = [] for element in all_elements: # Check if element only contains comments only_comments = True for child in element.children: if isinstance(child, Comment): continue if isinstance(child, NavigableString) and not child.strip(): continue only_comments = False break if not only_comments: filtered_elements.append(element) all_elements = filtered_elements for element in all_elements: # Strategy 1: Elements with ONLY text content (no child elements) if element.string and element.string.strip(): element['data-editable-id'] = f"editable-{editable_counter}" element['class'] = element.get('class', []) + ['editable-element'] editable_counter += 1 # Strategy 2: Elements with mixed content - wrap raw text nodes individually elif element.contents: has_mixed_content = False # Process each child node for child in list(element.contents): # Use list() to avoid modification during iteration # Skip comment nodes (Comments are a subclass of NavigableString) if isinstance(child, Comment): continue # Check if it's a NavigableString (raw text) with actual content if (isinstance(child, NavigableString) and child.strip()): # This is a raw text node with content text_content = child.strip() if text_content: # Create a wrapper span for the raw text wrapper_span = soup.new_tag('span') wrapper_span['data-editable-id'] = f"editable-{editable_counter}" wrapper_span['class'] = 'editable-element raw-text-wrapper' wrapper_span.string = text_content # Replace the text node with the wrapped span child.replace_with(wrapper_span) editable_counter += 1 has_mixed_content = True # If this element has no mixed content but has text, make the whole element editable if not has_mixed_content and element.get_text(strip=True): element['data-editable-id'] = f"editable-{editable_counter}" element['class'] = element.get('class', []) + ['editable-element'] editable_counter += 1 # All divs are removable (regardless of text content) div_elements = soup.find_all('div') for i, element in enumerate(div_elements): element['data-removable-id'] = f'div-{i}' element['class'] = element.get('class', []) + ['removable-element'] # Add editor CSS editor_css = """ """ # Add editor JavaScript editor_js = f""" """ # Inject CSS and JS if soup.head: soup.head.append(BeautifulSoup(editor_css, 'html.parser')) if soup.body: soup.body.append(BeautifulSoup(editor_js, 'html.parser')) return str(soup)