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 @@