From d814c69fc9fe4f3bfd4e87749c162528577ac16f Mon Sep 17 00:00:00 2001 From: cgpp5 Date: Wed, 29 Jul 2026 16:52:06 +0100 Subject: [PATCH] add paragraph style management to plugin and MCP bridge This adds create, edit, delete, list, inspect, and apply operations for paragraph styles via the UNO bridge, plugin HTTP API, and consolidated MCP tools. Plugin (uno_bridge.py, mcp_server.py): - set_paragraph_style: apply a style to selection or by paragraph number - list_paragraph_styles: enumerate all styles in the document - get_paragraph_style: read style name of current paragraph or selection - create_paragraph_style: new custom styles with font, size, bold, etc. - edit_paragraph_style: modify existing style properties - delete_paragraph_style: delete any style by name - get_style_properties: inspect font, size, weight, alignment of a style MCP bridge (libreoffice_mcp_server.py): - document action: styles, create_style, edit_style, delete_style, style_properties - text action: style (apply heading/paragraph style to selection or paragraph_n) Also: registration.py now logs to a file and auto-starts the server, manifest.xml includes the missing pythonpath file entries. --- libreoffice_mcp_server.py | 98 ++++- plugin/META-INF/manifest.xml | 3 + plugin/ProtocolHandler.xcu | 2 +- plugin/pythonpath/mcp_server.py | 305 ++++++++++++++ plugin/pythonpath/registration.py | 46 ++- plugin/pythonpath/uno_bridge.py | 650 +++++++++++++++++++++++++++++- plugin/test_mcp_server.py | 120 ++++++ 7 files changed, 1192 insertions(+), 32 deletions(-) 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