diff --git a/README.md b/README.md index cea5a1fc1..391fdc5c8 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The canvas makes agent work visible. You can watch it browse, inspect what chang Create, open, and cowork with the AI on documents, spreadsheets, and presentation decks. -The document canvas supports Markdown by default, with LibreOffice-backed DOCX, XLSX, and PPTX workflows when binary artifacts are needed. Agents can create substantial deliverables, read their contents, apply precise saved edits, preserve version history, and generate native XLSX charts directly inside spreadsheets. +The document canvas supports Markdown by default, with LibreOffice-native ODT, ODS, and ODP workflows when binary office artifacts are needed. DOCX, XLSX, and PPTX remain available for explicit Microsoft compatibility. Agents can create substantial deliverables, read their contents, apply precise saved edits, preserve version history, and generate native XLSX charts directly inside compatibility spreadsheets. ## Native Browser With Annotations and Extensions @@ -176,7 +176,7 @@ Agent Zero supports plugins, MCP, A2A, custom tools, custom prompts, project-sco ## Try These First - **Research with a browser:** "Open the browser, compare three project management tools for a small AI team, and summarize the tradeoffs with source links." -- **Cowork on a spreadsheet:** "Create an editable XLSX budget model with assumptions, monthly projections, and a native chart." +- **Cowork on a spreadsheet:** "Create an editable ODS budget model with assumptions and monthly projections." - **Review a web UI:** "Open my local app in the Browser. I will annotate the page with comments; then implement the requested UI fixes." - **Work inside a Git project:** "Clone this repository into a new project, inspect the architecture, and propose the safest first improvement." - **Create a specialist:** "Create an Agent Profile for financial analysis with cautious reasoning, clear assumptions, and spreadsheet-first deliverables." diff --git a/plugins/_office/api/office_session.py b/plugins/_office/api/office_session.py index 7f41ea401..5647c6430 100644 --- a/plugins/_office/api/office_session.py +++ b/plugins/_office/api/office_session.py @@ -33,6 +33,10 @@ class OfficeSession(ApiHandler): ) except ValueError as exc: return {"ok": False, "error": str(exc)} + if doc["extension"] in {"odt", "ods", "odp"}: + validation = libreoffice.validate_odf(doc["path"]) + if not validation.get("ok"): + return {"ok": False, "error": validation.get("error") or "ODF validation failed."} if doc["extension"] == "docx": validation = libreoffice.validate_docx(doc["path"]) if not validation.get("ok"): diff --git a/plugins/_office/helpers/artifact_editor.py b/plugins/_office/helpers/artifact_editor.py index 7b6bf6e8f..e32aeaf65 100644 --- a/plugins/_office/helpers/artifact_editor.py +++ b/plugins/_office/helpers/artifact_editor.py @@ -20,12 +20,24 @@ R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types" REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" XML_NS = "http://www.w3.org/XML/1998/namespace" +ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0" +ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0" +ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0" +ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" +ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" +ODS_DIRECT_EDIT_ROW_LIMIT = 10000 +ODS_DIRECT_EDIT_COLUMN_LIMIT = 1024 for prefix, namespace in { "w": W_NS, "a": A_NS, "p": P_NS, "r": R_NS, + "office": ODF_OFFICE_NS, + "text": ODF_TEXT_NS, + "table": ODF_TABLE_NS, + "draw": ODF_DRAW_NS, + "presentation": ODF_PRESENTATION_NS, }.items(): ET.register_namespace(prefix, namespace) @@ -40,6 +52,12 @@ def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any] ext = str(doc["extension"]).lower() if ext == "md": content = _read_markdown(path) + elif ext == "odt": + content = _read_odt(path) + elif ext == "ods": + content = _read_ods(path) + elif ext == "odp": + content = _read_odp(path) elif ext == "docx": content = _read_docx(path) elif ext == "xlsx": @@ -74,6 +92,12 @@ def edit_artifact( invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False)) if ext == "md": updated, details = _edit_markdown(before, op, content=content, find=find, replace=replace, **kwargs) + elif ext == "odt": + updated, details = _edit_odt(before, op, content=content, find=find, replace=replace, **kwargs) + elif ext == "ods": + updated, details = _edit_ods(before, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs) + elif ext == "odp": + updated, details = _edit_odp(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs) elif ext == "docx": updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs) elif ext == "xlsx": @@ -179,6 +203,65 @@ def _read_markdown(path: Path) -> dict[str, Any]: } +def _read_odt(path: Path) -> dict[str, Any]: + root = _odf_content_root(path) + paragraphs = _odf_text_lines(root) + headings = [ + "".join(node.itertext()).strip() + for node in root.iter(qn(ODF_TEXT_NS, "h")) + if "".join(node.itertext()).strip() + ] + return { + "kind": "document", + "format": "odt", + "paragraph_count": len(paragraphs), + "headings": headings[:40], + "text": "\n".join(paragraphs), + "paragraphs": paragraphs[:80], + } + + +def _read_ods(path: Path) -> dict[str, Any]: + sheets = _ods_sheets_from_bytes( + path.read_bytes(), + max_rows=ODS_DIRECT_EDIT_ROW_LIMIT, + max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT, + ) + return { + "kind": "spreadsheet", + "format": "ods", + "sheet_count": len(sheets), + "sheets": [ + { + "name": sheet["name"], + "max_row": len(sheet["rows"]), + "max_column": max((len(row) for row in sheet["rows"]), default=0), + "chart_count": 0, + "charts": [], + "preview_rows": sheet["rows"][:80], + } + for sheet in sheets[:8] + ], + } + + +def _read_odp(path: Path) -> dict[str, Any]: + slides = _odp_text_slides(path.read_bytes()) + return { + "kind": "presentation", + "format": "odp", + "slide_count": len(slides), + "slides": [ + { + "index": index + 1, + "title": slide.get("title", ""), + "lines": [slide.get("title", ""), *slide.get("bullets", [])], + } + for index, slide in enumerate(slides[:40]) + ], + } + + def _read_docx(path: Path) -> dict[str, Any]: with zipfile.ZipFile(path) as archive: xml = archive.read("word/document.xml") @@ -272,6 +355,30 @@ def _edit_markdown(before: bytes, op: str, *, content: str = "", find: str = "", return updated.encode("utf-8"), details +def _edit_odt(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]: + if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}: + raise ValueError(f"Unsupported ODT operation: {op}") + + paragraphs = _odf_text_lines(ET.fromstring(_zip_member(before, "content.xml"))) + if op == "set_text": + lines = _text_lines(content) + return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)} + if op == "append_text": + lines = [*paragraphs, *_text_lines(content)] + return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)} + if op == "prepend_text": + lines = [*_text_lines(content), *paragraphs] + return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)} + + if not find: + raise ValueError("find is required for replace_text") + replacement = "" if op == "delete_text" else replace + joined, count = _replace_limited("\n".join(paragraphs), find, replacement, _int_or_none(kwargs.get("count"))) + if count == 0: + return before, {"replacements": count} + return document_store.odt_bytes_from_paragraphs(joined.splitlines()), {"replacements": count} + + def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]: if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}: raise ValueError(f"Unsupported DOCX operation: {op}") @@ -327,6 +434,75 @@ def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", rep return _zip_from_existing(files), details +def _edit_ods( + before: bytes, + op: str, + *, + content: str = "", + find: str = "", + replace: str = "", + sheet: str = "", + cells: Any = None, + rows: Any = None, + **kwargs: Any, +) -> tuple[bytes, dict[str, Any]]: + if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}: + raise ValueError(f"Unsupported ODS operation: {op}") + + sheets = _ods_sheets_from_bytes( + before, + max_rows=ODS_DIRECT_EDIT_ROW_LIMIT, + max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT, + strict_limits=True, + ) + if not sheets: + sheets = [{"name": "Sheet1", "rows": []}] + worksheet = _ods_sheet(sheets, sheet) + details: dict[str, Any] = {"sheet": worksheet["name"]} + + if op in {"set_text", "set_rows"}: + parsed_rows = _normalize_rows(rows if rows is not None else content) + worksheet["rows"] = parsed_rows + details["rows_written"] = len(parsed_rows) + elif op in {"append_text", "append_rows"}: + parsed_rows = _normalize_rows(rows if rows is not None else content) + worksheet["rows"].extend(parsed_rows) + details["rows_appended"] = len(parsed_rows) + details["start_row"] = max(len(worksheet["rows"]) - len(parsed_rows) + 1, 1) + elif op == "set_cells": + assignments = _normalize_cells(cells, default_sheet=worksheet["name"]) + for sheet_name, cell, value in assignments: + target = _ods_sheet(sheets, sheet_name) + row_idx, col_idx = _cell_indices(cell) + _set_matrix_value(target["rows"], row_idx, col_idx, value) + details["cells_written"] = len(assignments) + else: + if not find: + raise ValueError("find is required for replace_text") + replacement = "" if op == "delete_text" else replace + count = 0 + limit = _int_or_none(kwargs.get("count")) + for item in sheets: + for row_idx, row in enumerate(item["rows"]): + for col_idx, value in enumerate(row): + if not isinstance(value, str) or find not in value: + continue + remaining = None if limit is None else max(limit - count, 0) + if remaining == 0: + break + row[col_idx], replaced = _replace_limited(value, find, replacement, remaining) + count += replaced + if limit is not None and count >= limit: + break + if limit is not None and count >= limit: + break + details["replacements"] = count + if count == 0: + return before, details + + return document_store.ods_bytes_from_sheets(sheets), details + + def _edit_xlsx( path: Path, op: str, @@ -956,6 +1132,57 @@ def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", rep return _zip_from_existing(files), {"replacements": count} +def _edit_odp(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]: + if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}: + raise ValueError(f"Unsupported ODP operation: {op}") + + if op in {"set_text", "set_slides"}: + parsed_slides = _normalize_slides(slides if slides is not None else content) + return document_store.odp_bytes_from_slides(parsed_slides), {"slides_written": len(parsed_slides)} + + existing = _odp_text_slides(before) + if op in {"append_text", "append_slide"}: + existing.extend(_normalize_slides(slides if slides is not None else content)) + return document_store.odp_bytes_from_slides(existing), {"slides_written": len(existing)} + + if not find: + raise ValueError("find is required for replace_text") + replacement = "" if op == "delete_text" else replace + count = 0 + limit = _int_or_none(kwargs.get("count")) + for slide in existing: + title, title_count = _replace_limited( + str(slide.get("title") or ""), + find, + replacement, + None if limit is None else max(limit - count, 0), + ) + if title_count: + slide["title"] = title + count += title_count + if limit is not None and count >= limit: + break + bullets = [] + for bullet in slide.get("bullets") or []: + updated, replaced = _replace_limited( + str(bullet), + find, + replacement, + None if limit is None else max(limit - count, 0), + ) + bullets.append(updated) + count += replaced + if limit is not None and count >= limit: + bullets.extend(slide.get("bullets", [])[len(bullets):]) + break + slide["bullets"] = bullets + if limit is not None and count >= limit: + break + if count == 0: + return before, {"replacements": count} + return document_store.odp_bytes_from_slides(existing), {"replacements": count} + + def _replace_text_in_paragraphs( root: ET.Element, *, @@ -1140,6 +1367,188 @@ def _markdown_table_rows(lines: list[str]) -> list[list[str]]: return rows +def _zip_member(data: bytes, name: str) -> bytes: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + return archive.read(name) + + +def _odf_content_root(path: Path) -> ET.Element: + with zipfile.ZipFile(path) as archive: + return ET.fromstring(archive.read("content.xml")) + + +def _odf_text_lines(root: ET.Element) -> list[str]: + lines = [] + for node in root.iter(): + if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}: + continue + text = "".join(node.itertext()).strip() + if text: + lines.append(text) + return lines + + +def _ods_sheets_from_bytes( + data: bytes, + *, + max_rows: int | None = None, + max_cols: int | None = None, + strict_limits: bool = False, +) -> list[dict[str, Any]]: + root = ET.fromstring(_zip_member(data, "content.xml")) + sheets = [] + for index, table in enumerate(root.iter(qn(ODF_TABLE_NS, "table")), start=1): + name = table.get(qn(ODF_TABLE_NS, "name")) or table.get("name") or f"Sheet{index}" + rows = [] + for row in table: + if row.tag != qn(ODF_TABLE_NS, "table-row"): + continue + values = _ods_row_values(row, max_cols=max_cols, strict_limits=strict_limits) + repeat_rows = _repeat_count(row.get(qn(ODF_TABLE_NS, "number-rows-repeated"))) + append_count = repeat_rows + if max_rows is not None: + remaining = max(max_rows - len(rows), 0) + append_count = min(repeat_rows, remaining) + if strict_limits and repeat_rows > append_count and _row_has_content(values): + raise ValueError(f"ODS direct editing is limited to {max_rows} populated rows per sheet.") + if remaining == 0: + if not strict_limits: + break + continue + for _ in range(append_count): + rows.append(values.copy()) + if max_rows is not None and len(rows) >= max_rows and not strict_limits: + break + sheets.append({"name": name, "rows": _trim_blank_edges(rows)}) + return sheets + + +def _ods_row_values( + row: ET.Element, + *, + max_cols: int | None = None, + strict_limits: bool = False, +) -> list[Any]: + values = [] + for cell in row: + if cell.tag not in {qn(ODF_TABLE_NS, "table-cell"), qn(ODF_TABLE_NS, "covered-table-cell")}: + continue + value = _ods_cell_value(cell) + repeat = _repeat_count(cell.get(qn(ODF_TABLE_NS, "number-columns-repeated"))) + append_count = repeat + if max_cols is not None: + remaining = max(max_cols - len(values), 0) + append_count = min(repeat, remaining) + if strict_limits and repeat > append_count and _cell_has_content(value): + raise ValueError(f"ODS direct editing is limited to {max_cols} populated columns per sheet.") + if remaining == 0: + if not strict_limits: + break + continue + for _ in range(append_count): + values.append(value) + if max_cols is not None and len(values) >= max_cols and not strict_limits: + break + return values + + +def _ods_cell_value(cell: ET.Element) -> Any: + value_type = str(cell.get(qn(ODF_OFFICE_NS, "value-type")) or "").lower() + if value_type in {"float", "currency", "percentage"}: + raw = cell.get(qn(ODF_OFFICE_NS, "value")) + if raw not in (None, ""): + try: + number = float(raw) + return int(number) if number.is_integer() else number + except ValueError: + pass + if value_type == "boolean": + raw = str(cell.get(qn(ODF_OFFICE_NS, "boolean-value")) or "").lower() + if raw in {"true", "false"}: + return raw == "true" + text = "\n".join("".join(node.itertext()).strip() for node in cell.iter(qn(ODF_TEXT_NS, "p"))) + return text.strip() + + +def _repeat_count(value: Any) -> int: + try: + count = int(value or 1) + except (TypeError, ValueError): + count = 1 + return max(1, count) + + +def _trim_blank_edges(rows: list[list[Any]]) -> list[list[Any]]: + trimmed = [] + for row in rows: + next_row = list(row) + while next_row and not _cell_has_content(next_row[-1]): + next_row.pop() + trimmed.append(next_row) + while trimmed and not _row_has_content(trimmed[-1]): + trimmed.pop() + return trimmed + + +def _cell_has_content(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + return True + + +def _row_has_content(row: list[Any]) -> bool: + return any(_cell_has_content(value) for value in row) + + +def _ods_sheet(sheets: list[dict[str, Any]], name: str = "") -> dict[str, Any]: + normalized = str(name or "").strip() + if normalized: + for sheet in sheets: + if str(sheet["name"]).casefold() == normalized.casefold(): + return sheet + sheet = {"name": normalized, "rows": []} + sheets.append(sheet) + return sheet + return sheets[0] + + +def _cell_indices(cell: str) -> tuple[int, int]: + match = re.fullmatch(r"\$?([A-Za-z]{1,4})\$?([1-9][0-9]*)", str(cell or "").strip()) + if not match: + raise ValueError(f"Invalid cell reference: {cell}") + col = 0 + for char in match.group(1).upper(): + col = col * 26 + (ord(char) - 64) + return int(match.group(2)), col + + +def _set_matrix_value(rows: list[list[Any]], row_idx: int, col_idx: int, value: Any) -> None: + while len(rows) < row_idx: + rows.append([]) + row = rows[row_idx - 1] + while len(row) < col_idx: + row.append("") + row[col_idx - 1] = value + + +def _odp_text_slides(data: bytes) -> list[dict[str, Any]]: + root = ET.fromstring(_zip_member(data, "content.xml")) + slides = [] + for page in root.iter(qn(ODF_DRAW_NS, "page")): + lines = [] + for node in page.iter(): + if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}: + continue + text = "".join(node.itertext()).strip() + if text: + lines.append(text) + if lines: + slides.append({"title": lines[0], "bullets": lines[1:]}) + return slides + + def _pptx_text_slides(data: bytes) -> list[dict[str, Any]]: slides = [] with zipfile.ZipFile(io.BytesIO(data)) as archive: diff --git a/plugins/_office/helpers/document_affordance.py b/plugins/_office/helpers/document_affordance.py index 25f338d65..b025ec23d 100644 --- a/plugins/_office/helpers/document_affordance.py +++ b/plugins/_office/helpers/document_affordance.py @@ -42,6 +42,9 @@ DOCUMENT_TERMS = { "manual", "markdown", "memo", + "odt", + "open document", + "opendocument", "policy", "proposal", "report", @@ -49,11 +52,14 @@ DOCUMENT_TERMS = { "spec", "story", "whitepaper", + "writer", } SPREADSHEET_TERMS = { "budget", + "calc", "excel", + "ods", "sheet", "spreadsheet", "table", @@ -63,6 +69,8 @@ SPREADSHEET_TERMS = { PRESENTATION_TERMS = { "deck", + "impress", + "odp", "ppt", "pptx", "presentation", @@ -89,6 +97,9 @@ EXPLICIT_FORMAT_TERMS = { "docx", "md", "markdown", + "odp", + "ods", + "odt", "pptx", "xlsx", } @@ -98,8 +109,10 @@ HANDOFF_TERMS = { "artifacts", "canvas", "document canvas", + "download", "downloadable", "editable", + "export", "open it", "save it", "save this", @@ -119,6 +132,36 @@ CHAT_ONLY_TERMS = { "no files", } +META_DISCUSSION_TERMS = { + "affordance", + "automatically", + "auto", + "disable", + "issue", + "less triggered", + "problem", + "speedbump", + "stop", + "trigger", + "triggered", + "why", +} + +OOXML_COMPAT_TERMS = { + "docx", + "excel", + "microsoft word", + "powerpoint", + "ppt", + "pptx", + "word", + "xlsx", +} + +ODF_DOCUMENT_TERMS = {"odt", "open document text", "opendocument text", "writer"} +ODF_SPREADSHEET_TERMS = {"calc", "ods", "open document spreadsheet", "opendocument spreadsheet"} +ODF_PRESENTATION_TERMS = {"impress", "odp", "open document presentation", "opendocument presentation"} + SKIP_RESPONSE_PREFIXES = ( "i can't", "i cannot", @@ -200,11 +243,17 @@ def normalize_text(value: str) -> str: def infer_kind_and_format(lowered_user: str) -> tuple[str, str]: if has_any(lowered_user, PRESENTATION_TERMS): - return "presentation", "pptx" + if has_any(lowered_user, {"powerpoint", "ppt", "pptx"}): + return "presentation", "pptx" + return "presentation", "odp" if has_any(lowered_user, SPREADSHEET_TERMS): - return "spreadsheet", "xlsx" - if has_any(lowered_user, {"docx"}): + if has_any(lowered_user, {"excel", "xlsx"}): + return "spreadsheet", "xlsx" + return "spreadsheet", "ods" + if has_any(lowered_user, {"docx", "microsoft word", "word"}): return "document", "docx" + if has_any(lowered_user, ODF_DOCUMENT_TERMS): + return "document", "odt" return "document", "md" @@ -213,8 +262,6 @@ def artifact_intent(lowered_user: str, response_text: str) -> str | None: return None if has_explicit_handoff_signal(lowered_user): return "explicit_handoff" - if has_any(lowered_user, DELIVERABLE_TERMS) and looks_like_standalone_artifact(response_text): - return "document_intent" return None @@ -226,25 +273,42 @@ def has_document_creation_intent(lowered_user: str) -> bool: def has_explicit_handoff_signal(lowered_user: str) -> bool: - if has_any(lowered_user, EXPLICIT_FORMAT_TERMS | HANDOFF_TERMS): - return True - if has_any(lowered_user, FILE_HANDOFF_TERMS) and has_any( - lowered_user, - DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS, - ): + if looks_like_affordance_meta_discussion(lowered_user): + return False + + creation = r"(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format|convert|turn|save|export)" + handoff = r"(?:file|files|artifact|artifacts|canvas|download|downloadable|editable file|open in canvas)" + format_name = ( + r"(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress|word|excel|" + r"powerpoint|document|spreadsheet|workbook|presentation|deck|slides)" + ) + + if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b{handoff}\b", lowered_user): return True if re.search( - r"\b(?:convert|format|save|turn)\b(?:\W+\w+){0,8}?\W+(?:as|to|into)\s+" - r"(?:a|an|the)?\s*(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides|md|docx|xlsx|pptx)\b", + rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+(?:as|to|into)\s+" + rf"(?:a|an|the)?\s*{format_name}\b", lowered_user, ): return True - return bool(re.search( - r"\b(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format)\b" - r"(?:\s+(?:me|us|a|an|the|new|blank|editable|office|word|excel|powerpoint))*" - r"\s+(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides)\b", + if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress)\b", lowered_user): + return True + return bool( + has_any(lowered_user, FILE_HANDOFF_TERMS | HANDOFF_TERMS) + and has_any( + lowered_user, + EXPLICIT_FORMAT_TERMS | ODF_DOCUMENT_TERMS | ODF_SPREADSHEET_TERMS | ODF_PRESENTATION_TERMS | OOXML_COMPAT_TERMS, + ) + ) + + +def looks_like_affordance_meta_discussion(lowered_user: str) -> bool: + if not has_any(lowered_user, META_DISCUSSION_TERMS): + return False + return has_any( lowered_user, - )) + DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS | EXPLICIT_FORMAT_TERMS | FILE_HANDOFF_TERMS, + ) def has_any(text: str, terms: set[str]) -> bool: diff --git a/plugins/_office/helpers/document_store.py b/plugins/_office/helpers/document_store.py index 77155791e..f357bb7e0 100644 --- a/plugins/_office/helpers/document_store.py +++ b/plugins/_office/helpers/document_store.py @@ -20,9 +20,25 @@ from plugins._office.helpers import pptx_writer PLUGIN_NAME = "_office" -SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"} +OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"} +OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"} +SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS} DEFAULT_TTL_SECONDS = 8 * 60 * 60 MAX_SAVE_BYTES = 512 * 1024 * 1024 +ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0" +ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0" +ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0" +ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" +ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" +ODF_STYLE_NS = "urn:oasis:names:tc:opendocument:xmlns:style:1.0" +ODF_FO_NS = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" +ODF_MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" +ODF_VERSION = "1.2" +ODF_MIMETYPES = { + "odt": "application/vnd.oasis.opendocument.text", + "ods": "application/vnd.oasis.opendocument.spreadsheet", + "odp": "application/vnd.oasis.opendocument.presentation", +} STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents")) DB_PATH = STATE_DIR / "documents.sqlite3" @@ -58,8 +74,6 @@ def normalize_extension(value: str) -> str: if not ext: ext = "md" if ext not in SUPPORTED_EXTENSIONS: - if ext == "odt": - raise ValueError("ODT editing is not supported in this migration. Use Markdown or DOCX.") raise ValueError(f"Unsupported document format: {ext}") return ext @@ -599,7 +613,7 @@ def create_document( def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path: - base = safe_title(title, "Document") + base = safe_document_stem(title, ext, "Document") root = document_home(context_id) if ext == "md" else document_binary_home(context_id) candidate = root / f"{base}.{ext}" index = 2 @@ -609,10 +623,24 @@ def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path: return candidate.resolve(strict=False) +def safe_document_stem(title: str, ext: str, fallback: str = "Document") -> str: + base = safe_title(title, fallback) + suffix = f".{normalize_extension(ext)}" + if base.casefold().endswith(suffix.casefold()): + base = base[: -len(suffix)].rstrip(" ._") or fallback + return base + + def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes: ext = normalize_extension(ext or "md") if ext == "md": return _markdown(title, content).encode("utf-8") + if ext == "odt": + return odt_bytes(title, content) + if ext == "ods": + return ods_bytes(title, content) + if ext == "odp": + return odp_bytes(title, content) if ext == "docx": return _docx(title, content) if ext == "xlsx": @@ -638,6 +666,219 @@ def _zip_bytes(files_map: dict[str, str | bytes]) -> bytes: return buffer.getvalue() +def odf_zip_bytes(ext: str, files_map: dict[str, str | bytes]) -> bytes: + ext = normalize_extension(ext) + if ext not in ODF_MIMETYPES: + raise ValueError(f"Unsupported ODF format: {ext}") + media_type = ODF_MIMETYPES[ext] + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("mimetype", media_type, compress_type=zipfile.ZIP_STORED) + for name, value in files_map.items(): + if name == "mimetype": + continue + data = value.encode("utf-8") if isinstance(value, str) else value + archive.writestr(name, data, compress_type=zipfile.ZIP_DEFLATED) + return buffer.getvalue() + + +def odt_bytes(title: str, content: str) -> bytes: + return odt_bytes_from_paragraphs(_document_lines(title, content)) + + +def odt_bytes_from_paragraphs(paragraphs: list[str]) -> bytes: + lines = [str(line) for line in paragraphs] or [""] + body = "\n".join(_odt_paragraph(line, index == 0) for index, line in enumerate(lines)) + return _odf_package( + "odt", + f""" + + + + {body} + + + +""", + ) + + +def ods_bytes(title: str, content: str) -> bytes: + return ods_bytes_from_sheets([{"name": "Sheet1", "rows": _xlsx_rows(title, content)}]) + + +def ods_bytes_from_sheets(sheets: list[dict[str, Any]]) -> bytes: + normalized = [] + for index, sheet in enumerate(sheets or []): + name = safe_title(str(sheet.get("name") or f"Sheet{index + 1}"), f"Sheet{index + 1}")[:31] or f"Sheet{index + 1}" + rows = sheet.get("rows") or [] + normalized.append({"name": name, "rows": rows}) + if not normalized: + normalized = [{"name": "Sheet1", "rows": [["Spreadsheet"]]}] + + tables = "\n".join( + f""" + {''.join(_ods_row(row) for row in sheet['rows'])} + """ + for sheet in normalized + ) + return _odf_package( + "ods", + f""" + + + + {tables} + + + +""", + ) + + +def odp_bytes(title: str, content: str) -> bytes: + return odp_bytes_from_slides(pptx_writer.slides_from_text(title, content)) + + +def odp_bytes_from_slides(slides: list[dict[str, Any]]) -> bytes: + normalized = pptx_writer.normalize_slides(slides) + if not normalized: + normalized = [{"title": "Presentation", "bullets": []}] + pages = "\n".join(_odp_page(slide, index) for index, slide in enumerate(normalized, start=1)) + return _odf_package( + "odp", + f""" + + + + {pages} + + + +""", + ) + + +def _document_lines(title: str, content: str) -> list[str]: + lines = [str(title or "Document").strip() or "Document"] + lines.extend(line.rstrip() for line in str(content or "").splitlines() if line.strip()) + if len(lines) == 1: + lines.append("") + return lines + + +def _odf_package(ext: str, content_xml: str) -> bytes: + return odf_zip_bytes( + ext, + { + "content.xml": content_xml, + "styles.xml": _odf_styles_xml(), + "meta.xml": _odf_meta_xml(), + "settings.xml": _odf_settings_xml(), + "META-INF/manifest.xml": _odf_manifest_xml(ODF_MIMETYPES[ext]), + }, + ) + + +def _odf_content_namespaces() -> str: + return ( + f'xmlns:office="{ODF_OFFICE_NS}" ' + f'xmlns:text="{ODF_TEXT_NS}" ' + f'xmlns:table="{ODF_TABLE_NS}" ' + f'xmlns:draw="{ODF_DRAW_NS}" ' + f'xmlns:presentation="{ODF_PRESENTATION_NS}" ' + f'xmlns:style="{ODF_STYLE_NS}" ' + f'xmlns:fo="{ODF_FO_NS}"' + ) + + +def _odf_styles_xml() -> str: + return f""" + + + + + + + + +""" + + +def _odf_meta_xml() -> str: + return f""" + + + +""" + + +def _odf_settings_xml() -> str: + return f""" + + + +""" + + +def _odf_manifest_xml(media_type: str) -> str: + return f""" + + + + + + + +""" + + +def _odt_paragraph(line: str, heading: bool = False) -> str: + text = escape(str(line)) + if heading: + return f'{text}' + return f"{text}" + + +def _ods_row(row: list[Any]) -> str: + cells = "".join(_ods_cell(value) for value in row) + return f"{cells}" + + +def _ods_cell(value: Any) -> str: + value = _xlsx_value(value) + if value in (None, ""): + return "" + if isinstance(value, bool): + text = "TRUE" if value else "FALSE" + return ( + f'' + f"{text}" + ) + if isinstance(value, (int, float)): + return ( + f'' + f"{value}" + ) + text = escape(str(value)) + return f'{text}' + + +def _odp_page(slide: dict[str, Any], index: int) -> str: + title = escape(str(slide.get("title") or f"Slide {index}")) + bullets = [escape(str(item)) for item in slide.get("bullets") or []] + bullet_items = "".join(f"{bullet}" for bullet in bullets) + body = f"{bullet_items}" if bullet_items else "" + return f""" + + {title} + + + {body} + +""" + + def _docx(title: str, content: str) -> bytes: lines = [title] + [line for line in content.splitlines() if line.strip()] if len(lines) == 1: diff --git a/plugins/_office/helpers/libreoffice.py b/plugins/_office/helpers/libreoffice.py index 4103f6a45..92e29fd25 100644 --- a/plugins/_office/helpers/libreoffice.py +++ b/plugins/_office/helpers/libreoffice.py @@ -11,6 +11,11 @@ from typing import Any SOFFICE_BINARIES = ("soffice", "libreoffice") CONVERT_TIMEOUT_SECONDS = 45 +ODF_MIMETYPES = { + "odt": "application/vnd.oasis.opendocument.text", + "ods": "application/vnd.oasis.opendocument.spreadsheet", + "odp": "application/vnd.oasis.opendocument.presentation", +} def find_soffice() -> str: @@ -73,6 +78,29 @@ def validate_docx(path: str | Path) -> dict[str, Any]: return {"ok": True} +def validate_odf(path: str | Path) -> dict[str, Any]: + source = Path(path) + if not source.exists(): + return {"ok": False, "error": f"File not found: {source}"} + ext = source.suffix.lower().lstrip(".") + expected_mimetype = ODF_MIMETYPES.get(ext) + if not expected_mimetype: + return {"ok": False, "error": f"Unsupported ODF extension: {ext}"} + try: + with zipfile.ZipFile(source) as archive: + first = archive.infolist()[0] + mimetype = archive.read("mimetype").decode("utf-8") + archive.getinfo("content.xml") + archive.getinfo("META-INF/manifest.xml") + except Exception as exc: + return {"ok": False, "error": f"ODF package validation failed: {exc}"} + if first.filename != "mimetype" or first.compress_type != zipfile.ZIP_STORED: + return {"ok": False, "error": "ODF mimetype must be the first uncompressed package entry."} + if mimetype != expected_mimetype: + return {"ok": False, "error": f"ODF mimetype mismatch: expected {expected_mimetype}, got {mimetype}"} + return {"ok": True} + + def convert_document(path: str | Path, target_format: str, output_dir: str | Path | None = None) -> dict[str, Any]: source = Path(path) if not source.exists(): diff --git a/plugins/_office/helpers/libreoffice_desktop.py b/plugins/_office/helpers/libreoffice_desktop.py index dde29054c..c87653190 100644 --- a/plugins/_office/helpers/libreoffice_desktop.py +++ b/plugins/_office/helpers/libreoffice_desktop.py @@ -20,7 +20,7 @@ from helpers import files, virtual_desktop from plugins._office.helpers import document_store, libreoffice -OFFICIAL_EXTENSIONS = {"docx", "xlsx", "pptx"} +OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"} SYSTEM_SESSION_ID = "agent-zero-desktop" SYSTEM_FILE_ID = "system-desktop" SYSTEM_TITLE = "Desktop" diff --git a/plugins/_office/plugin.yaml b/plugins/_office/plugin.yaml index 17c3b812d..b1397b6aa 100644 --- a/plugins/_office/plugin.yaml +++ b/plugins/_office/plugin.yaml @@ -1,6 +1,6 @@ name: _office title: LibreOffice -description: Markdown-first writing and LibreOffice-backed document artifacts in the right canvas. +description: Markdown writing and ODF-first LibreOffice document artifacts in the right canvas. version: "0.1" settings_sections: - developer diff --git a/plugins/_office/prompts/agent.system.tool.document_artifact.md b/plugins/_office/prompts/agent.system.tool.document_artifact.md index 3dc5f63ab..baa29d947 100644 --- a/plugins/_office/prompts/agent.system.tool.document_artifact.md +++ b/plugins/_office/prompts/agent.system.tool.document_artifact.md @@ -1,14 +1,15 @@ ### document_artifact create/open/read/edit reusable document artifacts in the Agent Zero canvas -formats: md docx xlsx pptx +formats: md odt ods odp docx xlsx pptx default format: md methods: create open read edit inspect export version_history restore_version status common args: method action kind title format content path file_id `method` is accepted as an alias for action when the tool_name has no suffix tool results save or update artifacts only; they do not open the canvas automatically created/updated artifacts are shown with explicit Download and Open in canvas message actions -XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts +ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests Microsoft compatibility +DOCX/XLSX/PPTX are compatibility formats, not defaults +XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required chart types: line bar column pie area scatter stock ohlc candlestick -XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells -ODT/ODS/ODP editing is intentionally unsupported in this migration -for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Word/Excel/Presentation skill first +ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells +for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Writer/Calc/Impress skill first diff --git a/plugins/_office/skills/excel-workbooks/SKILL.md b/plugins/_office/skills/excel-workbooks/SKILL.md index e7a1596ce..07787bb9e 100644 --- a/plugins/_office/skills/excel-workbooks/SKILL.md +++ b/plugins/_office/skills/excel-workbooks/SKILL.md @@ -1,10 +1,14 @@ --- name: excel-workbooks -description: Use when creating, opening, or editing Excel-compatible XLSX spreadsheets, workbooks, tables, budgets, formulas, sheets, or charts. -version: "1.0.0" +description: Use when creating, opening, or editing LibreOffice Calc ODS spreadsheets, or XLSX workbooks only when Excel compatibility is explicitly required. +version: "1.1.0" author: "Agent Zero Core Team" -tags: ["excel", "xlsx", "spreadsheet", "workbook", "calc", "tables", "charts", "budget"] +tags: ["calc", "ods", "opendocument", "excel", "xlsx", "spreadsheet", "workbook", "tables", "charts", "budget"] triggers: + - "Calc" + - "ODS" + - "ods" + - "OpenDocument Spreadsheet" - "Excel" - "XLSX" - "xlsx" @@ -17,11 +21,11 @@ allowed_tools: - document_artifact --- -# Excel Workbooks +# Calc Spreadsheets -Use XLSX when the user asks for Excel, a spreadsheet, a workbook, tables that should remain editable as cells, formulas, or embedded spreadsheet charts. +Use ODS when the user asks for a spreadsheet, workbook, editable table, budget, formulas, or Calc file. Use XLSX only when the user asks for Excel/XLSX compatibility, provides an existing `.xlsx`, or needs embedded spreadsheet charts supported by the tool. -The canvas is user-owned UI. Creating or editing an XLSX must save the workbook and return action buttons, but must not open the canvas automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation. +The canvas is user-owned UI. Creating or editing an ODS or XLSX must save the workbook and return action buttons, but must not open the canvas automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation. ## Workflow @@ -33,13 +37,13 @@ Create a workbook: "tool_args": { "kind": "spreadsheet", "title": "Budget", - "format": "xlsx", + "format": "ods", "content": "Item,Amount\nPlatform,1000" } } ``` -For a blank workbook request, create a simple workbook with the requested title and `format: "xlsx"`; do not call `status` first unless the user asked for availability. +For a blank workbook request, create a simple workbook with the requested title and `format: "ods"`; do not call `status` first unless the user asked for availability. Edit cells: @@ -61,5 +65,5 @@ Practical rules: - `content` may be CSV, TSV, or a Markdown table; the tool writes real spreadsheet cells. - Use `rows` for whole-table replacement, `append_rows` for adding records, and `set_cells` for precise edits. -- Use `create_chart` with a chart object for embedded charts before reaching for code execution. +- Use `create_chart` with a chart object for embedded charts when working in XLSX compatibility format; otherwise use Calc/Desktop or code execution for chart workflows that ODS direct editing does not yet cover. - Do not open Calc/canvas automatically. The user can choose Open in canvas when they want the visible spreadsheet. diff --git a/plugins/_office/skills/linux-desktop/SKILL.md b/plugins/_office/skills/linux-desktop/SKILL.md index 10e66b236..0c9fb7e57 100644 --- a/plugins/_office/skills/linux-desktop/SKILL.md +++ b/plugins/_office/skills/linux-desktop/SKILL.md @@ -23,13 +23,14 @@ Use the Desktop as a full Linux GUI when the user explicitly needs a visual work ## Operating Model -1. Prefer `document_artifact` for creating, reading, and editing Markdown, DOCX, XLSX, and PPTX files. +1. Prefer `document_artifact` for creating, reading, and editing Markdown, ODT, ODS, ODP, DOCX, XLSX, and PPTX files. 2. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas. -3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation. -4. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead. -5. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`. -6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version. -7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success. +3. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit Microsoft compatibility. +4. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation. +5. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead. +6. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`. +7. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version. +8. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success. ## Control Flow @@ -95,9 +96,9 @@ Use these folders when the user asks to inspect or manipulate project files, ski ## App Map -- `LibreOffice Writer`: word processing and DOCX layout. -- `LibreOffice Calc`: spreadsheets, formulas, tables, charts. -- `LibreOffice Impress`: presentations and slide polish. +- `LibreOffice Writer`: ODT word processing and DOCX compatibility layout. +- `LibreOffice Calc`: ODS spreadsheets, formulas, tables, charts, and XLSX compatibility. +- `LibreOffice Impress`: ODP presentations, slide polish, and PPTX compatibility. - `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`). - `Terminal`: shell work inside the Agent Zero runtime. - `Settings`: XFCE system settings. diff --git a/plugins/_office/skills/markdown-documents/SKILL.md b/plugins/_office/skills/markdown-documents/SKILL.md index 34436fb22..a34ef3e3d 100644 --- a/plugins/_office/skills/markdown-documents/SKILL.md +++ b/plugins/_office/skills/markdown-documents/SKILL.md @@ -18,7 +18,7 @@ allowed_tools: # Markdown Documents -Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for DOCX, XLSX, PPTX, or another binary Office-compatible format. +Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for a binary office file. When they do ask for a LibreOffice office file, prefer ODF: ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only for explicit Microsoft compatibility. The canvas is user-owned UI. Create or update the saved Markdown artifact, but never open the canvas automatically. The document message will provide explicit Download and Open in canvas actions. @@ -45,7 +45,7 @@ Minimal create: Practical rules: -- Prefer Markdown over DOCX for writing unless Word compatibility is explicitly needed. +- Prefer Markdown over ODT/DOCX for writing unless a binary Writer/Word file is explicitly needed. - Keep agent-only cleanup simple: if the user asks to fix a typo, update the file and finish; do not force a canvas workflow. - Use clear headings and Markdown tables when they improve editability. - The custom Markdown editor is available when the user chooses Open in canvas. diff --git a/plugins/_office/skills/office-artifacts/SKILL.md b/plugins/_office/skills/office-artifacts/SKILL.md index 3336753ed..ab7de93b9 100644 --- a/plugins/_office/skills/office-artifacts/SKILL.md +++ b/plugins/_office/skills/office-artifacts/SKILL.md @@ -1,17 +1,21 @@ --- name: office-artifacts -description: Use when creating, opening, reading, or editing editable document canvas artifacts such as Markdown documents, DOCX documents, XLSX spreadsheets, and PPTX presentations with the document_artifact tool. -version: "1.3.0" +description: Use when creating, opening, reading, or editing editable document canvas artifacts such as Markdown documents, LibreOffice-native ODT/ODS/ODP files, and compatibility DOCX/XLSX/PPTX files with the document_artifact tool. +version: "1.4.0" author: "Agent Zero Core Team" -tags: ["documents", "markdown", "md", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations"] +tags: ["documents", "markdown", "md", "odt", "ods", "odp", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations", "libreoffice", "opendocument"] triggers: - "document canvas" - "markdown document" - "editable document" - "md" + - "odt" + - "ods" + - "odp" - "docx" - "xlsx" - "pptx" + - "writer" - "spreadsheet" - "presentation" allowed_tools: @@ -20,16 +24,16 @@ allowed_tools: # Document Artifacts -Use `document_artifact` for substantial deliverables that should remain editable in the custom document canvas. Markdown is the first-class document format and the default for writing, notes, reports, briefs, and drafts. Use DOCX, XLSX, or PPTX only when the user explicitly asks for that binary format, provides an existing file in that format, or needs a Word/Excel/PowerPoint-compatible artifact. +Use `document_artifact` for substantial deliverables that should remain editable in the custom document canvas or LibreOffice Desktop. Markdown remains the default for ordinary writing, notes, reports, briefs, and drafts when no binary office file is needed. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for Microsoft compatibility, provides an existing file in that format, or needs that compatibility surface. The canvas is user-owned UI. Creating, reading, or editing an artifact must save the file and update its state, but it must not open the canvas automatically if the user has not opened it. Tool results provide explicit Download and Open in canvas actions for the user. For format-specific work, prefer the matching skill when available: - `markdown-documents` for Markdown-first editable writing. -- `word-documents` for DOCX/Word-compatible files. -- `excel-workbooks` for XLSX/Excel-compatible spreadsheets. -- `presentation-decks` for PPTX/PowerPoint-compatible decks. +- `word-documents` for Writer/ODT files and DOCX compatibility files. +- `excel-workbooks` for Calc/ODS spreadsheets and XLSX compatibility workbooks. +- `presentation-decks` for Impress/ODP decks and PPTX compatibility decks. ## Workflow @@ -67,7 +71,7 @@ Read: } ``` -Edit text in a Markdown, DOCX, or PPTX file: +Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file: ```json { "tool_name": "document_artifact:edit", @@ -85,7 +89,7 @@ Set spreadsheet cells: { "tool_name": "document_artifact:edit", "tool_args": { - "path": "/a0/usr/workdir/documents/Budget.xlsx", + "path": "/a0/usr/workdir/documents/Budget.ods", "operation": "set_cells", "cells": { "Sheet1!B2": 12500, @@ -118,16 +122,17 @@ Create an embedded spreadsheet chart: ## Edit Operations -- MD and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`. -- XLSX: `set_cells`, `append_rows`, `set_rows`, `create_chart`, `replace_text`, `delete_text`. -- PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`. +- MD, ODT, and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`. +- ODS and XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`. +- XLSX only: `create_chart` for embedded spreadsheet charts. +- ODP and PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`. Arguments: - `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`. - `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`. - `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table. -- `create_chart` accepts `chart` as an object or JSON string. Supported XLSX chart types: `line`, `bar`, `column`, `pie`, `area`, `scatter`, `stock`, `ohlc`, `candlestick`. Use `data_range`, `categories`/`labels`, `position`, `title`, `width`, and `height`. For stock-style charts only, provide Open/High/Low/Close columns in that order, or rely on a sheet whose headers are `Date, Open, High, Low, Close`. +- `create_chart` accepts `chart` as an object or JSON string for XLSX compatibility workbooks. Supported XLSX chart types: `line`, `bar`, `column`, `pie`, `area`, `scatter`, `stock`, `ohlc`, `candlestick`. Use `data_range`, `categories`/`labels`, `position`, `title`, `width`, and `height`. For stock-style charts only, provide Open/High/Low/Close columns in that order, or rely on a sheet whose headers are `Date, Open, High, Low, Close`. - `slides` accepts `[{"title":"Slide title","bullets":["point"]}]`. Text slides can be separated with a line containing `---`. - `count` limits text replacements. @@ -136,10 +141,10 @@ Arguments: - Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have. - Use `read` before editing unless the current saved content is already known. - Do not create an artifact for tiny one-shot edits or answers the agent can finish cleanly in chat or by directly editing the file. -- For document-style requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface. +- For document-style writing requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface. +- For spreadsheet or presentation file requests with no Microsoft compatibility requirement, create ODS or ODP. - The Desktop runtime may be warmed during Agent Zero startup, but visible Desktop/canvas use remains opt-in. Treat LibreOffice GUI work as appropriate for explicit GUI requests, binary Office visual polish, or final layout inspection. - Never open the canvas automatically from a tool result. If the user has not opened the canvas, leave the saved artifact available through the normal UI affordance. -- Do not create ODT, ODS, or ODP in this pass; return a clear unsupported response if asked. - Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool. - Use `edit` for precise saved changes; use the visual document canvas for human/manual layout polish. - Direct edits update version history and refresh the canvas on edit/open results. diff --git a/plugins/_office/skills/presentation-decks/SKILL.md b/plugins/_office/skills/presentation-decks/SKILL.md index 542c3f5d4..075a599c5 100644 --- a/plugins/_office/skills/presentation-decks/SKILL.md +++ b/plugins/_office/skills/presentation-decks/SKILL.md @@ -1,10 +1,14 @@ --- name: presentation-decks -description: Use when creating, opening, or editing PowerPoint-compatible PPTX presentations, slide decks, talks, briefing decks, or LibreOffice Impress files. -version: "1.0.0" +description: Use when creating, opening, or editing LibreOffice Impress ODP presentations, or PPTX decks only when PowerPoint compatibility is explicitly required. +version: "1.1.0" author: "Agent Zero Core Team" -tags: ["presentation", "pptx", "powerpoint", "slides", "deck", "impress"] +tags: ["presentation", "odp", "opendocument", "pptx", "powerpoint", "slides", "deck", "impress"] triggers: + - "Impress" + - "ODP" + - "odp" + - "OpenDocument Presentation" - "PowerPoint" - "PPTX" - "pptx" @@ -17,11 +21,11 @@ allowed_tools: - document_artifact --- -# Presentation Decks +# Impress Presentations -Use PPTX when the user asks for PowerPoint, a presentation, slides, a deck, or an Impress-compatible artifact. +Use ODP when the user asks for a presentation, slides, a deck, or an Impress artifact. Use PPTX only when the user asks for PowerPoint/PPTX compatibility or provides an existing `.pptx`. -The canvas is user-owned UI. Creating or editing a PPTX must save the deck and return action buttons, but must not open the canvas automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation. +The canvas is user-owned UI. Creating or editing an ODP or PPTX must save the deck and return action buttons, but must not open the canvas automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation. ## Workflow @@ -33,7 +37,7 @@ Create: "tool_args": { "kind": "presentation", "title": "Roadmap", - "format": "pptx", + "format": "odp", "content": "Title Slide\n\n---\n\nNext Steps" } } @@ -59,5 +63,5 @@ Practical rules: - Use `slides` arrays for structured decks and `---` separators for simple text-to-slide creation. - Keep slide text concise and scannable. -- Do not create ODP in this workflow. +- Treat PPTX as a compatibility export/request, not the default presentation format. - Do not open Impress/canvas automatically. The user can choose Open in canvas when they want to inspect or polish the deck visually. diff --git a/plugins/_office/skills/word-documents/SKILL.md b/plugins/_office/skills/word-documents/SKILL.md index 2ee4c3505..8105d4b59 100644 --- a/plugins/_office/skills/word-documents/SKILL.md +++ b/plugins/_office/skills/word-documents/SKILL.md @@ -1,10 +1,14 @@ --- name: word-documents -description: Use when creating, opening, or editing Word-compatible DOCX documents, including requests for Word files, DOCX reports, memos, contracts, resumes, or documents that must work in Microsoft Word or LibreOffice Writer. -version: "1.0.0" +description: Use when creating, opening, or editing LibreOffice Writer ODT documents, or DOCX documents only when Microsoft Word compatibility is explicitly required. +version: "1.1.0" author: "Agent Zero Core Team" -tags: ["word", "docx", "writer", "documents", "reports", "memos", "contracts"] +tags: ["writer", "odt", "opendocument", "word", "docx", "documents", "reports", "memos", "contracts"] triggers: + - "Writer" + - "ODT" + - "odt" + - "OpenDocument Text" - "Word" - "DOCX" - "docx" @@ -15,11 +19,11 @@ allowed_tools: - document_artifact --- -# Word Documents +# Writer Documents -Use DOCX only when the user explicitly asks for Word/DOCX compatibility, provides an existing `.docx`, or needs a binary Office file. For ordinary writing with no binary requirement, use Markdown instead. +Use ODT for LibreOffice Writer documents. Use DOCX only when the user explicitly asks for Word/DOCX/Microsoft compatibility, provides an existing `.docx`, or needs that compatibility format. For ordinary writing with no binary requirement, use Markdown instead. -The canvas is user-owned UI. Creating or editing a DOCX must save the file and return action buttons, but must not open the canvas automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation. +The canvas is user-owned UI. Creating or editing an ODT or DOCX must save the file and return action buttons, but must not open the canvas automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation. ## Workflow @@ -31,7 +35,7 @@ Create: "tool_args": { "kind": "document", "title": "Board Memo", - "format": "docx", + "format": "odt", "content": "Memo body text." } } @@ -45,6 +49,6 @@ Edit: Practical rules: -- Keep DOCX content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested. -- Do not create ODT in this workflow. +- Keep Writer content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested. +- Treat DOCX as a compatibility export/request, not the default Writer format. - Do not say the document is open. Say it was created or updated, and rely on the Open in canvas action for user-controlled viewing. diff --git a/plugins/_office/tools/document_artifact.py b/plugins/_office/tools/document_artifact.py index 09470874d..e2b9de21a 100644 --- a/plugins/_office/tools/document_artifact.py +++ b/plugins/_office/tools/document_artifact.py @@ -42,6 +42,13 @@ class DocumentArtifact(Tool): path=path, context_id=self._context_id(), ) + if doc["extension"] in {"odt", "ods", "odp"}: + validation = libreoffice.validate_odf(doc["path"]) + if not validation.get("ok"): + return Response( + message=f"document_artifact create failed: {validation.get('error')}", + break_loop=False, + ) if doc["extension"] == "docx": validation = libreoffice.validate_docx(doc["path"]) if not validation.get("ok"): diff --git a/plugins/_office/webui/office-panel.html b/plugins/_office/webui/office-panel.html index 9c9b3dd2c..4e364fc68 100644 --- a/plugins/_office/webui/office-panel.html +++ b/plugins/_office/webui/office-panel.html @@ -21,15 +21,15 @@ article Markdown - - - diff --git a/plugins/_office/webui/office-store.js b/plugins/_office/webui/office-store.js index 3dcc9c139..010580020 100644 --- a/plugins/_office/webui/office-store.js +++ b/plugins/_office/webui/office-store.js @@ -360,7 +360,7 @@ const model = { }, async create(kind = "document", format = "") { - const fmt = String(format || (kind === "spreadsheet" ? "xlsx" : kind === "presentation" ? "pptx" : "md")).toLowerCase(); + const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase(); const title = this.defaultTitle(kind, fmt); await this.openSession({ action: "create", @@ -831,7 +831,7 @@ const model = { isBinaryOffice(tab = this.session) { const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); - return ext === "docx" || ext === "xlsx" || ext === "pptx"; + return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext); }, hasOfficialOffice(tab = this.session) { @@ -1872,6 +1872,7 @@ const model = { defaultTitle(kind, fmt) { const date = new Date().toISOString().slice(0, 10); if (fmt === "md") return `Document ${date}`; + if (fmt === "odt") return `Writer ${date}`; if (fmt === "docx") return `DOCX ${date}`; if (kind === "spreadsheet") return `Spreadsheet ${date}`; if (kind === "presentation") return `Presentation ${date}`; @@ -1891,9 +1892,9 @@ const model = { const ext = String(tab.extension || tab.document?.extension || "").toLowerCase(); if (this.isDesktopSession(tab)) return "desktop_windows"; if (ext === "md") return "article"; - if (ext === "docx") return "description"; - if (ext === "xlsx") return "table_chart"; - if (ext === "pptx") return "co_present"; + if (ext === "odt" || ext === "docx") return "description"; + if (ext === "ods" || ext === "xlsx") return "table_chart"; + if (ext === "odp" || ext === "pptx") return "co_present"; return "draft"; }, diff --git a/tests/test_office_canvas_setup.py b/tests/test_office_canvas_setup.py index 8776d6d45..156e3d7dd 100644 --- a/tests/test_office_canvas_setup.py +++ b/tests/test_office_canvas_setup.py @@ -119,6 +119,11 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f assert "setupTitle()" not in panel assert "Setup in progress" not in store assert "office-log" not in panel + assert "New Writer document" in panel + assert "DOCX" not in panel + assert "$store.office.create('document', 'odt')" in panel + assert "$store.office.create('spreadsheet', 'ods')" in panel + assert "$store.office.create('presentation', 'odp')" in panel def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session(): @@ -407,7 +412,8 @@ def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy(): PROJECT_ROOT / "plugins" / "_office" / "skills" / "presentation-decks" / "SKILL.md" ).read_text(encoding="utf-8") - assert "Markdown is the first-class document format" in office_skill + assert "ODF is first-class" in office_skill + assert "DOCX, XLSX, or PPTX only" in office_skill assert "custom document canvas" in office_skill assert "must not open the canvas automatically" in office_skill assert "Download and Open in canvas actions" in office_skill @@ -418,10 +424,11 @@ def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy(): assert "persistent Desktop runtime during initial startup" in desktop_skill assert '"format": "md"' in markdown_skill assert "never open the canvas automatically" in markdown_skill - assert '"format": "docx"' in word_skill + assert '"format": "odt"' in word_skill + assert "DOCX only" in word_skill assert "must not open the canvas automatically" in word_skill - assert '"format": "xlsx"' in excel_skill + assert '"format": "ods"' in excel_skill assert "For a blank workbook request" in excel_skill assert "must not open the canvas automatically" in excel_skill - assert '"format": "pptx"' in presentation_skill + assert '"format": "odp"' in presentation_skill assert "must not open the canvas automatically" in presentation_skill diff --git a/tests/test_office_document_affordance.py b/tests/test_office_document_affordance.py index 5f7b9914c..24234d05d 100644 --- a/tests/test_office_document_affordance.py +++ b/tests/test_office_document_affordance.py @@ -53,10 +53,32 @@ def test_explicit_spreadsheet_file_request_creates_spreadsheet_artifact(): assert decision is not None assert decision.kind == "spreadsheet" - assert decision.fmt == "xlsx" + assert decision.fmt == "ods" assert decision.reason == "explicit_handoff" +def test_explicit_excel_request_keeps_xlsx_compatibility_format(): + decision = document_affordance.decide_response_artifact( + "Build an editable Excel XLSX file for this budget.", + substantial_text(), + ) + + assert decision is not None + assert decision.kind == "spreadsheet" + assert decision.fmt == "xlsx" + + +def test_explicit_presentation_file_request_uses_odp_by_default(): + decision = document_affordance.decide_response_artifact( + "Create a presentation file for this roadmap.", + substantial_text(), + ) + + assert decision is not None + assert decision.kind == "presentation" + assert decision.fmt == "odp" + + def test_convert_into_document_creates_document_artifact(): decision = document_affordance.decide_response_artifact( "Convert this into a document.", @@ -111,9 +133,16 @@ def test_deliverable_request_with_artifact_shape_creates_document_artifact(): standalone_report(), ) - assert decision is not None - assert decision.kind == "document" - assert decision.reason == "document_intent" + assert decision is None + + +def test_meta_discussion_about_auto_md_files_does_not_create_artifact(): + decision = document_affordance.decide_response_artifact( + "Why are .md files being created automatically by the document affordance?", + standalone_report(), + ) + + assert decision is None def test_chat_only_instruction_blocks_even_explicit_file_request(): diff --git a/tests/test_office_document_store.py b/tests/test_office_document_store.py index fede6f8dd..55d1b37dd 100644 --- a/tests/test_office_document_store.py +++ b/tests/test_office_document_store.py @@ -72,6 +72,21 @@ def test_document_artifact_create_defaults_to_markdown(office_state): assert Path(doc["path"]).read_text(encoding="utf-8").startswith("# Research Note") +@pytest.mark.parametrize( + ("kind", "title", "fmt", "expected_name"), + [ + ("document", "real-chat-canvas-smoke.md", "md", "real-chat-canvas-smoke.md"), + ("document", "Board Memo.ODT", "odt", "Board Memo.odt"), + ("spreadsheet", "Budget.ods", "ods", "Budget.ods"), + ("presentation", "Roadmap.odp", "odp", "Roadmap.odp"), + ], +) +def test_create_document_does_not_duplicate_matching_extension(office_state, kind, title, fmt, expected_name): + doc = document_store.create_document(kind, title, fmt, content="Smoke") + + assert Path(doc["path"]).name == expected_name + + def test_explicit_docx_creates_valid_word_package(office_state): doc = document_store.create_document("document", "Board Memo", "docx", "A careful memo.") @@ -82,6 +97,23 @@ def test_explicit_docx_creates_valid_word_package(office_state): assert "word/document.xml" in archive.namelist() +def test_odf_formats_create_valid_libreoffice_packages(office_state): + writer = document_store.create_document("document", "Board Memo", "odt", "A careful memo.") + sheet = document_store.create_document("spreadsheet", "Budget", "ods", "Name,Amount\nPlatform,1000") + deck = document_store.create_document("presentation", "Roadmap", "odp", "Roadmap\nLaunch sequence") + + assert writer["extension"] == "odt" + assert sheet["extension"] == "ods" + assert deck["extension"] == "odp" + assert Path(writer["path"]).parent == office_state.documents + assert libreoffice.validate_odf(writer["path"])["ok"] is True + assert libreoffice.validate_odf(sheet["path"])["ok"] is True + assert libreoffice.validate_odf(deck["path"])["ok"] is True + assert artifact_editor.read_artifact(writer)["text"].startswith("Board Memo") + assert artifact_editor.read_artifact(sheet)["sheets"][0]["preview_rows"][1][1] == 1000 + assert artifact_editor.read_artifact(deck)["slides"][0]["title"] == "Roadmap" + + def test_blank_docx_includes_editable_body_paragraph(office_state): doc = document_store.create_document("document", "Blank Memo", "docx", "") with zipfile.ZipFile(doc["path"]) as archive: @@ -93,7 +125,57 @@ def test_blank_docx_includes_editable_body_paragraph(office_state): assert 'xml:space="preserve"> ' in xml -def test_xlsx_and_pptx_creation_and_direct_edits_still_work(office_state): +def test_odf_and_ooxml_creation_and_direct_edits_still_work(office_state): + odt = document_store.create_document("document", "Writer Memo", "odt", "Old phrase") + updated_odt, odt_payload = artifact_editor.edit_artifact( + odt, + operation="replace_text", + find="Old phrase", + replace="New phrase", + ) + odt_read = artifact_editor.read_artifact(updated_odt) + + assert odt_payload["changed"] is True + assert "New phrase" in odt_read["text"] + + ods = document_store.create_document( + "spreadsheet", + "Budget ODS", + "ods", + "Name,Amount\nPlatform,1000", + ) + updated_ods, ods_payload = artifact_editor.edit_artifact( + ods, + operation="set_cells", + cells={"Sheet1!B2": 12500, "Sheet1!A3": "Research", "Sheet1!B3": 4700}, + ) + ods_read = artifact_editor.read_artifact(updated_ods) + ods_rows = ods_read["sheets"][0]["preview_rows"] + + assert ods_payload["changed"] is True + assert ods_rows[1][1] == 12500 + assert ods_rows[2][0] == "Research" + + odp = document_store.create_document( + "presentation", + "Roadmap ODP", + "odp", + "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout", + ) + updated_odp, odp_payload = artifact_editor.edit_artifact( + odp, + operation="set_slides", + slides=[ + {"title": "Now", "bullets": ["Stabilize"]}, + {"title": "Next", "bullets": ["Polish"]}, + ], + ) + odp_read = artifact_editor.read_artifact(updated_odp) + + assert odp_payload["changed"] is True + assert odp_read["slide_count"] == 2 + assert odp_read["slides"][1]["title"] == "Next" + sheet = document_store.create_document( "spreadsheet", "Budget", @@ -142,7 +224,29 @@ def test_xlsx_and_pptx_creation_and_direct_edits_still_work(office_state): assert deck_read["slides"][1]["title"] == "Next" -def test_document_artifact_accepts_method_alias_for_xlsx_create(office_state, monkeypatch): +def test_ods_direct_edit_preserves_rows_beyond_preview_window_and_blank_separators(office_state): + rows = [["Row", "Value"], ["alpha", 1], [], ["separator-survives", 2]] + rows.extend([[f"item-{index}", index] for index in range(4, 96)]) + doc = document_store.create_document("spreadsheet", "Long ODS", "ods", "") + updated, payload = artifact_editor.edit_artifact( + doc, + operation="set_rows", + rows=rows, + ) + updated, payload = artifact_editor.edit_artifact( + updated, + operation="set_cells", + cells={"Sheet1!B90": 9000}, + ) + parsed = artifact_editor._ods_sheets_from_bytes(Path(updated["path"]).read_bytes(), max_rows=120, max_cols=10) + + assert payload["changed"] is True + assert parsed[0]["rows"][2] == [] + assert parsed[0]["rows"][3][0] == "separator-survives" + assert parsed[0]["rows"][89][1] == 9000 + + +def test_document_artifact_accepts_method_alias_for_ods_create(office_state, monkeypatch): tool_module = types.ModuleType("helpers.tool") class Response: @@ -185,30 +289,32 @@ def test_document_artifact_accepts_method_alias_for_xlsx_create(office_state, mo tool.execute( method="create", kind="document", - title="New Excel Workbook", - format="xlsx", + title="New Calc Workbook", + format="ods", content="Sheet1\n", ) ) payload = json.loads(response.message) assert payload["action"] == "create" - assert payload["document"]["extension"] == "xlsx" - assert Path(payload["document"]["path"]).name == "New Excel Workbook.xlsx" + assert payload["document"]["extension"] == "ods" + assert Path(payload["document"]["path"]).name == "New Calc Workbook.ods" assert Path(document_store._path_from_a0(payload["document"]["path"])).exists() -def test_odt_is_not_advertised_and_returns_clear_unsupported_response(office_state): +def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state): prompt = (PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.system.tool.document_artifact.md").read_text( encoding="utf-8", ) - assert "formats: md docx xlsx pptx" in prompt + assert "formats: md odt ods odp docx xlsx pptx" in prompt + assert "ODF is first-class for LibreOffice" in prompt + assert "DOCX/XLSX/PPTX are compatibility formats" in prompt assert "`method` is accepted as an alias for action" in prompt assert "they do not open the canvas automatically" in prompt assert "Download and Open in canvas message actions" in prompt - with pytest.raises(ValueError, match="ODT editing is not supported"): - document_store.create_document("document", "Skip ODT", "odt", "") + doc = document_store.create_document("document", "Use ODT", "odt", "") + assert doc["extension"] == "odt" def test_project_scoped_creation_uses_active_project_root(office_state, monkeypatch): @@ -224,15 +330,15 @@ def test_project_scoped_creation_uses_active_project_root(office_state, monkeypa monkeypatch.setattr(office_state.project_helpers, "get_project_folder", lambda name: str(project_root)) markdown = document_store.create_document("document", "Project Note", "md", "Scoped.", context_id="ctx-project") - docx = document_store.create_document("document", "Project Memo", "docx", "Scoped.", context_id="ctx-project") + odt = document_store.create_document("document", "Project Memo", "odt", "Scoped.", context_id="ctx-project") assert Path(markdown["path"]).parent == project_root - assert Path(docx["path"]).parent == project_root / "documents" + assert Path(odt["path"]).parent == project_root / "documents" def test_non_project_creation_uses_configured_workdir(office_state): markdown = document_store.create_document("document", "Workdir Note", content="Plain.") - spreadsheet = document_store.create_document("spreadsheet", "Workdir Sheet", "xlsx", "Name,Value") + spreadsheet = document_store.create_document("spreadsheet", "Workdir Sheet", "ods", "Name,Value") assert markdown["extension"] == "md" assert Path(markdown["path"]).parent == office_state.workdir @@ -324,9 +430,9 @@ def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeyp def test_markdown_session_rejects_office_binaries(office_state): manager = markdown_sessions.MarkdownSessionManager() - doc = document_store.create_document("document", "Desktop Only", "docx", "Native text") + doc = document_store.create_document("document", "Desktop Only", "odt", "Native text") - with pytest.raises(ValueError, match="Open .docx files in the Desktop"): + with pytest.raises(ValueError, match="Open .odt files in the Desktop"): manager.open(doc) @@ -439,12 +545,12 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_spawn_desktop_locked", fake_spawn) monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_open_document_locked", fake_open_document) - doc = document_store.create_document("spreadsheet", "Official Sheet", "xlsx", "Name,Value\nA,1") + doc = document_store.create_document("spreadsheet", "Official Sheet", "ods", "Name,Value\nA,1") manager = libreoffice_desktop.LibreOfficeDesktopManager() payload = manager.open(doc) assert payload["available"] is True - assert payload["extension"] == "xlsx" + assert payload["extension"] == "ods" assert payload["url"].startswith("/desktop/session/") registry = tmp_path / "desktop" / "profiles" / payload["session_id"] / "user" / "registrymodifications.xcu" registry_text = registry.read_text(encoding="utf-8") @@ -463,10 +569,13 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, settings_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Settings.desktop" terminal_text = terminal_launcher.read_text(encoding="utf-8") settings_text = settings_launcher.read_text(encoding="utf-8") + browser_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop" + browser_text = browser_launcher.read_text(encoding="utf-8") assert "xfce4-terminal" in terminal_text assert "org.xfce.terminal" in terminal_text assert not files_launcher.exists() - assert not (tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop").exists() + assert "open-url" in browser_text + assert "firefox" not in browser_text.lower() assert "xfce4-settings-manager" in settings_text assert "org.xfce.settings.manager" in settings_text link_targets = {