From e64b9b2538941be906ebc68fc77299aea8f72502 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Sat, 2 May 2026 19:24:49 +0200 Subject: [PATCH] Remove legacy Office canvas affordances Route DOCX, spreadsheets, and presentations exclusively through the Xpra desktop LibreOffice session. Keep the custom canvas path focused on Markdown source editing, remove the old dashboard/preview/native LibreOfficeKit code, and update tests and runtime package declarations to match the new Office surface. --- docker/run/fs/ins/install_additional.sh | 4 - plugins/_office/api/office_session.py | 83 +-- plugins/_office/api/ws_office.py | 47 +- plugins/_office/helpers/artifact_editor.py | 4 +- plugins/_office/helpers/document_store.py | 207 +----- plugins/_office/helpers/libreoffice.py | 26 - .../_office/helpers/libreofficekit_native.py | 423 ----------- .../helpers/libreofficekit_sessions.py | 348 --------- .../_office/helpers/libreofficekit_worker.py | 214 ------ plugins/_office/helpers/markdown_sessions.py | 157 ++++ plugins/_office/hooks.py | 4 - plugins/_office/webui/office-panel.html | 678 +++--------------- plugins/_office/webui/office-store.js | 627 +--------------- tests/test_office_canvas_setup.py | 45 +- tests/test_office_document_store.py | 80 +-- 15 files changed, 353 insertions(+), 2594 deletions(-) delete mode 100644 plugins/_office/helpers/libreofficekit_native.py delete mode 100644 plugins/_office/helpers/libreofficekit_sessions.py delete mode 100644 plugins/_office/helpers/libreofficekit_worker.py create mode 100644 plugins/_office/helpers/markdown_sessions.py diff --git a/docker/run/fs/ins/install_additional.sh b/docker/run/fs/ins/install_additional.sh index 18f58b4e5..9c42f46f9 100644 --- a/docker/run/fs/ins/install_additional.sh +++ b/docker/run/fs/ins/install_additional.sh @@ -59,10 +59,6 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ libreoffice-calc \ libreoffice-impress \ libreoffice-gtk3 \ - libreofficekit-data \ - libreofficekit-dev \ - gir1.2-lokdocview-0.1 \ - python3-gi \ python3-uno \ xpra \ xpra-x11 \ diff --git a/plugins/_office/api/office_session.py b/plugins/_office/api/office_session.py index 495ea06e1..1092912bb 100644 --- a/plugins/_office/api/office_session.py +++ b/plugins/_office/api/office_session.py @@ -1,7 +1,7 @@ from __future__ import annotations from helpers.api import ApiHandler, Request -from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, libreofficekit_sessions +from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, markdown_sessions class OfficeSession(ApiHandler): @@ -13,24 +13,14 @@ class OfficeSession(ApiHandler): return libreoffice.collect_status() if action == "home": return {"ok": True, "path": document_store.default_open_path(context_id)} - if action == "recent": - return {"ok": True, "documents": _public_docs(document_store.get_recent_documents())} - if action == "open_documents": - return {"ok": True, "documents": _public_docs(document_store.get_open_documents(limit=24))} if action == "desktop": return self._desktop() - if action == "sync_open_sessions": - session_ids = input.get("session_ids") - if not isinstance(session_ids, list): - session_ids = [] - closed = document_store.sync_open_sessions(session_ids) - return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))} if action == "close": closed = document_store.close_session( session_id=str(input.get("session_id") or ""), file_id=str(input.get("file_id") or ""), ) - return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))} + return {"ok": True, "closed": closed} if action == "create": try: doc = document_store.create_document( @@ -65,32 +55,6 @@ class OfficeSession(ApiHandler): return self._desktop_save(input) if action == "desktop_sync": return self._desktop_sync(input) - if action == "desktop_close": - return self._desktop_close(input) - if action == "key": - return libreofficekit_sessions.get_manager().key( - str(input.get("session_id") or ""), - input.get("key") if isinstance(input.get("key"), dict) else {}, - ) - if action == "mouse": - return libreofficekit_sessions.get_manager().mouse( - str(input.get("session_id") or ""), - input.get("mouse") if isinstance(input.get("mouse"), dict) else {}, - ) - if action == "command": - return libreofficekit_sessions.get_manager().command( - str(input.get("session_id") or ""), - str(input.get("command") or ""), - arguments=input.get("arguments"), - notify=bool(input.get("notify", True)), - ) - if action == "command_values": - return libreofficekit_sessions.get_manager().command_values( - str(input.get("session_id") or ""), - str(input.get("command") or ""), - ) - if action == "export": - return self._export(input) return {"ok": False, "error": f"Unsupported office session action: {action}"} async def _open_document(self, doc: dict, input: dict, request: Request) -> dict: @@ -120,22 +84,21 @@ class OfficeSession(ApiHandler): "extension": doc["extension"], "path": doc["path"], "text": "", - "tiles": [], "document": _public_doc(doc), "version": document_store.item_version(doc), - "libreoffice": libreoffice.collect_status(), - "native": {"available": False, "mode": "desktop"}, "desktop": desktop, "store_session_id": store_session["session_id"], - "preview": document_store.build_preview(doc), "mode": mode, } - editor = libreofficekit_sessions.get_manager().open(doc, sid="") + try: + editor = markdown_sessions.get_manager().open(doc, sid="") + except ValueError as exc: + document_store.close_session(session_id=store_session["session_id"]) + return {"ok": False, "error": str(exc)} return { **editor, "store_session_id": store_session["session_id"], "session_id": editor["session_id"], - "preview": document_store.build_preview(doc), "mode": mode, } @@ -143,7 +106,7 @@ class OfficeSession(ApiHandler): session_id = str(input.get("session_id") or "").strip() if not session_id: return {"ok": False, "error": "session_id is required."} - return libreofficekit_sessions.get_manager().save(session_id, text=input.get("text")) + return markdown_sessions.get_manager().save(session_id, text=input.get("text")) def _desktop(self) -> dict: desktop = libreoffice_desktop.get_manager().ensure_system_desktop() @@ -162,7 +125,6 @@ class OfficeSession(ApiHandler): "extension": "desktop", "size": 0, "version": 0, - "preview": {}, } return { "ok": True, @@ -173,14 +135,10 @@ class OfficeSession(ApiHandler): "extension": "desktop", "path": desktop["path"], "text": "", - "tiles": [], "document": document, "version": 0, - "libreoffice": libreoffice.collect_status(), - "native": {"available": False, "mode": "desktop"}, "desktop": desktop, "store_session_id": "", - "preview": {}, "mode": "desktop", } @@ -199,34 +157,10 @@ class OfficeSession(ApiHandler): file_id=str(input.get("file_id") or ""), ) - def _desktop_close(self, input: dict) -> dict: - session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip() - if not session_id: - return {"ok": False, "error": "desktop_session_id is required."} - return libreoffice_desktop.get_manager().close( - session_id, - save_first=bool(input.get("save_first", True)), - ) - - def _export(self, input: dict) -> dict: - file_id = str(input.get("file_id") or "").strip() - path = str(input.get("path") or "").strip() - target_format = str(input.get("target_format") or input.get("format") or "pdf").lower().lstrip(".") - doc = document_store.get_document(file_id) if file_id else document_store.register_document(path) - result = libreoffice.convert_document(doc["path"], target_format) - if not result.get("ok"): - return result - return {"ok": True, "path": document_store.display_path(result["path"]), "source": _public_doc(doc)} - def _origin(self, request: Request) -> str: origin = request.headers.get("Origin") or request.host_url.rstrip("/") return origin.rstrip("/") - -def _public_docs(docs: list[dict]) -> list[dict]: - return [_public_doc(doc) for doc in docs] - - def _public_doc(doc: dict) -> dict: result = { "file_id": doc["file_id"], @@ -237,7 +171,6 @@ def _public_doc(doc: dict) -> dict: "size": doc["size"], "version": document_store.item_version(doc), "last_modified": doc["last_modified"], - "preview": doc.get("preview") or document_store.build_preview(doc), } for key in ("open_sessions", "last_opened_at", "session_expires_at"): if key in doc: diff --git a/plugins/_office/api/ws_office.py b/plugins/_office/api/ws_office.py index b0fa67547..322f0e631 100644 --- a/plugins/_office/api/ws_office.py +++ b/plugins/_office/api/ws_office.py @@ -4,12 +4,12 @@ from typing import Any from helpers.ws import WsHandler from helpers.ws_manager import WsResult -from plugins._office.helpers import document_store, libreofficekit_sessions +from plugins._office.helpers import document_store, markdown_sessions class WsOffice(WsHandler): async def on_disconnect(self, sid: str) -> None: - libreofficekit_sessions.get_manager().close_sid(sid) + markdown_sessions.get_manager().close_sid(sid) async def process(self, event: str, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult | None: if not event.startswith("office_"): @@ -18,53 +18,18 @@ class WsOffice(WsHandler): if event == "office_open": return self._open(data, sid) if event == "office_input": - return libreofficekit_sessions.get_manager().input( + return markdown_sessions.get_manager().input( str(data.get("session_id") or ""), text=data.get("text") if "text" in data else None, patch=data.get("patch") if isinstance(data.get("patch"), dict) else None, ) - if event == "office_key": - return libreofficekit_sessions.get_manager().key( - str(data.get("session_id") or ""), - data.get("key") if isinstance(data.get("key"), dict) else {}, - ) - if event == "office_mouse": - return libreofficekit_sessions.get_manager().mouse( - str(data.get("session_id") or ""), - data.get("mouse") if isinstance(data.get("mouse"), dict) else {}, - ) - if event == "office_cursor": - return libreofficekit_sessions.get_manager().cursor( - str(data.get("session_id") or ""), - data.get("cursor") if isinstance(data.get("cursor"), dict) else {}, - ) - if event == "office_selection": - return libreofficekit_sessions.get_manager().selection( - str(data.get("session_id") or ""), - data.get("selection") if isinstance(data.get("selection"), dict) else {}, - ) - if event == "office_invalidated_tiles": - session_id = str(data.get("session_id") or "") - return {"session_id": session_id, "tiles": libreofficekit_sessions.get_manager().tiles(session_id)} - if event == "office_command": - return libreofficekit_sessions.get_manager().command( - str(data.get("session_id") or ""), - str(data.get("command") or ""), - arguments=data.get("arguments"), - notify=bool(data.get("notify", True)), - ) - if event == "office_command_values": - return libreofficekit_sessions.get_manager().command_values( - str(data.get("session_id") or ""), - str(data.get("command") or ""), - ) if event == "office_save": - return libreofficekit_sessions.get_manager().save( + return markdown_sessions.get_manager().save( str(data.get("session_id") or ""), text=data.get("text") if "text" in data else None, ) if event == "office_close": - return libreofficekit_sessions.get_manager().close(str(data.get("session_id") or "")) + return markdown_sessions.get_manager().close(str(data.get("session_id") or "")) except FileNotFoundError as exc: return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId")) except Exception as exc: @@ -92,4 +57,4 @@ class WsOffice(WsHandler): content=str(data.get("content") or ""), context_id=context_id, ) - return libreofficekit_sessions.get_manager().open(doc, sid=sid) + return markdown_sessions.get_manager().open(doc, sid=sid) diff --git a/plugins/_office/helpers/artifact_editor.py b/plugins/_office/helpers/artifact_editor.py index 5c5fad456..7b6bf6e8f 100644 --- a/plugins/_office/helpers/artifact_editor.py +++ b/plugins/_office/helpers/artifact_editor.py @@ -110,9 +110,9 @@ def edit_artifact( def _refresh_open_editor_sessions(file_id: str) -> None: try: - from plugins._office.helpers import libreofficekit_sessions + from plugins._office.helpers import markdown_sessions - libreofficekit_sessions.get_manager().refresh_document(file_id) + markdown_sessions.get_manager().refresh_document(file_id) except Exception: # Direct artifact edits should never fail just because no canvas is open. return diff --git a/plugins/_office/helpers/document_store.py b/plugins/_office/helpers/document_store.py index 761e7c767..994361e2d 100644 --- a/plugins/_office/helpers/document_store.py +++ b/plugins/_office/helpers/document_store.py @@ -10,7 +10,6 @@ import sqlite3 import time import uuid import zipfile -import xml.etree.ElementTree as ET from contextlib import contextmanager from pathlib import Path from typing import Any @@ -23,16 +22,7 @@ from plugins._office.helpers import pptx_writer PLUGIN_NAME = "_office" SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"} DEFAULT_TTL_SECONDS = 8 * 60 * 60 -ORPHAN_SESSION_GRACE_SECONDS = 30 MAX_SAVE_BYTES = 512 * 1024 * 1024 -PREVIEW_LINE_LIMIT = 5 -PREVIEW_ROW_LIMIT = 5 -PREVIEW_COLUMN_LIMIT = 4 -PREVIEW_SLIDE_LIMIT = 2 - -W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" -A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" -X_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents")) DB_PATH = STATE_DIR / "documents.sqlite3" @@ -278,18 +268,6 @@ def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[s return _fetch(active) -def get_recent_documents(limit: int = 12, include_preview: bool = True) -> list[dict[str, Any]]: - with connect() as conn: - rows = conn.execute( - "SELECT * FROM documents ORDER BY updated_at DESC LIMIT ?", - (limit,), - ).fetchall() - documents = [dict(row) for row in rows] - if include_preview: - return [with_preview(document) for document in documents] - return documents - - def get_open_documents(limit: int = 6) -> list[dict[str, Any]]: with connect() as conn: _clear_expired_sessions(conn) @@ -309,7 +287,7 @@ def get_open_documents(limit: int = 6) -> list[dict[str, Any]]: """, (now(), limit), ).fetchall() - return [with_preview(dict(row)) for row in rows] + return [dict(row) for row in rows] def create_session( @@ -365,121 +343,11 @@ def close_session(session_id: str = "", file_id: str = "") -> int: return len(rows) -def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str]) -> int: - active_ids = {str(session_id).strip() for session_id in active_session_ids if str(session_id).strip()} - with connect() as conn: - _clear_expired_sessions(conn) - cutoff = now() - ORPHAN_SESSION_GRACE_SECONDS - if active_ids: - placeholders = ",".join("?" for _ in active_ids) - rows = conn.execute( - f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders}) AND created_at < ?", - (*tuple(active_ids), cutoff), - ).fetchall() - else: - rows = conn.execute("SELECT session_id, file_id FROM sessions WHERE created_at < ?", (cutoff,)).fetchall() - - if not rows: - return 0 - - session_ids = tuple(row["session_id"] for row in rows) - placeholders = ",".join("?" for _ in session_ids) - conn.execute(f"DELETE FROM sessions WHERE session_id IN ({placeholders})", session_ids) - for row in rows: - conn.execute( - "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", - (row["file_id"], "close_orphan_session", json.dumps({"session_id": row["session_id"]}), now()), - ) - return len(rows) - - -def with_preview(document: dict[str, Any]) -> dict[str, Any]: - return {**document, "preview": build_preview(document)} - - -def build_preview(document: dict[str, Any]) -> dict[str, Any]: - ext = str(document.get("extension") or "").lower() - path = Path(str(document.get("path") or "")) - preview = { - "available": False, - "kind": _preview_kind(ext), - "lines": [], - "rows": [], - "slides": [], - } - if not path.exists(): - return preview - try: - if ext == "md": - lines = _preview_markdown(path) - return {**preview, "available": bool(lines), "lines": lines} - if ext == "docx": - lines = _preview_docx(path) - return {**preview, "available": bool(lines), "lines": lines} - if ext == "xlsx": - rows = _preview_xlsx(path) - return {**preview, "available": bool(rows), "rows": rows} - if ext == "pptx": - slides = _preview_pptx(path) - return {**preview, "available": bool(slides), "slides": slides} - except Exception: - return preview - return preview - - -def _preview_kind(ext: str) -> str: - if ext == "xlsx": - return "spreadsheet" - if ext == "pptx": - return "presentation" - if ext in {"md", "docx"}: - return "document" - return "file" - - -def _qn(namespace: str, tag: str) -> str: - return f"{{{namespace}}}{tag}" - - -def _clean_preview_text(value: Any) -> str: - return re.sub(r"\s+", " ", str(value or "")).strip() - - -def _preview_markdown(path: Path) -> list[str]: - lines = [] - for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): - text = _clean_preview_text(raw.lstrip("#>-*0123456789.[]() ")) - if text: - lines.append(text) - if len(lines) >= PREVIEW_LINE_LIMIT: - break - return lines - - -def _preview_docx(path: Path) -> list[str]: - return _docx_paragraphs(path, limit=PREVIEW_LINE_LIMIT) - - -def _docx_paragraphs(path: Path, limit: int | None = None) -> list[str]: - with zipfile.ZipFile(path) as archive: - root = ET.fromstring(archive.read("word/document.xml")) - lines = [] - for paragraph in root.iter(_qn(W_NS, "p")): - text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(W_NS, "t")))) - if text: - lines.append(text) - if limit is not None and len(lines) >= limit: - break - return lines - - def read_text_for_editor(doc: dict[str, Any]) -> str: path = Path(doc["path"]) ext = str(doc["extension"]).lower() if ext == "md": return path.read_text(encoding="utf-8", errors="replace") - if ext == "docx": - return "\n\n".join(_docx_paragraphs(path)) raise ValueError(f"Text editing is not available for .{ext}.") @@ -487,79 +355,6 @@ def write_markdown(file_id: str, content: str) -> dict[str, Any]: return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="office:markdown") -def _preview_xlsx(path: Path) -> list[list[str]]: - with zipfile.ZipFile(path) as archive: - shared_strings = _xlsx_shared_strings(archive) - sheet_names = sorted( - (name for name in archive.namelist() if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)), - key=_natural_name_key, - ) - if not sheet_names: - return [] - root = ET.fromstring(archive.read(sheet_names[0])) - - rows = [] - for row in root.iter(_qn(X_NS, "row")): - cells = [] - for cell in list(row)[:PREVIEW_COLUMN_LIMIT]: - cells.append(_xlsx_cell_preview(cell, shared_strings)) - if any(cells): - rows.append(cells) - if len(rows) >= PREVIEW_ROW_LIMIT: - break - return rows - - -def _xlsx_shared_strings(archive: zipfile.ZipFile) -> list[str]: - try: - root = ET.fromstring(archive.read("xl/sharedStrings.xml")) - except KeyError: - return [] - strings = [] - for item in root.iter(_qn(X_NS, "si")): - strings.append(_clean_preview_text("".join(node.text or "" for node in item.iter(_qn(X_NS, "t"))))) - return strings - - -def _xlsx_cell_preview(cell: ET.Element, shared_strings: list[str]) -> str: - cell_type = cell.attrib.get("t", "") - if cell_type == "inlineStr": - return _clean_preview_text("".join(node.text or "" for node in cell.iter(_qn(X_NS, "t")))) - value_node = cell.find(_qn(X_NS, "v")) - value = _clean_preview_text(value_node.text if value_node is not None else "") - if cell_type == "s": - try: - return shared_strings[int(value)] - except (ValueError, IndexError): - return value - if cell_type == "b": - return "TRUE" if value == "1" else "FALSE" - return value - - -def _preview_pptx(path: Path) -> list[dict[str, Any]]: - with zipfile.ZipFile(path) as archive: - names = sorted( - (name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)), - key=_natural_name_key, - ) - slides = [] - for name in names[:PREVIEW_SLIDE_LIMIT]: - root = ET.fromstring(archive.read(name)) - lines = [] - for paragraph in root.iter(_qn(A_NS, "p")): - text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(A_NS, "t")))) - if text: - lines.append(text) - if lines: - slides.append({"title": lines[0], "lines": lines[1:PREVIEW_LINE_LIMIT]}) - return slides - - -def _natural_name_key(value: str) -> list[int | str]: - return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)] - - def replace_document_bytes( file_id: str, data: bytes, diff --git a/plugins/_office/helpers/libreoffice.py b/plugins/_office/helpers/libreoffice.py index 9dadcd743..4103f6a45 100644 --- a/plugins/_office/helpers/libreoffice.py +++ b/plugins/_office/helpers/libreoffice.py @@ -3,10 +3,8 @@ from __future__ import annotations import os import shutil import subprocess -import sys import tempfile import zipfile -from functools import lru_cache from pathlib import Path from typing import Any @@ -30,7 +28,6 @@ def collect_status() -> dict[str, Any]: "state": "healthy" if soffice else "missing", "healthy": bool(soffice), "soffice": soffice, - "libreofficekit": _libreofficekit_available(), "message": "LibreOffice is available." if soffice else "LibreOffice is not installed in this runtime.", } try: @@ -42,29 +39,6 @@ def collect_status() -> dict[str, Any]: return status -@lru_cache(maxsize=1) -def _libreofficekit_available() -> bool: - system_dist_packages = Path("/usr/lib/python3/dist-packages") - if system_dist_packages.exists() and str(system_dist_packages) not in sys.path: - sys.path.append(str(system_dist_packages)) - try: - import gi # type: ignore - - gi.require_version("LOKDocView", "0.1") - return True - except Exception: - return _lokdocview_typelib_available() - - -def _lokdocview_typelib_available() -> bool: - candidates = [ - Path("/usr/lib/x86_64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"), - Path("/usr/lib/aarch64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"), - Path("/usr/share/gir-1.0/LOKDocView-0.1.gir"), - ] - return any(path.exists() for path in candidates) - - def validate_docx(path: str | Path) -> dict[str, Any]: source = Path(path) if not source.exists(): diff --git a/plugins/_office/helpers/libreofficekit_native.py b/plugins/_office/helpers/libreofficekit_native.py deleted file mode 100644 index 804e75cc6..000000000 --- a/plugins/_office/helpers/libreofficekit_native.py +++ /dev/null @@ -1,423 +0,0 @@ -from __future__ import annotations - -import ctypes -import atexit -import base64 -import math -import json -import os -import shutil -import struct -import tempfile -import zlib -from pathlib import Path -from typing import Any - - -PROGRAM_DIR = Path(os.environ.get("A0_LIBREOFFICE_PROGRAM_DIR") or "/usr/lib/libreoffice/program") -MERGED_LIBRARY = PROGRAM_DIR / "libmergedlo.so" -DEFAULT_TILE_WIDTH_PX = 920 -MAX_TILE_HEIGHT_PX = 1800 -MAX_TILES = 12 - - -class LibreOfficeKitNativeError(RuntimeError): - pass - - -class _Office(ctypes.Structure): - pass - - -class _OfficeClass(ctypes.Structure): - pass - - -class _Document(ctypes.Structure): - pass - - -class _DocumentClass(ctypes.Structure): - pass - - -_OfficePtr = ctypes.POINTER(_Office) -_DocumentPtr = ctypes.POINTER(_Document) - -_DestroyOffice = ctypes.CFUNCTYPE(None, _OfficePtr) -_DocumentLoad = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p) -_GetError = ctypes.CFUNCTYPE(ctypes.c_char_p, _OfficePtr) -_DocumentLoadWithOptions = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p, ctypes.c_char_p) -_FreeError = ctypes.CFUNCTYPE(None, ctypes.c_char_p) - -_DestroyDocument = ctypes.CFUNCTYPE(None, _DocumentPtr) -_SaveAs = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p) -_GetDocumentType = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr) -_GetParts = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr) -_GetPartPageRectangles = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr) -_GetPart = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr) -_SetPart = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int) -_GetPartName = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_int) -_SetPartMode = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int) -_PaintTile = ctypes.CFUNCTYPE( - None, - _DocumentPtr, - ctypes.POINTER(ctypes.c_ubyte), - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, -) -_GetTileMode = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr) -_GetDocumentSize = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.POINTER(ctypes.c_long), ctypes.POINTER(ctypes.c_long)) -_InitializeForRendering = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p) -_RegisterDocumentCallback = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_void_p, ctypes.c_void_p) -_PostKeyEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int) -_PostMouseEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int) -_PostUnoCommand = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool) -_SetTextSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int) -_GetTextSelection = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)) -_Paste = ctypes.CFUNCTYPE(ctypes.c_bool, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t) -_SetGraphicSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int) -_ResetSelection = ctypes.CFUNCTYPE(None, _DocumentPtr) -_GetCommandValues = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p) - - -_Office._fields_ = [("pClass", ctypes.POINTER(_OfficeClass))] -_OfficeClass._fields_ = [ - ("nSize", ctypes.c_size_t), - ("destroy", _DestroyOffice), - ("documentLoad", _DocumentLoad), - ("getError", _GetError), - ("documentLoadWithOptions", _DocumentLoadWithOptions), - ("freeError", _FreeError), -] - -_Document._fields_ = [("pClass", ctypes.POINTER(_DocumentClass))] -_DocumentClass._fields_ = [ - ("nSize", ctypes.c_size_t), - ("destroy", _DestroyDocument), - ("saveAs", _SaveAs), - ("getDocumentType", _GetDocumentType), - ("getParts", _GetParts), - ("getPartPageRectangles", _GetPartPageRectangles), - ("getPart", _GetPart), - ("setPart", _SetPart), - ("getPartName", _GetPartName), - ("setPartMode", _SetPartMode), - ("paintTile", _PaintTile), - ("getTileMode", _GetTileMode), - ("getDocumentSize", _GetDocumentSize), - ("initializeForRendering", _InitializeForRendering), - ("registerCallback", _RegisterDocumentCallback), - ("postKeyEvent", _PostKeyEvent), - ("postMouseEvent", _PostMouseEvent), - ("postUnoCommand", _PostUnoCommand), - ("setTextSelection", _SetTextSelection), - ("getTextSelection", _GetTextSelection), - ("paste", _Paste), - ("setGraphicSelection", _SetGraphicSelection), - ("resetSelection", _ResetSelection), - ("getCommandValues", _GetCommandValues), -] - - -def available() -> bool: - return PROGRAM_DIR.exists() and MERGED_LIBRARY.exists() and os.environ.get("A0_OFFICE_DISABLE_NATIVE_LOK") != "1" - - -def open_document(path: str | Path) -> Any: - from plugins._office.helpers import libreofficekit_worker - - return libreofficekit_worker.open_document(path) - - -def open_document_in_process(path: str | Path) -> "NativeLokDocument": - return get_office().open_document(path) - - -def get_office() -> "NativeLokOffice": - global _office - try: - return _office - except NameError: - _office = NativeLokOffice() - atexit.register(_close_global_office) - return _office - - -def _close_global_office() -> None: - office = globals().get("_office") - if office: - try: - office.close() - except Exception: - pass - - -class NativeLokOffice: - def __init__(self) -> None: - if not available(): - raise LibreOfficeKitNativeError("LibreOfficeKit native library is not available.") - - os.environ.setdefault("HOME", "/tmp") - os.environ.setdefault("SAL_USE_VCLPLUGIN", "gen") - self._profile_dir = Path(tempfile.mkdtemp(prefix="a0-lok-profile-")) - self._library = ctypes.CDLL(str(MERGED_LIBRARY)) - self._library.libreofficekit_hook_2.argtypes = [ctypes.c_char_p, ctypes.c_char_p] - self._library.libreofficekit_hook_2.restype = _OfficePtr - profile_url = f"file://{self._profile_dir}".encode("utf-8") - self._office = self._library.libreofficekit_hook_2(str(PROGRAM_DIR).encode("utf-8"), profile_url) - if not self._office: - raise LibreOfficeKitNativeError("LibreOfficeKit hook returned no office instance.") - - def open_document(self, path: str | Path) -> "NativeLokDocument": - source = Path(path) - if not source.exists(): - raise FileNotFoundError(str(source)) - loaded = self._office.contents.pClass.contents.documentLoad(self._office, str(source).encode("utf-8")) - if not loaded: - raise LibreOfficeKitNativeError(self.error() or f"LibreOfficeKit could not load {source}.") - document = NativeLokDocument(loaded, source) - document.initialize_for_rendering() - return document - - def error(self) -> str: - get_error = self._office.contents.pClass.contents.getError - if not get_error: - return "" - value = get_error(self._office) - return _decode_c_string(value) - - def close(self) -> None: - office = getattr(self, "_office", None) - if office: - office.contents.pClass.contents.destroy(office) - self._office = None - - -class NativeLokDocument: - def __init__(self, document: _DocumentPtr, path: Path) -> None: - self._document = document - self.path = path - - @property - def _class(self) -> _DocumentClass: - return self._document.contents.pClass.contents - - def initialize_for_rendering(self) -> None: - self._class.initializeForRendering(self._document, None) - - def metadata(self) -> dict[str, Any]: - width = ctypes.c_long() - height = ctypes.c_long() - self._class.getDocumentSize(self._document, ctypes.byref(width), ctypes.byref(height)) - page_rectangles = self.page_rectangles(width.value, height.value) - return { - "available": True, - "doctype": self._class.getDocumentType(self._document), - "parts": self._class.getParts(self._document), - "part": self._class.getPart(self._document), - "tile_mode": self._class.getTileMode(self._document), - "width_twips": int(width.value), - "height_twips": int(height.value), - "page_rectangles": page_rectangles, - } - - def page_rectangles(self, width: int = 0, height: int = 0) -> list[dict[str, int]]: - raw = self._class.getPartPageRectangles(self._document) - rectangles = _parse_rectangles(_decode_c_string(raw)) - if rectangles: - return rectangles - if not width or not height: - width_ref = ctypes.c_long() - height_ref = ctypes.c_long() - self._class.getDocumentSize(self._document, ctypes.byref(width_ref), ctypes.byref(height_ref)) - width = int(width_ref.value) - height = int(height_ref.value) - return [{"x": 0, "y": 0, "width": int(width), "height": int(height)}] - - def render_tiles(self, pixel_width: int = DEFAULT_TILE_WIDTH_PX, max_tiles: int = MAX_TILES) -> list[dict[str, Any]]: - tile_mode = int(self._class.getTileMode(self._document)) - tiles: list[dict[str, Any]] = [] - for index, rectangle in enumerate(self.page_rectangles()[:max_tiles]): - width_twips = max(1, int(rectangle["width"])) - height_twips = max(1, int(rectangle["height"])) - width_px = max(320, min(int(pixel_width), 1400)) - height_px = max(320, min(MAX_TILE_HEIGHT_PX, math.ceil(width_px * (height_twips / width_twips)))) - buffer = (ctypes.c_ubyte * (width_px * height_px * 4))() - self._class.paintTile( - self._document, - buffer, - width_px, - height_px, - int(rectangle["x"]), - int(rectangle["y"]), - width_twips, - height_twips, - ) - png = _png_from_lok_buffer(buffer, width_px, height_px, tile_mode) - tiles.append({ - "index": index, - "kind": "lok-tile", - "width": width_px, - "height": height_px, - "twips": rectangle, - "image": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}", - }) - return tiles - - def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]: - normalized = normalize_uno_command(command) - payload = _encode_arguments(arguments) - self._class.postUnoCommand( - self._document, - normalized.encode("utf-8"), - payload, - bool(notify), - ) - return {"ok": True, "native": True, "command": normalized} - - def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]: - event_type = 1 if str(kind or "").lower() in {"up", "keyup"} else 0 - self._class.postKeyEvent(self._document, event_type, int(char_code or 0), int(key_code or 0)) - return {"ok": True, "native": True, "event": "key", "type": event_type} - - def type_text(self, text: str) -> dict[str, Any]: - inserted = 0 - for character in str(text or ""): - code = ord(character) - self._class.postKeyEvent(self._document, 0, code, code) - self._class.postKeyEvent(self._document, 1, code, code) - inserted += 1 - return {"ok": True, "native": True, "event": "text", "inserted": inserted} - - def post_mouse_event( - self, - kind: str, - x: int, - y: int, - count: int = 1, - buttons: int = 1, - modifier: int = 0, - ) -> dict[str, Any]: - mapping = {"down": 0, "mousedown": 0, "up": 1, "mouseup": 1, "move": 2, "mousemove": 2} - event_type = mapping.get(str(kind or "").lower(), 0) - self._class.postMouseEvent( - self._document, - event_type, - int(x), - int(y), - int(count or 1), - int(buttons or 1), - int(modifier or 0), - ) - return {"ok": True, "native": True, "event": "mouse", "type": event_type} - - def command_values(self, command: str) -> dict[str, Any]: - normalized = normalize_uno_command(command) - raw = self._class.getCommandValues(self._document, normalized.encode("utf-8")) - text = _decode_c_string(raw) - try: - parsed = json.loads(text) if text else {} - except json.JSONDecodeError: - parsed = {"raw": text} - return {"ok": True, "native": True, "command": normalized, "values": parsed} - - def save_as(self, path: str | Path | None = None, fmt: str | None = None) -> bool: - target = Path(path) if path else self.path - result = self._class.saveAs( - self._document, - str(target).encode("utf-8"), - fmt.encode("utf-8") if fmt else None, - None, - ) - return result != 0 - - def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes: - temp_dir = Path(tempfile.mkdtemp(prefix="a0-lok-save-")) - try: - target = temp_dir / f"document{suffix}" - if not self.save_as(target, fmt): - raise LibreOfficeKitNativeError("LibreOfficeKit saveAs failed.") - return target.read_bytes() - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - def close(self) -> None: - document = getattr(self, "_document", None) - if document: - self._class.destroy(document) - self._document = None - - -def normalize_uno_command(command: str) -> str: - value = str(command or "").strip() - if not value: - raise ValueError("UNO command is required.") - return value if value.startswith(".uno:") else f".uno:{value}" - - -def _encode_arguments(arguments: dict[str, Any] | str | None) -> bytes | None: - if arguments is None or arguments == "": - return None - if isinstance(arguments, str): - return arguments.encode("utf-8") - return json.dumps(arguments, separators=(",", ":")).encode("utf-8") - - -def _decode_c_string(value: bytes | int | None) -> str: - if not value: - return "" - if isinstance(value, bytes): - return value.decode("utf-8", errors="replace") - return ctypes.string_at(value).decode("utf-8", errors="replace") - - -def _parse_rectangles(payload: str) -> list[dict[str, int]]: - rectangles: list[dict[str, int]] = [] - for item in str(payload or "").split(";"): - numbers = [part.strip() for part in item.split(",")] - if len(numbers) < 4: - continue - try: - x, y, width, height = [int(float(value)) for value in numbers[:4]] - except ValueError: - continue - if width > 0 and height > 0: - rectangles.append({"x": x, "y": y, "width": width, "height": height}) - return rectangles - - -def _png_from_lok_buffer(buffer: Any, width: int, height: int, tile_mode: int) -> bytes: - raw = bytes(buffer) - rows = [] - stride = width * 4 - for y in range(height): - source = raw[y * stride:(y + 1) * stride] - if tile_mode == 1: - row = bytearray(stride) - for index in range(0, stride, 4): - blue = source[index] - green = source[index + 1] - red = source[index + 2] - alpha = source[index + 3] - row[index:index + 4] = bytes((red, green, blue, alpha)) - source = bytes(row) - rows.append(b"\x00" + source) - return _png_rgba(width, height, b"".join(rows)) - - -def _png_rgba(width: int, height: int, scanlines: bytes) -> bytes: - def chunk(kind: bytes, payload: bytes) -> bytes: - return ( - struct.pack(">I", len(payload)) - + kind - + payload - + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) - ) - - header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) - return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(scanlines, 6)) + chunk(b"IEND", b"") diff --git a/plugins/_office/helpers/libreofficekit_sessions.py b/plugins/_office/helpers/libreofficekit_sessions.py deleted file mode 100644 index d78800b3d..000000000 --- a/plugins/_office/helpers/libreofficekit_sessions.py +++ /dev/null @@ -1,348 +0,0 @@ -from __future__ import annotations - -import time -import uuid -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from plugins._office.helpers import document_store, libreoffice, libreofficekit_native - - -@dataclass -class EditorSession: - session_id: str - file_id: str - sid: str - extension: str - path: str - title: str - text: str = "" - native_document: Any | None = None - native_metadata: dict[str, Any] = field(default_factory=dict) - native_error: str = "" - cursor: dict[str, Any] = field(default_factory=dict) - selection: dict[str, Any] = field(default_factory=dict) - opened_at: float = field(default_factory=time.time) - updated_at: float = field(default_factory=time.time) - - -class LibreOfficeKitSessionManager: - """Small session facade for the right canvas. - - The public contract is shaped around LibreOfficeKit-style events: open, - input, cursor/selection, invalidated tiles, save, and close. When the native - Python LOK bridge is available the rendering path can be swapped underneath - this manager without changing the browser or tool APIs. - """ - - def __init__(self) -> None: - self._sessions: dict[str, EditorSession] = {} - - def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]: - ext = str(doc["extension"]).lower() - session_id = uuid.uuid4().hex - text = "" - if ext in {"md", "docx"}: - text = document_store.read_text_for_editor(doc) - native_document = None - native_metadata: dict[str, Any] = {} - native_error = "" - if ext == "docx": - try: - native_document = libreofficekit_native.open_document(doc["path"]) - native_metadata = native_document.metadata() - except Exception as exc: - native_error = str(exc) - - session = EditorSession( - session_id=session_id, - file_id=doc["file_id"], - sid=sid, - extension=ext, - path=doc["path"], - title=doc["basename"], - text=text, - native_document=native_document, - native_metadata=native_metadata, - native_error=native_error, - ) - self._sessions[session_id] = session - return self._payload(session, doc) - - def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]: - session = self._require(session_id) - if text is not None: - session.text = str(text) - elif patch: - session.text = _apply_text_patch(session.text, patch) - session.updated_at = time.time() - return {"ok": True, "session_id": session_id, "invalidated_tiles": self.tiles(session_id)} - - def key(self, session_id: str, key: dict[str, Any]) -> dict[str, Any]: - session = self._require(session_id) - native_document = session.native_document - if not native_document: - return {"ok": False, "native": False, "error": session.native_error or "Native key input is not available."} - - text = str(key.get("text") or "") - if text: - result = native_document.type_text(text) - else: - result = native_document.post_key_event( - str(key.get("type") or "down"), - char_code=int(key.get("char_code") or 0), - key_code=int(key.get("key_code") or 0), - ) - session.native_metadata = native_document.metadata() - session.updated_at = time.time() - return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)} - - def mouse(self, session_id: str, mouse: dict[str, Any]) -> dict[str, Any]: - session = self._require(session_id) - native_document = session.native_document - if not native_document: - return {"ok": False, "native": False, "error": session.native_error or "Native mouse input is not available."} - - result = native_document.post_mouse_event( - str(mouse.get("type") or "down"), - int(mouse.get("x") or 0), - int(mouse.get("y") or 0), - count=int(mouse.get("count") or 1), - buttons=int(mouse.get("buttons") or 1), - modifier=int(mouse.get("modifier") or 0), - ) - session.native_metadata = native_document.metadata() - session.updated_at = time.time() - return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)} - - def cursor(self, session_id: str, cursor: dict[str, Any]) -> dict[str, Any]: - session = self._require(session_id) - session.cursor = dict(cursor or {}) - session.updated_at = time.time() - return {"ok": True, "session_id": session_id, "cursor": session.cursor} - - def selection(self, session_id: str, selection: dict[str, Any]) -> dict[str, Any]: - session = self._require(session_id) - session.selection = dict(selection or {}) - session.updated_at = time.time() - return {"ok": True, "session_id": session_id, "selection": session.selection} - - def tiles(self, session_id: str) -> list[dict[str, Any]]: - session = self._require(session_id) - if session.extension == "docx" and session.native_document: - try: - return session.native_document.render_tiles() - except Exception as exc: - session.native_error = str(exc) - if session.extension == "docx": - return _docx_text_tiles(session.text) - if session.extension == "md": - return _markdown_text_tiles(session.text) - doc = document_store.get_document(session.file_id) - preview = document_store.build_preview(doc) - return [{"index": 0, "kind": preview.get("kind") or "file", "preview": preview}] - - def save(self, session_id: str, text: str | None = None) -> dict[str, Any]: - session = self._require(session_id) - if text is not None: - session.text = str(text) - - doc = document_store.get_document(session.file_id) - if session.extension == "md": - updated = document_store.write_markdown(session.file_id, session.text) - session.updated_at = time.time() - return {"ok": True, "document": _public_doc(updated), "tiles": self.tiles(session_id), "native": self._native_payload(session)} - - if session.extension == "docx": - from plugins._office.helpers import artifact_editor - - if session.native_document and text is None: - updated = document_store.replace_document_bytes( - session.file_id, - session.native_document.save_to_bytes(".docx", "docx"), - actor="libreofficekit:save", - invalidate_sessions=False, - ) - else: - updated, _payload = artifact_editor.edit_artifact( - doc, - operation="set_text", - content=session.text, - invalidate_sessions=False, - ) - validation = libreoffice.validate_docx(updated["path"]) - if not validation.get("ok"): - return {"ok": False, "error": validation.get("error") or "DOCX save verification failed."} - self._reopen_native_document(session, updated["path"]) - session.updated_at = time.time() - return { - "ok": True, - "document": _public_doc(updated), - "tiles": self.tiles(session_id), - "validation": validation, - "native": self._native_payload(session), - } - - return {"ok": False, "error": f"Canvas editing is not available for .{session.extension}."} - - def command(self, session_id: str, command: str, arguments: Any = None, notify: bool = True) -> dict[str, Any]: - session = self._require(session_id) - native_document = session.native_document - if not native_document: - return { - "ok": False, - "native": False, - "error": session.native_error or f"Native LibreOfficeKit commands are not available for .{session.extension}.", - } - result = native_document.post_uno_command(command, arguments=arguments, notify=notify) - session.native_metadata = native_document.metadata() - session.updated_at = time.time() - return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)} - - def command_values(self, session_id: str, command: str) -> dict[str, Any]: - session = self._require(session_id) - native_document = session.native_document - if not native_document: - return { - "ok": False, - "native": False, - "error": session.native_error or f"Native LibreOfficeKit command values are not available for .{session.extension}.", - } - return native_document.command_values(command) - - def refresh_document(self, file_id: str) -> dict[str, Any]: - normalized = str(file_id or "").strip() - if not normalized: - return {"ok": True, "refreshed": 0, "sessions": []} - try: - doc = document_store.get_document(normalized) - except Exception: - return {"ok": False, "refreshed": 0, "sessions": []} - - refreshed: list[str] = [] - for session in self._sessions.values(): - if session.file_id != normalized: - continue - if session.extension in {"md", "docx"}: - session.text = document_store.read_text_for_editor(doc) - if session.extension == "docx": - self._reopen_native_document(session, doc["path"]) - session.updated_at = time.time() - refreshed.append(session.session_id) - return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed} - - def close(self, session_id: str) -> dict[str, Any]: - session = self._sessions.pop(str(session_id or ""), None) - if not session: - return {"ok": True, "closed": 0} - self._close_native_document(session) - return {"ok": True, "closed": 1, "session_id": session_id} - - def close_sid(self, sid: str) -> int: - doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid] - for session_id in doomed: - session = self._sessions.pop(session_id, None) - if session: - self._close_native_document(session) - return len(doomed) - - def _payload(self, session: EditorSession, doc: dict[str, Any]) -> dict[str, Any]: - return { - "ok": True, - "session_id": session.session_id, - "file_id": session.file_id, - "title": session.title, - "extension": session.extension, - "path": session.path, - "text": session.text, - "tiles": self.tiles(session.session_id), - "document": _public_doc(doc), - "version": document_store.item_version(doc), - "libreoffice": libreoffice.collect_status(), - "native": self._native_payload(session), - } - - def _require(self, session_id: str) -> EditorSession: - normalized = str(session_id or "").strip() - session = self._sessions.get(normalized) - if not session: - raise FileNotFoundError(f"Editor session not found: {normalized}") - return session - - def _native_payload(self, session: EditorSession) -> dict[str, Any]: - if session.native_document: - return {"available": True, **session.native_metadata} - return {"available": False, "error": session.native_error} - - def _reopen_native_document(self, session: EditorSession, path: str) -> None: - self._close_native_document(session) - try: - session.native_document = libreofficekit_native.open_document(path) - session.native_metadata = session.native_document.metadata() - session.native_error = "" - except Exception as exc: - session.native_document = None - session.native_metadata = {} - session.native_error = str(exc) - - def _close_native_document(self, session: EditorSession) -> None: - native_document = session.native_document - if native_document: - try: - native_document.close() - except Exception: - pass - session.native_document = None - - -def get_manager() -> LibreOfficeKitSessionManager: - global _manager - try: - return _manager - except NameError: - _manager = LibreOfficeKitSessionManager() - return _manager - - -def _public_doc(doc: dict[str, Any]) -> dict[str, Any]: - return { - "file_id": doc["file_id"], - "path": document_store.display_path(doc["path"]), - "basename": doc["basename"], - "extension": doc["extension"], - "size": doc["size"], - "version": document_store.item_version(doc), - "last_modified": doc["last_modified"], - "exists": Path(doc["path"]).exists(), - } - - -def _apply_text_patch(text: str, patch: dict[str, Any]) -> str: - if "content" in patch: - return str(patch.get("content") or "") - start = int(patch.get("start") or 0) - end = int(patch.get("end") if patch.get("end") is not None else start) - replacement = str(patch.get("text") or "") - start = max(0, min(len(text), start)) - end = max(start, min(len(text), end)) - return text[:start] + replacement + text[end:] - - -def _markdown_text_tiles(text: str) -> list[dict[str, Any]]: - lines = [line for line in str(text or "").splitlines() if line.strip()] - return [{"index": 0, "kind": "markdown", "lines": lines[:36]}] - - -def _docx_text_tiles(text: str) -> list[dict[str, Any]]: - paragraphs = [line.strip() for line in str(text or "").splitlines() if line.strip()] - if not paragraphs: - paragraphs = [""] - pages = [] - for index in range(0, len(paragraphs), 18): - pages.append({ - "index": len(pages), - "kind": "docx", - "lines": paragraphs[index:index + 18], - }) - return pages diff --git a/plugins/_office/helpers/libreofficekit_worker.py b/plugins/_office/helpers/libreofficekit_worker.py deleted file mode 100644 index 60cb20966..000000000 --- a/plugins/_office/helpers/libreofficekit_worker.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -import base64 -import json -import os -import select -import subprocess -import sys -import threading -import time -from pathlib import Path -from typing import Any - - -REQUEST_TIMEOUT_SECONDS = 18 - - -def open_document(path: str | Path) -> "WorkerLokDocument": - return WorkerLokDocument(path) - - -class WorkerLokDocument: - def __init__(self, path: str | Path) -> None: - self.path = Path(path) - self._counter = 0 - self._lock = threading.RLock() - self._process = subprocess.Popen( - [sys.executable, "-m", "plugins._office.helpers.libreofficekit_worker", "--worker"], - cwd=str(Path(__file__).resolve().parents[3]), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - env={**os.environ, "PYTHONUNBUFFERED": "1", "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN", "gen")}, - ) - opened = self._request("open", {"path": str(self.path)}, timeout=REQUEST_TIMEOUT_SECONDS) - if not opened.get("ok"): - raise RuntimeError(opened.get("error") or "LibreOfficeKit worker could not open document.") - self._metadata = opened.get("metadata") or {} - - def metadata(self) -> dict[str, Any]: - response = self._request("metadata") - if response.get("metadata"): - self._metadata = response["metadata"] - return dict(self._metadata) - - def render_tiles(self, pixel_width: int = 920, max_tiles: int = 12) -> list[dict[str, Any]]: - response = self._request("tiles", {"pixel_width": pixel_width, "max_tiles": max_tiles}, timeout=REQUEST_TIMEOUT_SECONDS) - return response.get("tiles") or [] - - def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]: - return self._request("command", {"command": command, "arguments": arguments, "notify": notify}) - - def command_values(self, command: str) -> dict[str, Any]: - return self._request("command_values", {"command": command}) - - def post_mouse_event( - self, - kind: str, - x: int, - y: int, - count: int = 1, - buttons: int = 1, - modifier: int = 0, - ) -> dict[str, Any]: - return self._request("mouse", { - "type": kind, - "x": x, - "y": y, - "count": count, - "buttons": buttons, - "modifier": modifier, - }) - - def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]: - return self._request("key", { - "type": kind, - "char_code": char_code, - "key_code": key_code, - }) - - def type_text(self, text: str) -> dict[str, Any]: - return self._request("text", {"text": text}) - - def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes: - response = self._request("save", {"suffix": suffix, "format": fmt}, timeout=REQUEST_TIMEOUT_SECONDS) - data = response.get("bytes") or "" - return base64.b64decode(data.encode("ascii")) - - def close(self) -> None: - process = self._process - if process.poll() is not None: - return - try: - self._request("close", timeout=3) - process.wait(timeout=3) - except Exception: - process.kill() - process.wait(timeout=3) - - def _request(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]: - with self._lock: - return self._request_unlocked(action, payload=payload, timeout=timeout) - - def _request_unlocked(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]: - process = self._process - if process.poll() is not None: - stderr = process.stderr.read() if process.stderr else "" - raise RuntimeError(f"LibreOfficeKit worker exited with {process.returncode}: {stderr.strip()}") - self._counter += 1 - message = {"id": self._counter, "action": action, **(payload or {})} - assert process.stdin is not None - process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n") - process.stdin.flush() - assert process.stdout is not None - deadline = time.time() + timeout - while time.time() < deadline: - ready, _, _ = select.select([process.stdout], [], [], max(0.05, min(0.5, deadline - time.time()))) - if not ready: - continue - line = process.stdout.readline() - if not line: - break - try: - response = json.loads(line) - except json.JSONDecodeError: - continue - if response.get("id") == self._counter: - if response.get("ok") is False: - raise RuntimeError(response.get("error") or f"LibreOfficeKit worker {action} failed.") - return response - process.kill() - raise TimeoutError(f"LibreOfficeKit worker timed out during {action}.") - - -def _worker_loop() -> None: - from plugins._office.helpers import libreofficekit_native - - document = None - for line in sys.stdin: - try: - request = json.loads(line) - action = request.get("action") - if action == "open": - document = libreofficekit_native.open_document_in_process(request["path"]) - _respond(request, {"ok": True, "metadata": document.metadata()}) - elif not document: - _respond(request, {"ok": False, "error": "Document is not open."}) - elif action == "metadata": - _respond(request, {"ok": True, "metadata": document.metadata()}) - elif action == "tiles": - _respond(request, { - "ok": True, - "tiles": document.render_tiles( - pixel_width=int(request.get("pixel_width") or 920), - max_tiles=int(request.get("max_tiles") or 12), - ), - }) - elif action == "command": - result = document.post_uno_command( - str(request.get("command") or ""), - arguments=request.get("arguments"), - notify=bool(request.get("notify", True)), - ) - _respond(request, {"ok": True, **result, "metadata": document.metadata()}) - elif action == "command_values": - _respond(request, document.command_values(str(request.get("command") or ""))) - elif action == "mouse": - result = document.post_mouse_event( - str(request.get("type") or "down"), - int(request.get("x") or 0), - int(request.get("y") or 0), - count=int(request.get("count") or 1), - buttons=int(request.get("buttons") or 1), - modifier=int(request.get("modifier") or 0), - ) - _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()}) - elif action == "key": - result = document.post_key_event( - str(request.get("type") or "down"), - char_code=int(request.get("char_code") or 0), - key_code=int(request.get("key_code") or 0), - ) - _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()}) - elif action == "text": - result = document.type_text(str(request.get("text") or "")) - _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()}) - elif action == "save": - data = document.save_to_bytes( - suffix=str(request.get("suffix") or ".docx"), - fmt=request.get("format") or "docx", - ) - _respond(request, {"ok": True, "bytes": base64.b64encode(data).decode("ascii"), "metadata": document.metadata()}) - elif action == "close": - if document: - document.close() - _respond(request, {"ok": True, "closed": True}) - sys.stdout.flush() - os._exit(0) - else: - _respond(request, {"ok": False, "error": f"Unknown worker action: {action}"}) - except Exception as exc: - _respond(json.loads(line) if line.strip().startswith("{") else {}, {"ok": False, "error": str(exc)}) - os._exit(0) - - -def _respond(request: dict[str, Any], response: dict[str, Any]) -> None: - sys.stdout.write(json.dumps({"id": request.get("id"), **response}, separators=(",", ":")) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__" and "--worker" in sys.argv: - _worker_loop() diff --git a/plugins/_office/helpers/markdown_sessions.py b/plugins/_office/helpers/markdown_sessions.py new file mode 100644 index 000000000..7cc1efa46 --- /dev/null +++ b/plugins/_office/helpers/markdown_sessions.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from plugins._office.helpers import document_store + + +@dataclass +class MarkdownSession: + session_id: str + file_id: str + sid: str + extension: str + path: str + title: str + text: str = "" + opened_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + +class MarkdownSessionManager: + """Owns source-editor sessions for Markdown documents.""" + + def __init__(self) -> None: + self._sessions: dict[str, MarkdownSession] = {} + + def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]: + ext = str(doc["extension"]).lower() + if ext != "md": + raise ValueError(f"Canvas editing is only available for Markdown. Open .{ext} files in the Desktop.") + + session = MarkdownSession( + session_id=uuid.uuid4().hex, + file_id=doc["file_id"], + sid=sid, + extension=ext, + path=doc["path"], + title=doc["basename"], + text=document_store.read_text_for_editor(doc), + ) + self._sessions[session.session_id] = session + return self._payload(session, doc) + + def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]: + session = self._require(session_id) + if text is not None: + session.text = str(text) + elif patch: + session.text = _apply_text_patch(session.text, patch) + session.updated_at = time.time() + return {"ok": True, "session_id": session.session_id} + + def save(self, session_id: str, text: str | None = None) -> dict[str, Any]: + session = self._require(session_id) + if text is not None: + session.text = str(text) + + updated = document_store.write_markdown(session.file_id, session.text) + session.updated_at = time.time() + session.path = updated["path"] + session.title = updated["basename"] + return { + "ok": True, + "document": _public_doc(updated), + "version": document_store.item_version(updated), + } + + def refresh_document(self, file_id: str) -> dict[str, Any]: + normalized = str(file_id or "").strip() + if not normalized: + return {"ok": True, "refreshed": 0, "sessions": []} + try: + doc = document_store.get_document(normalized) + except Exception: + return {"ok": False, "refreshed": 0, "sessions": []} + if str(doc.get("extension") or "").lower() != "md": + return {"ok": True, "refreshed": 0, "sessions": []} + + refreshed: list[str] = [] + for session in self._sessions.values(): + if session.file_id != normalized: + continue + session.text = document_store.read_text_for_editor(doc) + session.path = doc["path"] + session.title = doc["basename"] + session.updated_at = time.time() + refreshed.append(session.session_id) + return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed} + + def close(self, session_id: str) -> dict[str, Any]: + session = self._sessions.pop(str(session_id or ""), None) + if not session: + return {"ok": True, "closed": 0} + return {"ok": True, "closed": 1, "session_id": session_id} + + def close_sid(self, sid: str) -> int: + doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid] + for session_id in doomed: + self._sessions.pop(session_id, None) + return len(doomed) + + def _payload(self, session: MarkdownSession, doc: dict[str, Any]) -> dict[str, Any]: + return { + "ok": True, + "session_id": session.session_id, + "file_id": session.file_id, + "title": session.title, + "extension": session.extension, + "path": session.path, + "text": session.text, + "document": _public_doc(doc), + "version": document_store.item_version(doc), + } + + def _require(self, session_id: str) -> MarkdownSession: + normalized = str(session_id or "").strip() + session = self._sessions.get(normalized) + if not session: + raise FileNotFoundError(f"Editor session not found: {normalized}") + return session + + +def get_manager() -> MarkdownSessionManager: + global _manager + try: + return _manager + except NameError: + _manager = MarkdownSessionManager() + return _manager + + +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]: + return { + "file_id": doc["file_id"], + "path": document_store.display_path(doc["path"]), + "basename": doc["basename"], + "extension": doc["extension"], + "size": doc["size"], + "version": document_store.item_version(doc), + "last_modified": doc["last_modified"], + "exists": Path(doc["path"]).exists(), + } + + +def _apply_text_patch(text: str, patch: dict[str, Any]) -> str: + if "content" in patch: + return str(patch.get("content") or "") + start = int(patch.get("start") or 0) + end = int(patch.get("end") if patch.get("end") is not None else start) + replacement = str(patch.get("text") or "") + start = max(0, min(len(text), start)) + end = max(start, min(len(text), end)) + return text[:start] + replacement + text[end:] diff --git a/plugins/_office/hooks.py b/plugins/_office/hooks.py index 8e58fbb53..c751869d5 100644 --- a/plugins/_office/hooks.py +++ b/plugins/_office/hooks.py @@ -43,10 +43,6 @@ RUNTIME_PACKAGES = ( "libreoffice-calc", "libreoffice-impress", "libreoffice-gtk3", - "libreofficekit-data", - "libreofficekit-dev", - "gir1.2-lokdocview-0.1", - "python3-gi", "python3-uno", "xpra", "xpra-x11", diff --git a/plugins/_office/webui/office-panel.html b/plugins/_office/webui/office-panel.html index 1c02e972c..90d51b63f 100644 --- a/plugins/_office/webui/office-panel.html +++ b/plugins/_office/webui/office-panel.html @@ -9,99 +9,71 @@