diff --git a/libreoffice_mcp_server.py b/libreoffice_mcp_server.py index 263ee4c..415f8c9 100644 --- a/libreoffice_mcp_server.py +++ b/libreoffice_mcp_server.py @@ -20,10 +20,11 @@ """ import httpx +import os from fastmcp import FastMCP # LibreOffice HTTP API endpoint -LIBREOFFICE_URL = "http://localhost:8765" +LIBREOFFICE_URL = os.environ.get("LIBREOFFICE_URL", "http://localhost:8765") # Create the MCP server mcp = FastMCP("LibreOffice") @@ -46,13 +47,19 @@ def call_libreoffice(path: str, method: str = "GET", data: dict = None) -> dict: # ============================================================================= # CONSOLIDATED TOOL 1: document -# Actions: create, info, list, content, status +# Actions: create, info, list, content, status, styles, +# create_style, edit_style, delete_style, style_properties # ============================================================================= @mcp.tool -def document(action: str, doc_type: str = "writer") -> dict: +def document(action: str, doc_type: str = "writer", + style_name: str = None, parent_style: str = None, + font_name: str = None, font_size: float = None, + bold: bool = None, italic: bool = None, + underline: bool = None, alignment: str = None) -> dict: """ - Manage LibreOffice documents - create, get info, list, get content, or check status. + Manage LibreOffice documents - create, get info, list, content, status, + paragraph styles (list, create, edit, delete). Args: action: The operation to perform. Options: @@ -61,7 +68,20 @@ def document(action: str, doc_type: str = "writer") -> dict: - "list": List all open documents - "content": Get full text content of active document - "status": Check LibreOffice MCP server health + - "styles": List all available paragraph styles in the active document + - "create_style": Create a new paragraph style (requires style_name) + - "edit_style": Modify an existing paragraph style (requires style_name) + - "delete_style": Delete a paragraph style (requires style_name) + - "style_properties": Get detailed properties of a named style (requires style_name) doc_type: Type of document for "create" action. Options: "writer", "calc", "impress", "draw" + style_name: Style name for create_style / edit_style / delete_style actions + parent_style: Parent style for create_style / edit_style (default: "Standard") + font_name: Font family for create_style / edit_style + font_size: Font size in points for create_style / edit_style + bold: Bold weight for create_style / edit_style + italic: Italic posture for create_style / edit_style + underline: Underline for create_style / edit_style + alignment: "left", "right", "center", or "justify" for create_style / edit_style Returns: Result based on action performed @@ -76,8 +96,48 @@ def document(action: str, doc_type: str = "writer") -> dict: return call_libreoffice("/tools/get_text_content_live", "POST", {}) elif action == "status": return call_libreoffice("/health") + elif action == "styles": + return call_libreoffice("/tools/list_paragraph_styles_live", "POST", {}) + elif action == "create_style": + if style_name is None: + return {"error": "Action 'create_style' requires parameter 'style_name'"} + data = {"style_name": style_name} + if parent_style: + data["parent_style"] = parent_style + if font_name: data["font_name"] = font_name + if font_size is not None: data["font_size"] = font_size + if bold is not None: data["bold"] = bold + if italic is not None: data["italic"] = italic + if underline is not None: data["underline"] = underline + if alignment: data["alignment"] = alignment + return call_libreoffice("/tools/create_paragraph_style_live", "POST", data) + elif action == "edit_style": + if style_name is None: + return {"error": "Action 'edit_style' requires parameter 'style_name'"} + data = {"style_name": style_name} + if parent_style: data["parent_style"] = parent_style + if font_name: data["font_name"] = font_name + if font_size is not None: data["font_size"] = font_size + if bold is not None: data["bold"] = bold + if italic is not None: data["italic"] = italic + if underline is not None: data["underline"] = underline + if alignment: data["alignment"] = alignment + return call_libreoffice("/tools/edit_paragraph_style_live", "POST", data) + elif action == "delete_style": + if style_name is None: + return {"error": "Action 'delete_style' requires parameter 'style_name'"} + return call_libreoffice("/tools/delete_paragraph_style_live", "POST", + {"style_name": style_name}) + elif action == "style_properties": + if style_name is None: + return {"error": "Action 'style_properties' requires parameter 'style_name'"} + return call_libreoffice("/tools/get_style_properties_live", "POST", + {"style_name": style_name}) else: - return {"error": f"Invalid action '{action}'", "valid_actions": ["create", "info", "list", "content", "status"]} + return {"error": f"Invalid action '{action}'", + "valid_actions": ["create", "info", "list", "content", "status", + "styles", "create_style", "edit_style", "delete_style", + "style_properties"]} # ============================================================================= @@ -356,25 +416,30 @@ def save(action: str, file_path: str = None, export_format: str = "pdf") -> dict # ============================================================================= # CONSOLIDATED TOOL 9: text -# Actions: insert, format +# Actions: insert, format, style # ============================================================================= @mcp.tool def text(action: str, content: str = None, bold: bool = None, italic: bool = None, - underline: bool = None, font_size: int = None, font_name: str = None) -> dict: + underline: bool = None, font_size: int = None, font_name: str = None, + style_name: str = None, paragraph_n: int = None) -> dict: """ - Insert and format text in the document. + Insert, format text, or apply paragraph styles in the document. Args: action: The operation to perform. Options: - "insert": Insert text at cursor position (requires content) - "format": Apply formatting to selected text (use formatting params) + - "style": Apply a paragraph style (e.g. "Heading 1") to selected text + or a specific paragraph (use style_name + optional paragraph_n) content: Text to insert for "insert" action bold: Set bold formatting (True/False) for "format" action italic: Set italic formatting (True/False) for "format" action underline: Set underline formatting (True/False) for "format" action font_size: Font size in points for "format" action font_name: Font family name for "format" action + style_name: Name of paragraph style for "style" action (e.g. "Heading 1", "Heading 2", "Title") + paragraph_n: Optional paragraph number (1-indexed) to target with "style" action directly Returns: Result with success status @@ -396,9 +461,22 @@ def text(action: str, content: str = None, bold: bool = None, italic: bool = Non if font_name is not None: formatting["font_name"] = font_name return call_libreoffice("/tools/format_text_live", "POST", {"formatting": formatting}) + elif action == "style": + if style_name is None: + return {"error": "Action 'style' requires parameter 'style_name' (e.g. 'Heading 1', 'Heading 2')"} + data = {"style_name": style_name} + if paragraph_n is not None: + data["paragraph_n"] = paragraph_n + return call_libreoffice("/tools/format_paragraph_live", "POST", data) else: - return {"error": f"Invalid action '{action}'", "valid_actions": ["insert", "format"]} + return {"error": f"Invalid action '{action}'", "valid_actions": ["insert", "format", "style"]} if __name__ == "__main__": - mcp.run() + transport = os.environ.get("MCP_TRANSPORT", "stdio") + if transport == "sse": + host = os.environ.get("MCP_HOST", "127.0.0.1") + port = int(os.environ.get("MCP_PORT", "8766")) + mcp.run(transport="sse", host=host, port=port) + else: + mcp.run() diff --git a/plugin/META-INF/manifest.xml b/plugin/META-INF/manifest.xml index 2ca3ff2..49ff582 100644 --- a/plugin/META-INF/manifest.xml +++ b/plugin/META-INF/manifest.xml @@ -2,6 +2,9 @@ + + + diff --git a/plugin/ProtocolHandler.xcu b/plugin/ProtocolHandler.xcu index a01b9b1..7d5d1f0 100644 --- a/plugin/ProtocolHandler.xcu +++ b/plugin/ProtocolHandler.xcu @@ -9,7 +9,7 @@ - service:org.mcp.libreoffice.MCPExtension* + * diff --git a/plugin/pythonpath/mcp_server.py b/plugin/pythonpath/mcp_server.py index a044822..9c968bc 100644 --- a/plugin/pythonpath/mcp_server.py +++ b/plugin/pythonpath/mcp_server.py @@ -107,6 +107,168 @@ def _register_tools(self): "handler": self.format_text_live } + # Paragraph style tools + self.tools["format_paragraph_live"] = { + "description": "Apply a paragraph style (e.g., Heading 1) to selected text or a specific paragraph", + "parameters": { + "type": "object", + "properties": { + "style_name": { + "type": "string", + "description": "Name of the paragraph style to apply (e.g. 'Heading 1', 'Heading 2', 'Title', 'Text body')" + }, + "paragraph_n": { + "type": "integer", + "description": "Optional paragraph number (1-indexed) to target directly instead of current selection" + } + }, + "required": ["style_name"] + }, + "handler": self.format_paragraph_live + } + + self.tools["list_paragraph_styles_live"] = { + "description": "List all available paragraph styles in the active document (built-in and user-defined)", + "parameters": { + "type": "object", + "properties": {} + }, + "handler": self.list_paragraph_styles_live + } + + self.tools["get_paragraph_style_live"] = { + "description": "Get the paragraph style name of a specific paragraph or the current selection", + "parameters": { + "type": "object", + "properties": { + "paragraph_n": { + "type": "integer", + "description": "Optional paragraph number (1-indexed) to query. If not provided, queries the current selection." + } + } + }, + "handler": self.get_paragraph_style_live + } + + self.tools["create_paragraph_style_live"] = { + "description": "Create a new paragraph style in the active document", + "parameters": { + "type": "object", + "properties": { + "style_name": { + "type": "string", + "description": "Name for the new style (must not already exist)" + }, + "parent_style": { + "type": "string", + "description": "Parent style to inherit from (default: 'Standard')", + "default": "Standard" + }, + "font_name": { + "type": "string", + "description": "Font family name (optional)" + }, + "font_size": { + "type": "number", + "description": "Font size in points (optional)" + }, + "bold": { + "type": "boolean", + "description": "Bold weight (optional)" + }, + "italic": { + "type": "boolean", + "description": "Italic posture (optional)" + }, + "underline": { + "type": "boolean", + "description": "Single underline (optional)" + }, + "alignment": { + "type": "string", + "enum": ["left", "right", "center", "justify"], + "description": "Paragraph alignment (optional)" + } + }, + "required": ["style_name"] + }, + "handler": self.create_paragraph_style_live + } + + self.tools["edit_paragraph_style_live"] = { + "description": "Modify an existing paragraph style's properties", + "parameters": { + "type": "object", + "properties": { + "style_name": { + "type": "string", + "description": "Name of the existing style to modify" + }, + "parent_style": { + "type": "string", + "description": "New parent style (optional)" + }, + "font_name": { + "type": "string", + "description": "Font family name (optional)" + }, + "font_size": { + "type": "number", + "description": "Font size in points (optional)" + }, + "bold": { + "type": "boolean", + "description": "Bold weight (optional)" + }, + "italic": { + "type": "boolean", + "description": "Italic posture (optional)" + }, + "underline": { + "type": "boolean", + "description": "Single underline (optional)" + }, + "alignment": { + "type": "string", + "enum": ["left", "right", "center", "justify"], + "description": "Paragraph alignment (optional)" + } + }, + "required": ["style_name"] + }, + "handler": self.edit_paragraph_style_live + } + + self.tools["delete_paragraph_style_live"] = { + "description": "Delete a paragraph style from the active document", + "parameters": { + "type": "object", + "properties": { + "style_name": { + "type": "string", + "description": "Name of the style to delete" + } + }, + "required": ["style_name"] + }, + "handler": self.delete_paragraph_style_live + } + + self.tools["get_style_properties_live"] = { + "description": "Get all properties (font, size, bold, italic, underline, alignment, parent) of a named paragraph style", + "parameters": { + "type": "object", + "properties": { + "style_name": { + "type": "string", + "description": "Name of the paragraph style to inspect" + } + }, + "required": ["style_name"] + }, + "handler": self.get_style_properties_live + } + # Document saving tools self.tools["save_document_live"] = { "description": "Save the currently active document", @@ -501,6 +663,78 @@ def _register_tools(self): "handler": self.reject_all_changes_live } + # ── Calc / Spreadsheet Tools ── + + self.tools["get_cell_value_live"] = { + "description": "Get the value of a cell in a Calc spreadsheet (e.g., 'A1' or 'Sheet1.B5')", + "parameters": { + "type": "object", + "properties": { + "cell_address": { + "type": "string", + "description": "Cell address like 'A1', 'B5', or 'Sheet1.A1'" + }, + "sheet_name": { + "type": "string", + "description": "Sheet name (optional; uses active sheet or address prefix if omitted)" + } + }, + "required": ["cell_address"] + }, + "handler": self.get_cell_value_live + } + + self.tools["set_cell_value_live"] = { + "description": "Set the value of a cell in a Calc spreadsheet", + "parameters": { + "type": "object", + "properties": { + "cell_address": { + "type": "string", + "description": "Cell address like 'A1' or 'Sheet1.B5'" + }, + "value": { + "type": "string", + "description": "Value to set (number or text)" + }, + "sheet_name": { + "type": "string", + "description": "Sheet name (optional)" + } + }, + "required": ["cell_address", "value"] + }, + "handler": self.set_cell_value_live + } + + self.tools["get_cell_range_live"] = { + "description": "Get a rectangular range of cells as a 2D array (e.g., 'A1:C10' or 'Sheet1.A1:C10')", + "parameters": { + "type": "object", + "properties": { + "range_address": { + "type": "string", + "description": "Range like 'A1:B10' or 'Sheet1.A1:B10'" + }, + "sheet_name": { + "type": "string", + "description": "Sheet name (optional)" + } + }, + "required": ["range_address"] + }, + "handler": self.get_cell_range_live + } + + self.tools["list_sheets_live"] = { + "description": "List all sheet names in the current Calc document", + "parameters": { + "type": "object", + "properties": {} + }, + "handler": self.list_sheets_live + } + logger.info(f"Registered {len(self.tools)} MCP tools") async def execute_tool(self, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]: @@ -583,6 +817,54 @@ def format_text_live(self, **formatting) -> Dict[str, Any]: """Apply formatting to selected text""" return self.uno_bridge.format_text(formatting) + def format_paragraph_live(self, style_name: str, paragraph_n: Optional[int] = None) -> Dict[str, Any]: + """Apply a paragraph style to selected text or a specific paragraph""" + return self.uno_bridge.set_paragraph_style(style_name, paragraph_n) + + def list_paragraph_styles_live(self) -> Dict[str, Any]: + """List all available paragraph styles""" + return self.uno_bridge.list_paragraph_styles() + + def get_paragraph_style_live(self, paragraph_n: Optional[int] = None) -> Dict[str, Any]: + """Get the paragraph style of a specific paragraph or current selection""" + return self.uno_bridge.get_paragraph_style(paragraph_n) + + def create_paragraph_style_live(self, style_name: str, parent_style: str = "Standard", + font_name: Optional[str] = None, + font_size: Optional[float] = None, + bold: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + alignment: Optional[str] = None) -> Dict[str, Any]: + """Create a new paragraph style""" + return self.uno_bridge.create_paragraph_style( + style_name=style_name, parent_style=parent_style, + font_name=font_name, font_size=font_size, + bold=bold, italic=italic, underline=underline, + alignment=alignment) + + def edit_paragraph_style_live(self, style_name: str, parent_style: Optional[str] = None, + font_name: Optional[str] = None, + font_size: Optional[float] = None, + bold: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + alignment: Optional[str] = None) -> Dict[str, Any]: + """Modify an existing paragraph style""" + return self.uno_bridge.edit_paragraph_style( + style_name=style_name, parent_style=parent_style, + font_name=font_name, font_size=font_size, + bold=bold, italic=italic, underline=underline, + alignment=alignment) + + def delete_paragraph_style_live(self, style_name: str) -> Dict[str, Any]: + """Delete a user-defined paragraph style""" + return self.uno_bridge.delete_paragraph_style(style_name) + + def get_style_properties_live(self, style_name: str) -> Dict[str, Any]: + """Get all properties of a named paragraph style""" + return self.uno_bridge.get_style_properties(style_name) + def save_document_live(self, file_path: Optional[str] = None) -> Dict[str, Any]: """Save the currently active document""" return self.uno_bridge.save_document(file_path=file_path) @@ -727,6 +1009,29 @@ def reject_all_changes_live(self) -> Dict[str, Any]: """Reject all tracked changes in the document""" return self.uno_bridge.reject_all_changes() + # ── Calc / Spreadsheet Handlers ── + + def get_cell_value_live(self, cell_address: str, sheet_name: str = None) -> Dict[str, Any]: + """Get the value of a cell in a Calc spreadsheet""" + return self.uno_bridge.get_cell_value(sheet_name, cell_address) + + def set_cell_value_live(self, cell_address: str, value: str, sheet_name: str = None) -> Dict[str, Any]: + """Set the value of a cell in a Calc spreadsheet""" + # Try numeric conversion + try: + numeric = float(value) + return self.uno_bridge.set_cell_value(sheet_name, cell_address, numeric) + except ValueError: + return self.uno_bridge.set_cell_value(sheet_name, cell_address, value) + + def get_cell_range_live(self, range_address: str, sheet_name: str = None) -> Dict[str, Any]: + """Get a range of cells as a 2D array""" + return self.uno_bridge.get_cell_range(sheet_name, range_address) + + def list_sheets_live(self) -> Dict[str, Any]: + """List all sheet names""" + return self.uno_bridge.list_sheets() + # Global instance mcp_server = None diff --git a/plugin/pythonpath/registration.py b/plugin/pythonpath/registration.py index 447d8ef..9568e02 100644 --- a/plugin/pythonpath/registration.py +++ b/plugin/pythonpath/registration.py @@ -13,16 +13,25 @@ import os from com.sun.star.lang import XServiceInfo from com.sun.star.frame import XDispatchProvider, XDispatch -from com.sun.star.lang import XInitialization # Add the pythonpath directory to sys.path for imports _this_dir = os.path.dirname(__file__) if _this_dir not in sys.path: sys.path.insert(0, _this_dir) -# Set up logging -logging.basicConfig(level=logging.DEBUG) +# ── File-based logging so we can see what happens inside LO ── +LOG_FILE = os.path.join(os.environ.get("TEMP", "/tmp"), "mcp_extension.log") +_file_handler = None +try: + _file_handler = logging.FileHandler(LOG_FILE, mode="w") + _file_handler.setLevel(logging.DEBUG) + _file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) + logging.getLogger().addHandler(_file_handler) + logging.getLogger().setLevel(logging.DEBUG) +except Exception: + pass logger = logging.getLogger("MCPExtension") +logger.info("=== Extension module loaded, log at %s ===", LOG_FILE) # Implementation name and service name for the extension IMPLEMENTATION_NAME = "org.mcp.libreoffice.MCPExtension" @@ -80,19 +89,13 @@ def _stop_server(): logger.error(traceback.format_exc()) -class MCPProtocolHandler(unohelper.Base, XServiceInfo, XDispatchProvider, XDispatch, XInitialization): +class MCPProtocolHandler(unohelper.Base, XServiceInfo, XDispatchProvider, XDispatch): """Protocol handler for MCP extension menu commands""" def __init__(self, ctx): self.ctx = ctx self.frame = None - logger.debug("MCPProtocolHandler created") - - # XInitialization - def initialize(self, args): - if args: - self.frame = args[0] - logger.debug("MCPProtocolHandler initialized with frame") + logger.info("MCPProtocolHandler.__init__ called, ctx=%s", ctx) # XServiceInfo def getImplementationName(self): @@ -106,8 +109,11 @@ def getSupportedServiceNames(self): # XDispatchProvider def queryDispatch(self, url, target, flags): - logger.debug(f"queryDispatch: {url.Complete}") - if url.Protocol == "service:": + logger.info("queryDispatch: Complete=%r Protocol=%r Path=%r", + url.Complete, url.Protocol, url.Path) + # Accept ALL service: URLs for our protocol + if url.Complete and "org.mcp.libreoffice.MCPExtension" in url.Complete: + logger.info("queryDispatch: ACCEPTING") return self return None @@ -116,11 +122,11 @@ def queryDispatches(self, requests): # XDispatch def dispatch(self, url, args): - logger.info(f"dispatch called: {url.Complete}") + logger.info("dispatch called: Complete=%r", url.Complete) try: if "?" in url.Complete: command = url.Complete.split("?")[1] - logger.info(f"Executing command: {command}") + logger.info("Executing command: %s", command) if command == "start_mcp_server": # Run in thread to not block UI @@ -155,3 +161,13 @@ def removeStatusListener(self, listener, url): IMPLEMENTATION_NAME, SERVICE_NAMES ) +logger.info("=== addImplementation completed ===") + +# ── Auto-start the MCP server on extension load ── +logger.info("=== Auto-starting MCP server... ===") +try: + _start_server() + logger.info("=== Auto-start complete, server_started=%s ===", _server_started) +except Exception as e: + logger.error("=== Auto-start FAILED: %s ===", e) + logger.error(traceback.format_exc()) diff --git a/plugin/pythonpath/uno_bridge.py b/plugin/pythonpath/uno_bridge.py index 30336f4..e66fc96 100644 --- a/plugin/pythonpath/uno_bridge.py +++ b/plugin/pythonpath/uno_bridge.py @@ -177,8 +177,9 @@ def insert_text(self, text: str, position: Optional[int] = None, doc: Any = None text_obj = doc.getText() if position is None: - # Insert at current cursor position - cursor = doc.getCurrentController().getViewCursor() + # Insert at end of document (most reliable) + cursor = text_obj.createTextCursor() + cursor.gotoEnd(False) else: # Insert at specific position cursor = text_obj.createTextCursor() @@ -195,7 +196,9 @@ def insert_text(self, text: str, position: Optional[int] = None, doc: Any = None except Exception as e: logger.error(f"Failed to insert text: {e}") - return {"success": False, "error": str(e)} + import traceback as _tb + logger.error(_tb.format_exc()) + return {"success": False, "error": str(e) or repr(e)} def format_text(self, formatting: Dict[str, Any], doc: Any = None) -> Dict[str, Any]: """ @@ -246,6 +249,480 @@ def format_text(self, formatting: Dict[str, Any], doc: Any = None) -> Dict[str, logger.error(f"Failed to format text: {e}") return {"success": False, "error": str(e)} + def set_paragraph_style(self, style_name: str, paragraph_n: Optional[int] = None, + doc: Any = None) -> Dict[str, Any]: + """ + Apply a paragraph style (e.g., "Heading 1") to selected text or a specific paragraph. + + Args: + style_name: Name of the paragraph style to apply (e.g. "Heading 1", "Heading 2", "Title") + paragraph_n: Optional paragraph number (1-indexed) to target directly. + If provided, selects that paragraph before applying the style. + doc: Document to work with (None for active document) + + Returns: + Result dictionary with success status and paragraph number + """ + try: + if doc is None: + doc = self.get_active_document() + + if not doc: + return {"success": False, "error": "No document available"} + if self._get_document_type(doc) != "writer": + return {"success": False, "error": "Paragraph styles only supported for Writer documents"} + + # Validate that the style exists in the document + style_families = doc.getStyleFamilies() + para_styles = style_families.getByName("ParagraphStyles") + if not para_styles.hasByName(style_name): + # Collect a few known style names as hints + known = ["Heading 1", "Heading 2", "Heading 3", "Title", "Subtitle", + "Text body", "Standard"] + existing = [s for s in known if para_styles.hasByName(s)] + hint = ", ".join(existing[:6]) if existing else "none of the expected styles found" + return { + "success": False, + "error": f"Paragraph style '{style_name}' not found in document. " + f"Known styles available: {hint}" + } + + # If paragraph_n is given, select that paragraph first + applied_paragraph = None + if paragraph_n is not None: + select_result = self.select_paragraph(paragraph_n, doc) + if not select_result.get("success"): + return { + "success": False, + "error": f"Failed to select paragraph {paragraph_n}: " + f"{select_result.get('error', 'unknown error')}" + } + applied_paragraph = paragraph_n + + # Get current selection and apply the style + selection = doc.getCurrentController().getSelection() + if selection.getCount() == 0: + return {"success": False, "error": "No text selected. " + "Select a paragraph first or pass paragraph_n to target directly."} + + text_range = selection.getByIndex(0) + text_range.ParaStyleName = style_name + + # Determine which paragraph was affected + if applied_paragraph is None: + applied_paragraph = "current selection" + + logger.info(f"Applied paragraph style '{style_name}' to paragraph {applied_paragraph}") + return { + "success": True, + "message": f"Applied paragraph style '{style_name}'", + "paragraph": applied_paragraph, + "style_name": style_name + } + + except Exception as e: + logger.error(f"Failed to set paragraph style: {e}") + return {"success": False, "error": str(e)} + + def list_paragraph_styles(self, doc: Any = None) -> Dict[str, Any]: + """ + List all available paragraph styles in the document. + + Args: + doc: Document to query (None for active document) + + Returns: + Result dictionary with list of style names and count + """ + try: + if doc is None: + doc = self.get_active_document() + + if not doc: + return {"success": False, "error": "No document available"} + if self._get_document_type(doc) != "writer": + return {"success": False, "error": "Paragraph styles only supported for Writer documents"} + + style_families = doc.getStyleFamilies() + para_styles = style_families.getByName("ParagraphStyles") + + styles = [] + style_names = para_styles.getElementNames() + for name in style_names: + styles.append({"name": name}) + + styles.sort(key=lambda x: x["name"]) + + logger.info(f"Found {len(styles)} paragraph styles") + return { + "success": True, + "styles": styles, + "count": len(styles) + } + + except Exception as e: + logger.error(f"Failed to list paragraph styles: {e}") + return {"success": False, "error": str(e)} + + def get_paragraph_style(self, paragraph_n: Optional[int] = None, + doc: Any = None) -> Dict[str, Any]: + """ + Get the paragraph style name of a specific paragraph or the current selection. + + Args: + paragraph_n: Optional paragraph number (1-indexed) to query. + If None, queries the current selection. + doc: Document to query (None for active document) + + Returns: + Result dictionary with style_name and paragraph number + """ + try: + if doc is None: + doc = self.get_active_document() + + if not doc: + return {"success": False, "error": "No document available"} + if self._get_document_type(doc) != "writer": + return {"success": False, "error": "Paragraph styles only supported for Writer documents"} + + queried_paragraph = paragraph_n + + if paragraph_n is not None: + # Find the specific paragraph + text = doc.getText() + enum = text.createEnumeration() + current = 0 + target_para = None + while enum.hasMoreElements(): + para = enum.nextElement() + if hasattr(para, 'supportsService') and para.supportsService("com.sun.star.text.Paragraph"): + current += 1 + if current == paragraph_n: + target_para = para + break + + if target_para is None: + return {"success": False, "error": f"Paragraph {paragraph_n} out of range. " + f"Valid range: 1-{current}"} + + style_name = target_para.ParaStyleName if hasattr(target_para, 'ParaStyleName') else "" + else: + # Query current selection + selection = doc.getCurrentController().getSelection() + if selection.getCount() == 0: + return {"success": False, "error": "No text selected. " + "Select a paragraph first or pass paragraph_n to query directly."} + + text_range = selection.getByIndex(0) + style_name = text_range.ParaStyleName if hasattr(text_range, 'ParaStyleName') else "" + queried_paragraph = "current selection" + + logger.info(f"Paragraph {queried_paragraph} has style: '{style_name}'") + return { + "success": True, + "paragraph": queried_paragraph, + "style_name": style_name + } + + except Exception as e: + logger.error(f"Failed to get paragraph style: {e}") + return {"success": False, "error": str(e)} + + # ── Paragraph Style Management ─────────────────────────────────────── + + # Alignment constants for UNO ParagraphAdjust + _ALIGNMENT_VALUES = { + "left": 0, + "right": 1, + "center": 2, + "justify": 3, + } + + def get_style_properties(self, style_name: str, doc: Any = None) -> Dict[str, Any]: + """ + Get all properties of a named paragraph style. + + Args: + style_name: Name of the paragraph style to inspect + doc: Document (None for active document) + + Returns: + Result dictionary with style properties + """ + try: + if doc is None: + doc = self.get_active_document() + + if not doc: + return {"success": False, "error": "No document available"} + if self._get_document_type(doc) != "writer": + return {"success": False, "error": "Paragraph styles only supported for Writer documents"} + + style_families = doc.getStyleFamilies() + para_styles = style_families.getByName("ParagraphStyles") + + if not para_styles.hasByName(style_name): + return {"success": False, "error": f"Style '{style_name}' not found"} + + style = para_styles.getByName(style_name) + + # Read all accessible properties + props = { + "name": style_name, + "parent_style": getattr(style, 'ParentStyle', '') if hasattr(style, 'ParentStyle') else '', + "font_name": getattr(style, 'CharFontName', '') if hasattr(style, 'CharFontName') else '', + "font_size": getattr(style, 'CharHeight', 0.0) if hasattr(style, 'CharHeight') else 0.0, + "bold": getattr(style, 'CharWeight', 100.0) if hasattr(style, 'CharWeight') else 100.0, + "italic": getattr(style, 'CharPosture', 0) if hasattr(style, 'CharPosture') else 0, + "underline": getattr(style, 'CharUnderline', 0) if hasattr(style, 'CharUnderline') else 0, + "alignment": getattr(style, 'ParaAdjust', -1) if hasattr(style, 'ParaAdjust') else -1, + } + + # Convert numeric values to human-readable + props["bold"] = props["bold"] > 120.0 # > 120 = bold + props["italic"] = props["italic"] == 2 # 2 = ITALIC + props["underline"] = props["underline"] != 0 # non-zero = underlined + alignment_map = {0: "left", 1: "right", 2: "center", 3: "justify"} + props["alignment"] = alignment_map.get(props["alignment"], "unknown") + + logger.info(f"Retrieved properties for style '{style_name}'") + return {"success": True, "style_name": style_name, "properties": props} + + except Exception as e: + logger.error(f"Failed to get style properties: {e}") + return {"success": False, "error": str(e)} + + def create_paragraph_style(self, style_name: str, parent_style: str = "Standard", + font_name: Optional[str] = None, + font_size: Optional[float] = None, + bold: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + alignment: Optional[str] = None, + doc: Any = None) -> Dict[str, Any]: + """ + Create a new paragraph style in the document. + + Args: + style_name: Name for the new style (must not already exist) + parent_style: Parent style to inherit from (default: "Standard") + font_name: Font family name (optional) + font_size: Font size in points (optional) + bold: Bold weight (optional) + italic: Italic posture (optional) + underline: Single underline (optional) + alignment: "left", "right", "center", or "justify" (optional) + doc: Document (None for active document) + + Returns: + Result dictionary + """ + try: + if doc is None: + doc = self.get_active_document() + + if not doc: + return {"success": False, "error": "No document available"} + if self._get_document_type(doc) != "writer": + return {"success": False, "error": "Paragraph styles only supported for Writer documents"} + + style_families = doc.getStyleFamilies() + para_styles = style_families.getByName("ParagraphStyles") + + if para_styles.hasByName(style_name): + return {"success": False, "error": f"Style '{style_name}' already exists. " + "Use edit_paragraph_style to modify it."} + + # Validate parent style exists + if not para_styles.hasByName(parent_style): + return {"success": False, "error": f"Parent style '{parent_style}' not found"} + + # Validate alignment value if provided + if alignment is not None and alignment not in self._ALIGNMENT_VALUES: + valid = ", ".join(self._ALIGNMENT_VALUES.keys()) + return {"success": False, "error": f"Invalid alignment '{alignment}'. " + f"Valid values: {valid}"} + + # Create the style object + # Use the document itself as a factory (XMultiServiceFactory) + style = None + try: + style = doc.createInstance("com.sun.star.style.ParagraphStyle") + except Exception: + pass + + if style is None: + # Fallback: try ServiceManager + try: + style = self.smgr.createInstanceWithContext( + "com.sun.star.style.ParagraphStyle", self.ctx) + except Exception: + pass + + if style is None: + return {"success": False, + "error": "Failed to create paragraph style object. " + "The UNO service 'com.sun.star.style.ParagraphStyle' " + "could not be instantiated."} + + style.ParentStyle = parent_style + + # Apply character-level properties + if font_name is not None: + style.CharFontName = font_name + if font_size is not None: + style.CharHeight = font_size + if bold is not None: + style.CharWeight = 150.0 if bold else 100.0 + if italic is not None: + style.CharPosture = 2 if italic else 0 + if underline is not None: + style.CharUnderline = 1 if underline else 0 + + # Apply paragraph-level properties + if alignment is not None: + style.ParaAdjust = self._ALIGNMENT_VALUES[alignment] + + # Insert into the document + para_styles.insertByName(style_name, style) + + logger.info(f"Created paragraph style '{style_name}' (parent: {parent_style})") + return { + "success": True, + "message": f"Created paragraph style '{style_name}'", + "style_name": style_name, + "parent_style": parent_style + } + + except Exception as e: + logger.error(f"Failed to create paragraph style: {e}") + return {"success": False, "error": str(e)} + + def edit_paragraph_style(self, style_name: str, parent_style: Optional[str] = None, + font_name: Optional[str] = None, + font_size: Optional[float] = None, + bold: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + alignment: Optional[str] = None, + doc: Any = None) -> Dict[str, Any]: + """ + Modify an existing paragraph style's properties. + + Args: + style_name: Name of the existing style to modify + parent_style: New parent style (optional) + font_name: Font family name (optional) + font_size: Font size in points (optional) + bold: Bold weight (optional) + italic: Italic posture (optional) + underline: Single underline (optional) + alignment: "left", "right", "center", or "justify" (optional) + doc: Document (None for active document) + + Returns: + Result dictionary + """ + try: + if doc is None: + doc = self.get_active_document() + + if not doc: + return {"success": False, "error": "No document available"} + if self._get_document_type(doc) != "writer": + return {"success": False, "error": "Paragraph styles only supported for Writer documents"} + + style_families = doc.getStyleFamilies() + para_styles = style_families.getByName("ParagraphStyles") + + if not para_styles.hasByName(style_name): + return {"success": False, "error": f"Style '{style_name}' not found. " + "Use create_paragraph_style to create it first."} + + style = para_styles.getByName(style_name) + changed = [] + + if parent_style is not None: + if not para_styles.hasByName(parent_style): + return {"success": False, "error": f"Parent style '{parent_style}' not found"} + style.ParentStyle = parent_style + changed.append("parent_style") + + if font_name is not None: + style.CharFontName = font_name + changed.append("font_name") + if font_size is not None: + style.CharHeight = font_size + changed.append("font_size") + if bold is not None: + style.CharWeight = 150.0 if bold else 100.0 + changed.append("bold") + if italic is not None: + style.CharPosture = 2 if italic else 0 + changed.append("italic") + if underline is not None: + style.CharUnderline = 1 if underline else 0 + changed.append("underline") + if alignment is not None: + if alignment not in self._ALIGNMENT_VALUES: + valid = ", ".join(self._ALIGNMENT_VALUES.keys()) + return {"success": False, "error": f"Invalid alignment '{alignment}'. " + f"Valid values: {valid}"} + style.ParaAdjust = self._ALIGNMENT_VALUES[alignment] + changed.append("alignment") + + if not changed: + return {"success": False, "error": "No properties specified to change"} + + logger.info(f"Edited paragraph style '{style_name}': changed {changed}") + return { + "success": True, + "message": f"Updated paragraph style '{style_name}'", + "style_name": style_name, + "changed": changed + } + + except Exception as e: + logger.error(f"Failed to edit paragraph style: {e}") + return {"success": False, "error": str(e)} + + def delete_paragraph_style(self, style_name: str, doc: Any = None) -> Dict[str, Any]: + """ + Delete a paragraph style from the document. + + Args: + style_name: Name of the style to delete + doc: Document (None for active document) + + Returns: + Result dictionary + """ + try: + if doc is None: + doc = self.get_active_document() + + if not doc: + return {"success": False, "error": "No document available"} + if self._get_document_type(doc) != "writer": + return {"success": False, "error": "Paragraph styles only supported for Writer documents"} + + style_families = doc.getStyleFamilies() + para_styles = style_families.getByName("ParagraphStyles") + + if not para_styles.hasByName(style_name): + return {"success": False, "error": f"Style '{style_name}' not found"} + + para_styles.removeByName(style_name) + + logger.info(f"Deleted paragraph style '{style_name}'") + return {"success": True, "message": f"Deleted paragraph style '{style_name}'", + "style_name": style_name} + + except Exception as e: + logger.error(f"Failed to delete paragraph style: {e}") + return {"success": False, "error": str(e)} + def save_document(self, doc: Any = None, file_path: Optional[str] = None) -> Dict[str, Any]: """ Save a document @@ -349,14 +826,30 @@ def get_text_content(self, doc: Any = None) -> Dict[str, Any]: hasattr(doc, 'getText') if is_writer: - text = doc.getText().getString() - return {"success": True, "content": text, "length": len(text)} + text_obj = doc.getText() + # Try getting string directly first + content = text_obj.getString() + # If empty, try iterating through text portions (handles tables, frames, etc.) + if not content: + try: + portions = [] + enum = text_obj.createEnumeration() + while enum.hasMoreElements(): + portion = enum.nextElement() + if hasattr(portion, 'getString'): + portions.append(portion.getString()) + content = "".join(portions) + except Exception: + pass + return {"success": True, "content": content, "length": len(content)} else: return {"success": False, "error": f"Text extraction not supported for {self._get_document_type(doc)}"} except Exception as e: logger.error(f"Failed to get text content: {e}") - return {"success": False, "error": str(e)} + import traceback as _tb + logger.error(_tb.format_exc()) + return {"success": False, "error": str(e) or repr(e)} def get_comments(self, doc: Any = None) -> Dict[str, Any]: """Get all comments/annotations from the document""" @@ -1911,6 +2404,151 @@ def find_and_replace_all(self, old: str, new: str, doc: Any = None) -> Dict[str, logger.error(f"Failed to find and replace all: {e}") return {"success": False, "error": str(e)} + # ═══════════════════════════════════════════════════════════════════════ + # Calc / Spreadsheet Operations + # ═══════════════════════════════════════════════════════════════════════ + + def _get_sheet(self, doc: Any, sheet_name: Optional[str] = None) -> Any: + """Helper: get a sheet by name, or the active sheet.""" + sheets = doc.getSheets() + if sheet_name: + return sheets.getByName(sheet_name) + # Get active sheet via controller + controller = doc.getCurrentController() + return controller.getActiveSheet() + + def get_cell_value(self, sheet_name: Optional[str], cell_address: str, + doc: Any = None) -> Dict[str, Any]: + """Get the value of a single cell (e.g., 'A1', 'Sheet1.B5').""" + try: + if doc is None: + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + if not self._is_calc(doc): + return {"success": False, "error": "Active document is not a spreadsheet"} + + # Handle "Sheet1.A1" notation + if "." in cell_address and not sheet_name: + sheet_name, cell_address = cell_address.split(".", 1) + + sheet = self._get_sheet(doc, sheet_name) + actual_sheet = sheet.getName() + cell = sheet.getCellRangeByName(cell_address) + # Determine value type + cell_type = cell.getType().value if hasattr(cell.getType(), 'value') else "unknown" + if cell_type == "VALUE" or cell_type == 0: + val = cell.getValue() + elif cell_type == "FORMULA" or cell_type == 3: + val = cell.getFormula() + else: + val = cell.getString() + + return { + "success": True, + "cell": cell_address, + "sheet": actual_sheet, + "value": val, + "type": str(cell.getType()) if hasattr(cell, 'getType') else "unknown" + } + except Exception as e: + logger.error(f"get_cell_value failed: {e}") + return {"success": False, "error": str(e)} + + def set_cell_value(self, sheet_name: Optional[str], cell_address: str, + value, doc: Any = None) -> Dict[str, Any]: + """Set the value of a single cell.""" + try: + if doc is None: + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + if not self._is_calc(doc): + return {"success": False, "error": "Active document is not a spreadsheet"} + + if "." in cell_address and not sheet_name: + sheet_name, cell_address = cell_address.split(".", 1) + + sheet = self._get_sheet(doc, sheet_name) + cell = sheet.getCellRangeByName(cell_address) + if isinstance(value, (int, float)): + cell.setValue(float(value)) + else: + cell.setFormula(str(value)) + return {"success": True, "cell": cell_address, "sheet": sheet.getName()} + except Exception as e: + logger.error(f"set_cell_value failed: {e}") + return {"success": False, "error": str(e)} + + def get_cell_range(self, sheet_name: Optional[str], range_address: str, + doc: Any = None) -> Dict[str, Any]: + """Get a rectangular range of cells as a 2D array.""" + try: + if doc is None: + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + if not self._is_calc(doc): + return {"success": False, "error": "Active document is not a spreadsheet"} + + if "." in range_address and not sheet_name: + sheet_name, range_address = range_address.split(".", 1) + + sheet = self._get_sheet(doc, sheet_name) + cell_range = sheet.getCellRangeByName(range_address) + data = cell_range.getDataArray() # returns tuple of tuples + # Convert to list of lists + result = [list(row) for row in data] + return { + "success": True, + "range": range_address, + "sheet": sheet.getName(), + "rows": len(result), + "cols": len(result[0]) if result else 0, + "data": result + } + except Exception as e: + logger.error(f"get_cell_range failed: {e}") + return {"success": False, "error": str(e)} + + def list_sheets(self, doc: Any = None) -> Dict[str, Any]: + """List all sheet names in the document.""" + try: + if doc is None: + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + if not self._is_calc(doc): + return {"success": False, "error": "Active document is not a spreadsheet"} + + sheets = doc.getSheets() + names = [sheets.getByIndex(i).getName() for i in range(sheets.getCount())] + active = self.get_active_sheet_name(doc).get("name", "") + return {"success": True, "sheets": names, "active": active, "count": len(names)} + except Exception as e: + logger.error(f"list_sheets failed: {e}") + return {"success": False, "error": str(e)} + + def get_active_sheet_name(self, doc: Any = None) -> Dict[str, Any]: + """Get the name of the currently active sheet.""" + try: + if doc is None: + doc = self.get_active_document() + if not doc: + return {"success": False, "error": "No active document"} + controller = doc.getCurrentController() + sheet = controller.getActiveSheet() + return {"success": True, "name": sheet.getName()} + except Exception as e: + logger.error(f"get_active_sheet_name failed: {e}") + return {"success": False, "error": str(e)} + + def _is_calc(self, doc: Any) -> bool: + """Check if document is a Calc spreadsheet.""" + return self._get_document_type(doc) == "calc" + + # ═══════════════════════════════════════════════════════════════════════ + def _get_document_type(self, doc: Any) -> str: """Determine document type""" # Try isinstance first if types are available diff --git a/plugin/test_mcp_server.py b/plugin/test_mcp_server.py index 79ccd19..196dc9a 100644 --- a/plugin/test_mcp_server.py +++ b/plugin/test_mcp_server.py @@ -531,6 +531,120 @@ def test_reject_all_changes(): return True +# Paragraph Style Tools Tests + +def test_list_paragraph_styles(): + """Test listing available paragraph styles""" + print("Testing list_paragraph_styles_live tool...") + result = make_request("/tools/list_paragraph_styles_live", method="POST", data={}) + + if "error" in result: + print(f" ⚠ Error: {result['error']}") + elif result.get("success"): + count = result.get("count", 0) + builtin = result.get("builtin_count", 0) + user = result.get("user_defined_count", 0) + print(f" ✓ Found {count} paragraph styles ({builtin} built-in, {user} user-defined)") + # Verify some expected built-in styles exist + style_names = [s["name"] for s in result.get("styles", [])] + expected = ["Heading 1", "Heading 2", "Standard", "Text body"] + found_expected = [s for s in expected if s in style_names] + print(f" ✓ Standard styles found: {', '.join(found_expected)}") + else: + print(f" ⚠ Unexpected result: {result}") + return True + + +def test_apply_paragraph_style_by_selection(): + """Test applying a paragraph style via selection""" + print("Testing format_paragraph_live via selection...") + + # First ensure we have a document with paragraphs + make_request("/tools/create_document_live", method="POST", data={"doc_type": "writer"}) + # Insert a paragraph to work with + make_request("/tools/insert_text_live", method="POST", data={"text": "Test Heading"}) + make_request("/tools/insert_text_live", method="POST", data={"text": "\n"}) + make_request("/tools/insert_text_live", method="POST", data={"text": "Test body paragraph"}) + + # Select the first paragraph + select_result = make_request("/tools/select_paragraph_live", method="POST", data={"n": 1}) + if not select_result.get("success"): + print(f" ⚠ Could not select paragraph: {select_result.get('error', 'unknown')}") + return True + + # Apply heading style to the selection + result = make_request("/tools/format_paragraph_live", method="POST", + data={"style_name": "Heading 1"}) + + if result.get("success"): + print(f" ✓ Applied '{result.get('style_name')}' to {result.get('paragraph')}") + else: + print(f" ⚠ Error: {result.get('error', 'unknown')}") + + # Verify the style was applied by checking the outline + outline_result = make_request("/tools/get_document_outline_live", method="POST", data={}) + if outline_result.get("success"): + headings = outline_result.get("outline", []) + if len(headings) > 0: + print(f" ✓ Verified: heading appears in document outline ({len(headings)} heading(s))") + else: + print(f" ⚠ Warning: heading not found in outline after applying style") + + return True + + +def test_apply_paragraph_style_by_number(): + """Test applying a paragraph style directly by paragraph number""" + print("Testing format_paragraph_live via paragraph_n...") + + # Apply Heading 2 to paragraph 3 directly (no selection needed) + result = make_request("/tools/format_paragraph_live", method="POST", + data={"style_name": "Heading 2", "paragraph_n": 3}) + + if result.get("success"): + print(f" ✓ Applied '{result.get('style_name')}' directly to paragraph {result.get('paragraph')}") + else: + print(f" ⚠ Error (may need document with 3+ paragraphs): {result.get('error', 'unknown')}") + + return True + + +def test_get_paragraph_style(): + """Test getting the style of a specific paragraph""" + print("Testing get_paragraph_style_live tool...") + + # Query paragraph 1 (should have Heading 1 from previous test) + result = make_request("/tools/get_paragraph_style_live", method="POST", + data={"paragraph_n": 1}) + + if result.get("success"): + style_name = result.get("style_name", "unknown") + print(f" ✓ Paragraph {result.get('paragraph')} has style: '{style_name}'") + else: + print(f" ⚠ Error: {result.get('error', 'unknown')}") + + return True + + +def test_apply_paragraph_style_invalid_name(): + """Test applying an invalid style name returns a helpful error""" + print("Testing format_paragraph_live with invalid style name...") + + result = make_request("/tools/format_paragraph_live", method="POST", + data={"style_name": "NonExistentStyleXYZ"}) + + if not result.get("success"): + error_msg = result.get("error", "") + if "not found" in error_msg.lower(): + print(f" ✓ Got expected error for invalid style name") + else: + print(f" ⚠ Error but unexpected message: {error_msg}") + else: + print(f" ⚠ Expected failure but got success: {result}") + + return True + + def run_all_tests(): """Run all tests""" print("=" * 60) @@ -588,6 +702,12 @@ def run_all_tests(): test_reject_tracked_change, test_accept_all_changes, test_reject_all_changes, + # Paragraph Style Tools + test_list_paragraph_styles, + test_apply_paragraph_style_by_selection, + test_apply_paragraph_style_by_number, + test_get_paragraph_style, + test_apply_paragraph_style_invalid_name, ] passed = 0