mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 16:31:30 +00:00
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.
This commit is contained in:
parent
739c0a18a3
commit
e64b9b2538
15 changed files with 353 additions and 2594 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"")
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
157
plugins/_office/helpers/markdown_sessions.py
Normal file
157
plugins/_office/helpers/markdown_sessions.py
Normal file
|
|
@ -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:]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -9,99 +9,71 @@
|
|||
<template x-if="$store.office">
|
||||
<div class="office-shell">
|
||||
<div class="office-toolbar">
|
||||
<div class="office-tool-group">
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="Open" @click="$store.office.openPrompt()">
|
||||
<span class="material-symbols-outlined">folder_open</span>
|
||||
<span class="office-button-label">Open</span>
|
||||
</button>
|
||||
<div class="office-toolbar-row is-primary">
|
||||
<div class="office-tool-group">
|
||||
<button type="button" class="office-icon-button" title="Open" aria-label="Open" @click="$store.office.openFileBrowser()">
|
||||
<span class="material-symbols-outlined">folder_open</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="office-tool-group">
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New Markdown" @click="$store.office.create('document', 'md')">
|
||||
<span class="material-symbols-outlined">article</span>
|
||||
<span class="office-button-label">Markdown</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<span class="office-button-label">DOCX</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
|
||||
<span class="material-symbols-outlined">table_chart</span>
|
||||
<span class="office-button-label">Spreadsheet</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
|
||||
<span class="material-symbols-outlined">co_present</span>
|
||||
<span class="office-button-label">Presentation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="office-toolbar-spacer"></span>
|
||||
|
||||
<div class="office-tool-group office-tool-actions" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
|
||||
<button type="button" class="office-icon-button" title="Save" aria-label="Save" :class="{ 'is-primary': $store.office.dirty }" :disabled="$store.office.saving" @click="$store.office.save()">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="office-tool-group">
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New Markdown" @click="$store.office.create('document', 'md')">
|
||||
<span class="material-symbols-outlined">article</span>
|
||||
<span class="office-button-label">Markdown</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<span class="office-button-label">DOCX</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
|
||||
<span class="material-symbols-outlined">table_chart</span>
|
||||
<span class="office-button-label">Spreadsheet</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
|
||||
<span class="material-symbols-outlined">co_present</span>
|
||||
<span class="office-button-label">Presentation</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="office-toolbar-row is-editor" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
|
||||
<div class="office-tool-group">
|
||||
<button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
|
||||
<span class="material-symbols-outlined">undo</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
|
||||
<span class="material-symbols-outlined">redo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
|
||||
<button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
|
||||
<span class="material-symbols-outlined">undo</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
|
||||
<span class="material-symbols-outlined">redo</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="office-tool-group">
|
||||
<button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
|
||||
<span class="material-symbols-outlined">format_bold</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
|
||||
<span class="material-symbols-outlined">format_italic</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
|
||||
<button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
|
||||
<span class="material-symbols-outlined">format_bold</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
|
||||
<span class="material-symbols-outlined">format_italic</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Underline" aria-label="Underline" @click="$store.office.format('underline')">
|
||||
<span class="material-symbols-outlined">format_underlined</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
|
||||
<span class="material-symbols-outlined">format_list_bulleted</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
|
||||
<span class="material-symbols-outlined">format_list_numbered</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
|
||||
<span class="material-symbols-outlined">table</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Align left" aria-label="Align left" @click="$store.office.format('alignLeft')">
|
||||
<span class="material-symbols-outlined">format_align_left</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Align center" aria-label="Align center" @click="$store.office.format('alignCenter')">
|
||||
<span class="material-symbols-outlined">format_align_center</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Align right" aria-label="Align right" @click="$store.office.format('alignRight')">
|
||||
<span class="material-symbols-outlined">format_align_right</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Source" aria-label="Source" :class="{ 'is-active': $store.office.sourceMode }" x-show="$store.office.isMarkdown()" @click="$store.office.toggleSource()">
|
||||
<span class="material-symbols-outlined">code</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice()" style="display: none;">
|
||||
<button type="button" class="office-icon-button" title="Zoom out" aria-label="Zoom out" @click="$store.office.zoomOut()">
|
||||
<span class="material-symbols-outlined">zoom_out</span>
|
||||
</button>
|
||||
<span class="office-zoom" x-text="$store.office.zoomLabel()"></span>
|
||||
<button type="button" class="office-icon-button" title="Zoom in" aria-label="Zoom in" @click="$store.office.zoomIn()">
|
||||
<span class="material-symbols-outlined">zoom_in</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="office-toolbar-spacer"></span>
|
||||
|
||||
<div class="office-tool-group" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="Export PDF" :disabled="$store.office.loading" @click="$store.office.exportPdf()">
|
||||
<span class="material-symbols-outlined">picture_as_pdf</span>
|
||||
<span class="office-button-label">Export PDF</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="Save" :class="{ 'is-primary': $store.office.dirty }" :disabled="$store.office.saving" @click="$store.office.save()">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
|
||||
<span class="office-button-label">Save</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button office-command-button" aria-label="Close" @click="$confirmClick($event, () => $store.office.closeFile())">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span class="office-button-label">Close</span>
|
||||
</button>
|
||||
<div class="office-tool-group">
|
||||
<button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
|
||||
<span class="material-symbols-outlined">format_list_bulleted</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
|
||||
<span class="material-symbols-outlined">format_list_numbered</span>
|
||||
</button>
|
||||
<button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
|
||||
<span class="material-symbols-outlined">table</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -124,100 +96,9 @@
|
|||
<span x-text="$store.office.error || $store.office.message || 'Working'"></span>
|
||||
</div>
|
||||
|
||||
<div class="office-body">
|
||||
<div class="office-start" x-show="!$store.office.session" style="display: none;">
|
||||
<section class="office-dashboard-section" aria-label="Create">
|
||||
<div class="office-dashboard-heading">New</div>
|
||||
<div class="office-template-grid">
|
||||
<button type="button" class="office-create-tile is-markdown" @click="$store.office.create('document', 'md')">
|
||||
<span class="material-symbols-outlined">article</span>
|
||||
<strong>Markdown</strong>
|
||||
<small>.md</small>
|
||||
</button>
|
||||
<button type="button" class="office-create-tile is-docx" @click="$store.office.create('document', 'docx')">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<strong>DOCX</strong>
|
||||
<small>.docx</small>
|
||||
</button>
|
||||
<button type="button" class="office-create-tile is-sheet" @click="$store.office.create('spreadsheet', 'xlsx')">
|
||||
<span class="material-symbols-outlined">table_chart</span>
|
||||
<strong>Sheet</strong>
|
||||
<small>.xlsx</small>
|
||||
</button>
|
||||
<button type="button" class="office-create-tile is-deck" @click="$store.office.create('presentation', 'pptx')">
|
||||
<span class="material-symbols-outlined">co_present</span>
|
||||
<strong>Deck</strong>
|
||||
<small>.pptx</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open" style="display: none;">
|
||||
<div class="office-dashboard-heading">Open</div>
|
||||
<div class="office-card-grid">
|
||||
<template x-for="doc in $store.office.openCards()" :key="doc.tab_id || doc.file_id || doc.path">
|
||||
<button type="button" class="office-document-card is-open" :title="doc.path" @click="$store.office.selectTab(doc.tab_id)">
|
||||
<span class="office-card-badge">Open</span>
|
||||
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
|
||||
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
|
||||
<div class="office-sheet-preview">
|
||||
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
|
||||
<div class="office-sheet-row">
|
||||
<template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
|
||||
<div class="office-page-preview">
|
||||
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.office.hasPreview(doc)">
|
||||
<div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
|
||||
<small x-text="$store.office.dashboardMeta(doc)"></small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent" style="display: none;">
|
||||
<div class="office-dashboard-heading">Recent</div>
|
||||
<div class="office-card-grid">
|
||||
<template x-for="doc in $store.office.recentCards()" :key="doc.file_id || doc.path">
|
||||
<button type="button" class="office-document-card" :title="doc.path" @click="$store.office.openPath(doc.path)">
|
||||
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
|
||||
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
|
||||
<div class="office-sheet-preview">
|
||||
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
|
||||
<div class="office-sheet-row">
|
||||
<template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
|
||||
<div class="office-page-preview">
|
||||
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.office.hasPreview(doc)">
|
||||
<div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
|
||||
<small x-text="$store.office.dashboardMeta(doc)"></small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
|
||||
<div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice() }" :style="`--office-zoom: ${$store.office.zoom}`" @click.self="$store.office.focusEditor()">
|
||||
<div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
|
||||
<div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
|
||||
<div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
|
||||
<template x-if="$store.office.hasOfficialOffice()">
|
||||
<div class="office-desktop-wrap">
|
||||
<iframe
|
||||
|
|
@ -236,7 +117,7 @@
|
|||
class="office-source-editor"
|
||||
data-office-source
|
||||
aria-label="Markdown source"
|
||||
x-show="$store.office.isMarkdown() && $store.office.sourceMode"
|
||||
x-show="$store.office.isMarkdown()"
|
||||
x-model="$store.office.editorText"
|
||||
@input="$store.office.onSourceInput()"
|
||||
@blur="$store.office.flushInput()"
|
||||
|
|
@ -244,64 +125,6 @@
|
|||
style="display: none;"
|
||||
></textarea>
|
||||
|
||||
<article
|
||||
class="office-rich-editor"
|
||||
x-show="$store.office.isMarkdown() && !$store.office.sourceMode"
|
||||
x-init="$store.office.bindEditorElement($el, 'markdown')"
|
||||
contenteditable="true"
|
||||
tabindex="0"
|
||||
role="textbox"
|
||||
aria-label="Markdown document"
|
||||
spellcheck="true"
|
||||
@input="$store.office.onRichInput($el)"
|
||||
@blur="$store.office.flushInput()"
|
||||
style="display: none;"
|
||||
></article>
|
||||
|
||||
<div
|
||||
class="office-docx-stage"
|
||||
x-show="$store.office.isDocx() && !$store.office.hasOfficialOffice()"
|
||||
style="display: none;"
|
||||
>
|
||||
<div
|
||||
class="office-docx-pages"
|
||||
:class="{ 'is-native': $store.office.hasNativeDocxTiles() }"
|
||||
x-init="$store.office.bindEditorElement($el, 'docx')"
|
||||
:contenteditable="$store.office.hasNativeDocxTiles() ? 'false' : 'true'"
|
||||
tabindex="0"
|
||||
role="textbox"
|
||||
aria-label="DOCX document"
|
||||
spellcheck="true"
|
||||
@click="$store.office.onNativeDocxClick($event)"
|
||||
@keydown="$store.office.onNativeDocxKeydown($event)"
|
||||
@input="$store.office.onDocxInput($el)"
|
||||
@blur="$store.office.flushInput()"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="office-preview-editor" x-show="$store.office.isPreviewOnly() && !$store.office.hasOfficialOffice()" style="display: none;">
|
||||
<div class="office-card-preview is-large" :class="`is-${$store.office.previewKind($store.office.session || {})}`">
|
||||
<template x-if="$store.office.previewKind($store.office.session || {}) === 'spreadsheet' && $store.office.hasPreview($store.office.session || {})">
|
||||
<div class="office-sheet-preview">
|
||||
<template x-for="(row, rowIndex) in $store.office.previewRows($store.office.session || {})" :key="rowIndex">
|
||||
<div class="office-sheet-row">
|
||||
<template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.office.previewKind($store.office.session || {}) === 'presentation'">
|
||||
<div class="office-slide-preview">
|
||||
<template x-for="(slide, index) in $store.office.previewSlides($store.office.session || {})" :key="index">
|
||||
<div class="office-slide-line">
|
||||
<strong x-text="slide.title"></strong>
|
||||
<span x-text="(slide.lines || []).join(' / ')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -429,36 +252,47 @@
|
|||
|
||||
.office-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
min-height: 58px;
|
||||
padding: 9px 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 8px 10px;
|
||||
overflow: visible;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
|
||||
background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
|
||||
}
|
||||
|
||||
.office-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.office-toolbar-row.is-editor {
|
||||
gap: 6px 8px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.office-tool-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.office-toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8px;
|
||||
.office-tool-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.office-toolbar-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
margin: 0 4px;
|
||||
background: color-mix(in srgb, var(--color-border), transparent 15%);
|
||||
.office-toolbar-spacer {
|
||||
flex: 1 1 80px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.office-icon-button,
|
||||
|
|
@ -523,20 +357,11 @@
|
|||
}
|
||||
|
||||
.office-icon-button .material-symbols-outlined,
|
||||
.office-tab-icon,
|
||||
.office-create-tile .material-symbols-outlined {
|
||||
.office-tab-icon {
|
||||
font-size: 21px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.office-zoom {
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.office-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
|
@ -636,170 +461,10 @@
|
|||
linear-gradient(90deg, rgba(44, 123, 229, 0.05), transparent 38%),
|
||||
linear-gradient(180deg, rgba(44, 165, 141, 0.04), transparent 46%),
|
||||
#eef2f7;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.office-start {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.office-dashboard-section {
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
.office-dashboard-heading {
|
||||
margin: 0 0 9px;
|
||||
color: #536274;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-template-grid,
|
||||
.office-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.office-create-tile {
|
||||
display: grid;
|
||||
grid-template-rows: 34px auto auto;
|
||||
align-items: center;
|
||||
min-height: 122px;
|
||||
padding: 14px;
|
||||
border: 1px solid #d9dee7;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
|
||||
text-align: left;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.office-create-tile:hover,
|
||||
.office-document-card:hover {
|
||||
border-color: #8db5ef;
|
||||
box-shadow: 0 18px 42px rgba(35, 48, 68, 0.13);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.office-create-tile strong,
|
||||
.office-card-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.office-create-tile small,
|
||||
.office-document-card small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #536274;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.office-create-tile.is-markdown .material-symbols-outlined { color: #2ca58d; }
|
||||
.office-create-tile.is-docx .material-symbols-outlined { color: #2c7be5; }
|
||||
.office-create-tile.is-sheet .material-symbols-outlined { color: #8f6f19; }
|
||||
.office-create-tile.is-deck .material-symbols-outlined { color: #b84a62; }
|
||||
|
||||
.office-document-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 118px 18px 16px;
|
||||
gap: 7px;
|
||||
min-height: 172px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d9dee7;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
|
||||
text-align: left;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.office-card-badge {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
z-index: 1;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
background: color-mix(in srgb, #2ca58d, var(--color-panel) 20%);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.office-card-preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9dee7;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.office-card-preview.is-large {
|
||||
width: min(720px, 100%);
|
||||
min-height: 340px;
|
||||
border-color: #d2d8e3;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 44px rgba(35, 48, 68, 0.12);
|
||||
}
|
||||
|
||||
.office-page-preview,
|
||||
.office-sheet-preview,
|
||||
.office-slide-preview,
|
||||
.office-preview-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.office-page-preview span,
|
||||
.office-sheet-row span,
|
||||
.office-slide-line span,
|
||||
.office-slide-line strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-sheet-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.office-sheet-row span {
|
||||
border-bottom: 1px solid #d9dee7;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.office-preview-fallback {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.office-preview-fallback .material-symbols-outlined {
|
||||
font-size: 36px;
|
||||
.office-body.is-source {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.office-editor-wrap {
|
||||
|
|
@ -817,6 +482,13 @@
|
|||
padding: 30px 24px;
|
||||
}
|
||||
|
||||
.office-editor-scroll.is-source {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding: var(--spacing-md);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.office-editor-scroll.is-desktop {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
|
@ -845,136 +517,28 @@
|
|||
background: #20242a;
|
||||
}
|
||||
|
||||
.office-rich-editor,
|
||||
.office-source-editor,
|
||||
.office-docx-stage,
|
||||
.office-preview-editor {
|
||||
transform: scale(var(--office-zoom));
|
||||
transform-origin: top center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.office-rich-editor {
|
||||
box-sizing: border-box;
|
||||
width: min(760px, 100%);
|
||||
min-height: min(980px, calc(100vh - 170px));
|
||||
padding: 54px 58px;
|
||||
border: 1px solid #d2d8e3;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.office-rich-editor:focus,
|
||||
.office-source-editor:focus,
|
||||
.office-docx-pages:focus-within .office-docx-page:first-child,
|
||||
.office-docx-pages:focus .office-docx-page:first-child {
|
||||
border-color: #8db5ef;
|
||||
box-shadow:
|
||||
0 18px 44px rgba(35, 48, 68, 0.16),
|
||||
0 0 0 3px rgba(44, 123, 229, 0.16);
|
||||
}
|
||||
|
||||
.office-rich-editor h1,
|
||||
.office-rich-editor h2,
|
||||
.office-rich-editor h3 {
|
||||
line-height: 1.25;
|
||||
margin: 0 0 0.65em;
|
||||
}
|
||||
|
||||
.office-rich-editor p,
|
||||
.office-rich-editor ul,
|
||||
.office-rich-editor table {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
.office-rich-editor table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.office-rich-editor td,
|
||||
.office-rich-editor th {
|
||||
border: 1px solid #d9dee7;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.office-source-editor {
|
||||
box-sizing: border-box;
|
||||
width: min(920px, 100%);
|
||||
min-height: min(980px, calc(100vh - 170px));
|
||||
padding: 22px;
|
||||
border: 1px solid #d2d8e3;
|
||||
border-radius: 8px;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.office-docx-stage {
|
||||
width: min(860px, 100%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.office-docx-pages {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.office-docx-pages.is-native {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.office-docx-page {
|
||||
box-sizing: border-box;
|
||||
width: min(760px, 100%);
|
||||
min-height: 980px;
|
||||
margin: 0 auto 20px;
|
||||
padding: 70px 74px;
|
||||
border: 1px solid #d2d8e3;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
|
||||
color: #1f2937;
|
||||
font-family: "Liberation Serif", "Times New Roman", serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.office-docx-page.is-native-tile {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.office-docx-page.is-native-tile img {
|
||||
display: block;
|
||||
width: min(920px, 100%);
|
||||
height: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.office-docx-page p {
|
||||
margin: 0 0 0.85em;
|
||||
}
|
||||
|
||||
.office-preview-editor {
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
width: min(860px, 100%);
|
||||
min-height: 420px;
|
||||
padding: 18px;
|
||||
textarea:focus {
|
||||
background: transparent;
|
||||
filter: brightness(1) !important;
|
||||
}
|
||||
|
||||
.office-panel .spinning {
|
||||
|
|
@ -991,21 +555,15 @@
|
|||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.office-toolbar-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.office-command-button {
|
||||
max-width: 132px;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.office-template-grid,
|
||||
.office-card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
}
|
||||
|
||||
.office-rich-editor,
|
||||
.office-docx-page {
|
||||
min-height: 720px;
|
||||
padding: 34px 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import { getNamespacedClient } from "/js/websocket.js";
|
||||
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
||||
|
||||
const officeSocket = getNamespacedClient("/ws");
|
||||
officeSocket.addHandlers(["ws_webui"]);
|
||||
|
|
@ -22,20 +23,6 @@ function currentContextId() {
|
|||
}
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (!Number.isFinite(size) || size <= 0) return "";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let amount = size;
|
||||
let index = 0;
|
||||
while (amount >= 1024 && index < units.length - 1) {
|
||||
amount /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
const digits = amount >= 10 || index === 0 ? 0 : 1;
|
||||
return `${amount.toFixed(digits)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function basename(path = "") {
|
||||
const value = String(path || "").split("?")[0].split("#")[0];
|
||||
return value.split("/").filter(Boolean).pop() || "Untitled";
|
||||
|
|
@ -51,163 +38,6 @@ function uniqueTabId(session = {}) {
|
|||
return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
|
||||
}
|
||||
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function inlineMarkdown(value = "") {
|
||||
return escapeHtml(value)
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown = "") {
|
||||
const normalized = String(markdown || "").replace(/\r\n?/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
const html = [];
|
||||
let paragraph = [];
|
||||
let list = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph.length) return;
|
||||
html.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
const flushList = () => {
|
||||
if (!list.length) return;
|
||||
html.push(`<ul>${list.map((line) => `<li>${inlineMarkdown(line)}</li>`).join("")}</ul>`);
|
||||
list = [];
|
||||
};
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const raw = lines[index];
|
||||
const line = raw.trimEnd();
|
||||
if (!line.trim()) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = Math.min(4, heading[1].length);
|
||||
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
|
||||
if (bullet) {
|
||||
flushParagraph();
|
||||
list.push(bullet[1]);
|
||||
continue;
|
||||
}
|
||||
flushList();
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
flushList();
|
||||
if (!html.length || /\n\s*$/.test(normalized)) {
|
||||
html.push("<p><br></p>");
|
||||
}
|
||||
return html.join("") || "<p></p>";
|
||||
}
|
||||
|
||||
function htmlToMarkdown(root) {
|
||||
if (!root) return "";
|
||||
|
||||
const walk = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const childText = () => Array.from(node.childNodes).map(walk).join("");
|
||||
|
||||
if (tag === "br") return "\n";
|
||||
if (tag === "strong" || tag === "b") return `**${childText().trim()}**`;
|
||||
if (tag === "em" || tag === "i") return `*${childText().trim()}*`;
|
||||
if (tag === "code") return `\`${childText().trim()}\``;
|
||||
if (tag === "a") {
|
||||
const href = node.getAttribute("href") || "";
|
||||
const label = childText().trim() || href;
|
||||
return href ? `[${label}](${href})` : label;
|
||||
}
|
||||
if (/^h[1-6]$/.test(tag)) return `\n${"#".repeat(Number(tag[1]))} ${childText().trim()}\n\n`;
|
||||
if (tag === "li") return `- ${childText().trim()}\n`;
|
||||
if (tag === "ul" || tag === "ol") return `\n${childText()}\n`;
|
||||
if (tag === "tr") {
|
||||
const cells = Array.from(node.children).map((cell) => cell.textContent?.trim() || "");
|
||||
return `| ${cells.join(" | ")} |\n`;
|
||||
}
|
||||
if (tag === "table") return `\n${Array.from(node.querySelectorAll("tr")).map(walk).join("")}\n`;
|
||||
if (tag === "p" || tag === "div" || tag === "section" || tag === "article") {
|
||||
const text = childText().trim();
|
||||
return text ? `${text}\n\n` : "";
|
||||
}
|
||||
return childText();
|
||||
};
|
||||
|
||||
return Array.from(root.childNodes)
|
||||
.map(walk)
|
||||
.join("")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
function textToPageHtml(text = "") {
|
||||
const paragraphs = String(text || "")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const lines = paragraphs.length ? paragraphs : [""];
|
||||
const pages = [];
|
||||
for (let index = 0; index < lines.length; index += 18) {
|
||||
pages.push(lines.slice(index, index + 18));
|
||||
}
|
||||
return pages
|
||||
.map((page, index) => (
|
||||
`<section class="office-docx-page" data-page="${index + 1}">`
|
||||
+ page.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
|
||||
+ "</section>"
|
||||
))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function nativeTilesToHtml(tiles = []) {
|
||||
return tiles
|
||||
.filter((tile) => tile?.image)
|
||||
.map((tile) => {
|
||||
const twips = encodeURIComponent(JSON.stringify(tile.twips || {}));
|
||||
const width = Number(tile.width || 1);
|
||||
const height = Number(tile.height || 1);
|
||||
return (
|
||||
`<section class="office-docx-page is-native-tile" data-tile-index="${Number(tile.index || 0)}" data-twips="${twips}">`
|
||||
+ `<img src="${escapeHtml(tile.image)}" width="${width}" height="${height}" alt="" draggable="false">`
|
||||
+ "</section>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function docxEditorText(element) {
|
||||
if (!element) return "";
|
||||
const pages = Array.from(element.querySelectorAll(".office-docx-page"));
|
||||
if (!pages.length) return element.innerText || "";
|
||||
return pages
|
||||
.map((page) => Array.from(page.querySelectorAll("p"))
|
||||
.map((p) => p.innerText.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n"))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function editorContainsFocus(element) {
|
||||
const active = document.activeElement;
|
||||
return Boolean(element && active && (element === active || element.contains(active)));
|
||||
|
|
@ -263,9 +93,6 @@ function normalizeSession(payload = {}) {
|
|||
title: payload.title || document.title || document.basename || basename(document.path),
|
||||
tab_id: uniqueTabId(payload),
|
||||
text: String(payload.text || ""),
|
||||
tiles: Array.isArray(payload.tiles) ? payload.tiles : [],
|
||||
preview: payload.preview || document.preview || {},
|
||||
native: payload.native || {},
|
||||
desktop: payload.desktop || null,
|
||||
desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
|
||||
dirty: false,
|
||||
|
|
@ -306,8 +133,6 @@ function isOfficeSocketData(data) {
|
|||
|| Object.prototype.hasOwnProperty.call(data, "ok")
|
||||
|| Object.prototype.hasOwnProperty.call(data, "session_id")
|
||||
|| Object.prototype.hasOwnProperty.call(data, "document")
|
||||
|| Object.prototype.hasOwnProperty.call(data, "tiles")
|
||||
|| Object.prototype.hasOwnProperty.call(data, "native")
|
||||
|| Object.prototype.hasOwnProperty.call(data, "desktop")
|
||||
|| Object.prototype.hasOwnProperty.call(data, "closed")
|
||||
);
|
||||
|
|
@ -315,8 +140,6 @@ function isOfficeSocketData(data) {
|
|||
|
||||
const model = {
|
||||
status: null,
|
||||
recent: [],
|
||||
openDocuments: [],
|
||||
tabs: [],
|
||||
activeTabId: "",
|
||||
session: null,
|
||||
|
|
@ -325,22 +148,16 @@ const model = {
|
|||
dirty: false,
|
||||
error: "",
|
||||
message: "",
|
||||
sourceMode: false,
|
||||
editorText: "",
|
||||
zoom: 1,
|
||||
_root: null,
|
||||
_mode: "canvas",
|
||||
_saveMessageTimer: null,
|
||||
_inputTimer: null,
|
||||
_history: [],
|
||||
_historyIndex: -1,
|
||||
_rendering: false,
|
||||
_pendingFocus: false,
|
||||
_pendingFocusEnd: true,
|
||||
_focusAttempts: 0,
|
||||
_richEditor: null,
|
||||
_docxEditor: null,
|
||||
_nativeEventQueue: Promise.resolve(),
|
||||
_floatingCleanup: null,
|
||||
_desktopHeartbeatTimer: null,
|
||||
_desktopHeartbeatSessionId: "",
|
||||
|
|
@ -404,22 +221,10 @@ const model = {
|
|||
if (this._mode === "modal") this._root = null;
|
||||
},
|
||||
|
||||
bindEditorElement(element, type) {
|
||||
if (type === "markdown") this._richEditor = element;
|
||||
if (type === "docx") this._docxEditor = element;
|
||||
this.queueRender();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const [status, recent, openDocuments] = await Promise.all([
|
||||
callOffice("status"),
|
||||
callOffice("recent"),
|
||||
callOffice("open_documents"),
|
||||
]);
|
||||
const status = await callOffice("status");
|
||||
this.status = status || {};
|
||||
this.recent = (recent?.documents || []).map(normalizeDocument);
|
||||
this.openDocuments = (openDocuments?.documents || []).map(normalizeDocument);
|
||||
this.error = "";
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -485,17 +290,20 @@ const model = {
|
|||
});
|
||||
},
|
||||
|
||||
async openPrompt() {
|
||||
let defaultPath = "/a0/usr/workdir/";
|
||||
async openFileBrowser() {
|
||||
let workdirPath = "/a0/usr/workdir";
|
||||
try {
|
||||
const home = await callOffice("home");
|
||||
defaultPath = home?.path || defaultPath;
|
||||
const response = await callJsonApi("settings_get", null);
|
||||
workdirPath = response?.settings?.workdir_path || workdirPath;
|
||||
} catch {
|
||||
// The prompt still works with the static fallback.
|
||||
try {
|
||||
const home = await callOffice("home");
|
||||
workdirPath = home?.path || workdirPath;
|
||||
} catch {
|
||||
// The file browser can still open with the static fallback.
|
||||
}
|
||||
}
|
||||
const path = globalThis.prompt?.("Path", defaultPath);
|
||||
if (!path) return;
|
||||
await this.openPath(path);
|
||||
await fileBrowserStore.open(workdirPath);
|
||||
},
|
||||
|
||||
async openPath(path) {
|
||||
|
|
@ -560,7 +368,6 @@ const model = {
|
|||
basename: "Desktop",
|
||||
title: "Desktop",
|
||||
extension: "desktop",
|
||||
preview: {},
|
||||
},
|
||||
dirty: false,
|
||||
};
|
||||
|
|
@ -569,7 +376,6 @@ const model = {
|
|||
const desktopTabId = desktopTab.tab_id;
|
||||
this.session = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
|
||||
this.activeTabId = desktopTabId;
|
||||
this.sourceMode = false;
|
||||
this.editorText = "";
|
||||
this.dirty = false;
|
||||
this.resetHistory("");
|
||||
|
|
@ -581,7 +387,6 @@ const model = {
|
|||
const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
|
||||
this.session = tab;
|
||||
this.activeTabId = tab?.tab_id || "";
|
||||
this.sourceMode = false;
|
||||
this.editorText = String(tab?.text || "");
|
||||
this.dirty = Boolean(tab?.dirty);
|
||||
this.resetHistory(this.editorText);
|
||||
|
|
@ -598,41 +403,6 @@ const model = {
|
|||
return Boolean(tab && tab.tab_id === this.activeTabId);
|
||||
},
|
||||
|
||||
async closeFile() {
|
||||
if (!this.session) return;
|
||||
if (this.isDesktopOfficeDocument(this.session) && !this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) {
|
||||
await this.closeDesktopDocumentSession(this.session);
|
||||
return;
|
||||
}
|
||||
await this.closeTab(this.session.tab_id);
|
||||
},
|
||||
|
||||
async closeDesktopDocumentSession(session) {
|
||||
try {
|
||||
await callOffice("desktop_save", {
|
||||
desktop_session_id: session.desktop_session_id || session.session_id,
|
||||
file_id: session.file_id || "",
|
||||
}).catch(() => null);
|
||||
await callOffice("close", {
|
||||
session_id: session.store_session_id || "",
|
||||
file_id: session.file_id || "",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Desktop document close skipped", error);
|
||||
}
|
||||
this.session = null;
|
||||
this.activeTabId = "";
|
||||
this.editorText = "";
|
||||
this.dirty = false;
|
||||
const desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
|
||||
if (desktopTab) {
|
||||
this.selectTab(desktopTab.tab_id, { focus: false });
|
||||
} else {
|
||||
await this.ensureDesktopSession({ select: true });
|
||||
}
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
async closeTab(tabId) {
|
||||
const tab = this.tabs.find((item) => item.tab_id === tabId);
|
||||
if (!tab) return;
|
||||
|
|
@ -705,14 +475,12 @@ const model = {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (this.hasNativeDocxTiles()) await this.awaitNativeEvents();
|
||||
if (!this.hasNativeDocxTiles()) this.syncEditorText();
|
||||
this.syncEditorText();
|
||||
this.saving = true;
|
||||
this.error = "";
|
||||
try {
|
||||
let response;
|
||||
const payload = { session_id: this.session.session_id };
|
||||
if (!this.hasNativeDocxTiles()) payload.text = this.editorText;
|
||||
const payload = { session_id: this.session.session_id, text: this.editorText };
|
||||
try {
|
||||
response = await requestOffice("office_save", payload, 10000);
|
||||
} catch (_socketError) {
|
||||
|
|
@ -727,8 +495,6 @@ const model = {
|
|||
document,
|
||||
path: document.path || this.session.path,
|
||||
file_id: document.file_id || this.session.file_id,
|
||||
tiles: Array.isArray(response.tiles) ? response.tiles : this.session.tiles,
|
||||
native: response.native || this.session.native || {},
|
||||
version: document.version || response.version || this.session.version,
|
||||
};
|
||||
this.replaceActiveSession(updated);
|
||||
|
|
@ -742,26 +508,6 @@ const model = {
|
|||
}
|
||||
},
|
||||
|
||||
async exportPdf() {
|
||||
if (!this.session) return;
|
||||
if (this.isDesktopSession()) return;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const response = await callOffice("export", {
|
||||
file_id: this.session.file_id,
|
||||
path: this.session.path,
|
||||
target_format: "pdf",
|
||||
});
|
||||
if (response?.ok === false) throw new Error(response.error || "Export failed.");
|
||||
this.setMessage(response.path ? `Exported ${response.path}` : "Exported");
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
replaceActiveSession(next) {
|
||||
if (!this.session) return;
|
||||
this.session = next;
|
||||
|
|
@ -835,32 +581,9 @@ const model = {
|
|||
this.scheduleInputPush();
|
||||
},
|
||||
|
||||
onRichInput(element) {
|
||||
if (this._rendering) return;
|
||||
this.editorText = htmlToMarkdown(element);
|
||||
this.markDirty();
|
||||
this.pushHistory(this.editorText);
|
||||
this.scheduleInputPush();
|
||||
},
|
||||
|
||||
onDocxInput(element) {
|
||||
if (this.hasNativeDocxTiles()) return;
|
||||
if (this._rendering) return;
|
||||
this.editorText = docxEditorText(element);
|
||||
this.markDirty();
|
||||
this.pushHistory(this.editorText);
|
||||
this.scheduleInputPush();
|
||||
},
|
||||
|
||||
syncEditorText() {
|
||||
if (!this.session) return;
|
||||
if (this.hasOfficialOffice()) return;
|
||||
if (this.hasNativeDocxTiles()) return;
|
||||
if (this.isMarkdown() && !this.sourceMode && this._richEditor) {
|
||||
this.editorText = htmlToMarkdown(this._richEditor);
|
||||
} else if (this.isDocx() && this._docxEditor) {
|
||||
this.editorText = docxEditorText(this._docxEditor);
|
||||
}
|
||||
this.session.text = this.editorText;
|
||||
},
|
||||
|
||||
|
|
@ -883,98 +606,10 @@ const model = {
|
|||
}, 3000).catch(() => {});
|
||||
},
|
||||
|
||||
toggleSource() {
|
||||
if (!this.isMarkdown()) return;
|
||||
if (!this.sourceMode) this.syncEditorText();
|
||||
this.sourceMode = !this.sourceMode;
|
||||
this.queueRender({ force: true, focus: true });
|
||||
},
|
||||
|
||||
format(command) {
|
||||
if (!this.session) return;
|
||||
if (this.sourceMode) {
|
||||
this.applySourceFormat(command);
|
||||
return;
|
||||
}
|
||||
const editor = this.isDocx() ? this._docxEditor : this._richEditor;
|
||||
editor?.focus?.();
|
||||
const uno = this.unoCommand(command);
|
||||
if (this.isDocx() && uno) {
|
||||
void this.dispatchUnoCommand(uno.command, uno.arguments);
|
||||
if (this.hasNativeDocxTiles()) {
|
||||
this.markDirty();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (command === "bold") document.execCommand?.("bold");
|
||||
if (command === "italic") document.execCommand?.("italic");
|
||||
if (command === "underline") document.execCommand?.("underline");
|
||||
if (command === "list") document.execCommand?.("insertUnorderedList");
|
||||
if (command === "numbered") document.execCommand?.("insertOrderedList");
|
||||
if (command === "alignLeft") document.execCommand?.("justifyLeft");
|
||||
if (command === "alignCenter") document.execCommand?.("justifyCenter");
|
||||
if (command === "alignRight") document.execCommand?.("justifyRight");
|
||||
if (command === "table") {
|
||||
document.execCommand?.(
|
||||
"insertHTML",
|
||||
false,
|
||||
'<table><tbody><tr><th>Column</th><th>Value</th></tr><tr><td></td><td></td></tr></tbody></table>',
|
||||
);
|
||||
}
|
||||
this.syncEditorText();
|
||||
this.markDirty();
|
||||
this.pushHistory(this.editorText);
|
||||
this.scheduleInputPush();
|
||||
},
|
||||
|
||||
unoCommand(command) {
|
||||
const commands = {
|
||||
bold: { command: ".uno:Bold" },
|
||||
italic: { command: ".uno:Italic" },
|
||||
underline: { command: ".uno:Underline" },
|
||||
list: { command: ".uno:DefaultBullet" },
|
||||
numbered: { command: ".uno:DefaultNumbering" },
|
||||
alignLeft: { command: ".uno:LeftPara" },
|
||||
alignCenter: { command: ".uno:CenterPara" },
|
||||
alignRight: { command: ".uno:RightPara" },
|
||||
};
|
||||
return commands[command] || null;
|
||||
},
|
||||
|
||||
async dispatchUnoCommand(command, argumentsPayload = null) {
|
||||
if (!this.session?.session_id || !command) return null;
|
||||
return await this.queueNativeEvent(async () => {
|
||||
try {
|
||||
let response;
|
||||
try {
|
||||
response = await requestOffice("office_command", {
|
||||
session_id: this.session.session_id,
|
||||
command,
|
||||
arguments: argumentsPayload,
|
||||
notify: true,
|
||||
}, 5000);
|
||||
} catch (_socketError) {
|
||||
response = await callOffice("command", {
|
||||
session_id: this.session.session_id,
|
||||
command,
|
||||
arguments: argumentsPayload,
|
||||
notify: true,
|
||||
});
|
||||
}
|
||||
if (response?.ok === false) throw new Error(response.error || `${command} failed.`);
|
||||
if (response?.metadata && this.session) {
|
||||
this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
|
||||
}
|
||||
if (Array.isArray(response?.tiles) && this.session) {
|
||||
this.session.tiles = response.tiles;
|
||||
this.queueRender({ force: true, focus: true });
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.warn("LibreOffice command skipped", command, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (!this.isMarkdown()) return;
|
||||
this.applySourceFormat(command);
|
||||
},
|
||||
|
||||
applySourceFormat(command) {
|
||||
|
|
@ -999,18 +634,6 @@ const model = {
|
|||
});
|
||||
},
|
||||
|
||||
zoomIn() {
|
||||
this.zoom = Math.min(1.6, Math.round((this.zoom + 0.1) * 10) / 10);
|
||||
},
|
||||
|
||||
zoomOut() {
|
||||
this.zoom = Math.max(0.7, Math.round((this.zoom - 0.1) * 10) / 10);
|
||||
},
|
||||
|
||||
zoomLabel() {
|
||||
return `${Math.round(this.zoom * 100)}%`;
|
||||
},
|
||||
|
||||
queueRender(options = {}) {
|
||||
const force = Boolean(options.force);
|
||||
if (options.focus) {
|
||||
|
|
@ -1019,7 +642,6 @@ const model = {
|
|||
this._focusAttempts = 0;
|
||||
}
|
||||
const render = () => {
|
||||
this.renderEditors(force);
|
||||
if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
|
||||
this._pendingFocus = false;
|
||||
this._focusAttempts = 0;
|
||||
|
|
@ -1035,35 +657,16 @@ const model = {
|
|||
}
|
||||
},
|
||||
|
||||
renderEditors(force = false) {
|
||||
if (!this.session) return;
|
||||
if (this.hasOfficialOffice()) return;
|
||||
this._rendering = true;
|
||||
try {
|
||||
if (this._richEditor && this.isMarkdown() && (!editorContainsFocus(this._richEditor) || force)) {
|
||||
this._richEditor.innerHTML = markdownToHtml(this.editorText);
|
||||
}
|
||||
if (this._docxEditor && this.isDocx() && this.hasNativeDocxTiles() && (!editorContainsFocus(this._docxEditor) || force)) {
|
||||
this._docxEditor.innerHTML = nativeTilesToHtml(this.session.tiles || []);
|
||||
} else if (this._docxEditor && this.isDocx() && (!editorContainsFocus(this._docxEditor) || force)) {
|
||||
this._docxEditor.innerHTML = textToPageHtml(this.editorText);
|
||||
}
|
||||
} finally {
|
||||
this._rendering = false;
|
||||
}
|
||||
},
|
||||
|
||||
focusEditor(options = {}) {
|
||||
if (!this.session || this.isPreviewOnly()) return false;
|
||||
if (!this.session) return false;
|
||||
if (this.hasOfficialOffice()) {
|
||||
return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
|
||||
}
|
||||
const source = this._root?.querySelector?.("[data-office-source]");
|
||||
const editor = this.sourceMode ? source : (this.isDocx() ? this._docxEditor : this._richEditor);
|
||||
if (!editor) return false;
|
||||
editor.focus?.({ preventScroll: true });
|
||||
if (!editorContainsFocus(editor)) return false;
|
||||
if (options.end !== false) placeCaretAtEnd(editor);
|
||||
if (!this.isMarkdown() || !source) return false;
|
||||
source.focus?.({ preventScroll: true });
|
||||
if (!editorContainsFocus(source)) return false;
|
||||
if (options.end !== false) placeCaretAtEnd(source);
|
||||
return true;
|
||||
},
|
||||
|
||||
|
|
@ -1072,11 +675,6 @@ const model = {
|
|||
return ext === "md";
|
||||
},
|
||||
|
||||
isDocx(tab = this.session) {
|
||||
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
|
||||
return ext === "docx";
|
||||
},
|
||||
|
||||
isBinaryOffice(tab = this.session) {
|
||||
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
|
||||
return ext === "docx" || ext === "xlsx" || ext === "pptx";
|
||||
|
|
@ -1964,123 +1562,6 @@ const model = {
|
|||
await this.refresh();
|
||||
},
|
||||
|
||||
hasNativeDocxTiles() {
|
||||
return Boolean(
|
||||
this.isDocx()
|
||||
&& this.session?.native?.available
|
||||
&& Array.isArray(this.session?.tiles)
|
||||
&& this.session.tiles.some((tile) => tile?.image),
|
||||
);
|
||||
},
|
||||
|
||||
async onNativeDocxClick(event) {
|
||||
if (!this.hasNativeDocxTiles()) return;
|
||||
const page = event.target?.closest?.(".office-docx-page.is-native-tile");
|
||||
const image = page?.querySelector?.("img");
|
||||
if (!page || !image) return;
|
||||
const twips = this.decodeTileTwips(page);
|
||||
const rect = image.getBoundingClientRect();
|
||||
const ratioX = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width)));
|
||||
const ratioY = Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)));
|
||||
const x = Math.round((twips.x || 0) + ratioX * (twips.width || 0));
|
||||
const y = Math.round((twips.y || 0) + ratioY * (twips.height || 0));
|
||||
this._docxEditor?.focus?.({ preventScroll: true });
|
||||
await this.sendNativeMouse({ type: "down", x, y, count: 1, buttons: 1, modifier: 0 });
|
||||
await this.sendNativeMouse({ type: "up", x, y, count: 1, buttons: 1, modifier: 0 });
|
||||
},
|
||||
|
||||
onNativeDocxKeydown(event) {
|
||||
if (!this.hasNativeDocxTiles()) return;
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
const key = event.key || "";
|
||||
if (key.length === 1) {
|
||||
event.preventDefault();
|
||||
void this.sendNativeKey({ text: key });
|
||||
return;
|
||||
}
|
||||
const special = {
|
||||
Enter: { text: "\n" },
|
||||
Tab: { text: "\t" },
|
||||
Backspace: { char_code: 0, key_code: 8 },
|
||||
Delete: { char_code: 0, key_code: 127 },
|
||||
ArrowLeft: { char_code: 0, key_code: 37 },
|
||||
ArrowUp: { char_code: 0, key_code: 38 },
|
||||
ArrowRight: { char_code: 0, key_code: 39 },
|
||||
ArrowDown: { char_code: 0, key_code: 40 },
|
||||
}[key];
|
||||
if (!special) return;
|
||||
event.preventDefault();
|
||||
if (special.text != null) {
|
||||
void this.sendNativeKey({ text: special.text });
|
||||
} else {
|
||||
void this.sendNativeKey({ type: "down", ...special }).then(() => this.sendNativeKey({ type: "up", ...special }));
|
||||
}
|
||||
},
|
||||
|
||||
decodeTileTwips(page) {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(page?.dataset?.twips || "{}"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
async sendNativeKey(key) {
|
||||
if (!this.session?.session_id) return null;
|
||||
return await this.queueNativeEvent(async () => {
|
||||
const response = await this.sendNativeEvent("office_key", "key", key, "key");
|
||||
if (response?.ok) this.markDirty();
|
||||
return response;
|
||||
});
|
||||
},
|
||||
|
||||
async sendNativeMouse(mouse) {
|
||||
if (!this.session?.session_id) return null;
|
||||
return await this.queueNativeEvent(() => this.sendNativeEvent("office_mouse", "mouse", mouse, "mouse"));
|
||||
},
|
||||
|
||||
async queueNativeEvent(task) {
|
||||
const run = this._nativeEventQueue.catch(() => null).then(task);
|
||||
this._nativeEventQueue = run.catch(() => null);
|
||||
return await run;
|
||||
},
|
||||
|
||||
async awaitNativeEvents() {
|
||||
await this._nativeEventQueue.catch(() => null);
|
||||
},
|
||||
|
||||
async sendNativeEvent(socketEvent, apiAction, payload, key) {
|
||||
try {
|
||||
let response;
|
||||
try {
|
||||
response = await requestOffice(socketEvent, {
|
||||
session_id: this.session.session_id,
|
||||
[key]: payload,
|
||||
}, 7000);
|
||||
} catch (_socketError) {
|
||||
response = await callOffice(apiAction, {
|
||||
session_id: this.session.session_id,
|
||||
[key]: payload,
|
||||
});
|
||||
}
|
||||
if (response?.metadata && this.session) {
|
||||
this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
|
||||
}
|
||||
if (Array.isArray(response?.tiles) && this.session) {
|
||||
this.session.tiles = response.tiles;
|
||||
this.queueRender({ force: true, focus: true });
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.warn("LibreOffice native event skipped", socketEvent, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
isPreviewOnly() {
|
||||
return Boolean(this.session && !this.hasOfficialOffice() && !this.isMarkdown() && !this.isDocx());
|
||||
},
|
||||
|
||||
defaultTitle(kind, fmt) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
if (fmt === "md") return `Document ${date}`;
|
||||
|
|
@ -2109,70 +1590,6 @@ const model = {
|
|||
return "draft";
|
||||
},
|
||||
|
||||
documentPath() {
|
||||
return this.session?.document?.path || this.session?.path || "";
|
||||
},
|
||||
|
||||
documentMeta(doc = this.session?.document || this.session || {}) {
|
||||
const parts = [String(doc.extension || "").toUpperCase(), formatBytes(doc.size)].filter(Boolean);
|
||||
return parts.join(" · ");
|
||||
},
|
||||
|
||||
openCards() {
|
||||
return this.visibleTabs()
|
||||
.filter((tab) => !this.isDesktopSession(tab))
|
||||
.map((tab) => normalizeDocument({
|
||||
...tab.document,
|
||||
...tab,
|
||||
open: true,
|
||||
}));
|
||||
},
|
||||
|
||||
recentCards() {
|
||||
const openIds = new Set(this.tabs.map((tab) => tab.file_id).filter(Boolean));
|
||||
return this.recent.filter((doc) => !openIds.has(doc.file_id)).slice(0, 8);
|
||||
},
|
||||
|
||||
previewKind(doc = {}) {
|
||||
const ext = String(doc.extension || "").toLowerCase();
|
||||
if (ext === "xlsx") return "spreadsheet";
|
||||
if (ext === "pptx") return "presentation";
|
||||
if (ext === "md") return "markdown";
|
||||
return "document";
|
||||
},
|
||||
|
||||
hasPreview(doc = {}) {
|
||||
const preview = doc.preview || {};
|
||||
return Boolean(
|
||||
(Array.isArray(preview.lines) && preview.lines.length)
|
||||
|| (Array.isArray(preview.rows) && preview.rows.length)
|
||||
|| (Array.isArray(preview.slides) && preview.slides.length)
|
||||
);
|
||||
},
|
||||
|
||||
previewLines(doc = {}) {
|
||||
const preview = doc.preview || {};
|
||||
return (preview.lines || []).slice(0, 8);
|
||||
},
|
||||
|
||||
previewRows(doc = {}) {
|
||||
const preview = doc.preview || {};
|
||||
return (preview.rows || []).slice(0, 6);
|
||||
},
|
||||
|
||||
previewSlides(doc = {}) {
|
||||
const preview = doc.preview || {};
|
||||
return (preview.slides || []).slice(0, 3);
|
||||
},
|
||||
|
||||
dashboardTitle(doc = {}) {
|
||||
return doc.title || doc.basename || basename(doc.path);
|
||||
},
|
||||
|
||||
dashboardMeta(doc = {}) {
|
||||
return [String(doc.extension || "").toUpperCase(), doc.open ? "Open" : "", formatBytes(doc.size)].filter(Boolean).join(" · ");
|
||||
},
|
||||
|
||||
setupFloatingModal(element = null) {
|
||||
const root = element || globalThis.document?.querySelector(".office-panel");
|
||||
const inner = root?.closest?.(".modal-inner");
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
|
|||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert "office-rich-editor" in panel
|
||||
assert "office-docx-pages" in panel
|
||||
assert "office-source-editor" in panel
|
||||
assert "data-office-source" in panel
|
||||
assert "office-rich-editor" not in panel
|
||||
assert "office-docx-pages" not in panel
|
||||
assert "office-desktop-frame" in panel
|
||||
assert "data-office-desktop-frame" in panel
|
||||
assert 'title="LibreOffice desktop"' not in panel
|
||||
|
|
@ -28,12 +30,14 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
|
|||
assert "office-modal-resizer" in panel
|
||||
assert "resize: both" not in panel
|
||||
assert 'tabindex="0"' in panel
|
||||
assert "format_underlined" in panel
|
||||
assert "format_align_center" in panel
|
||||
assert "is-native-tile" in panel
|
||||
assert "format_underlined" not in panel
|
||||
assert "format_align_center" not in panel
|
||||
assert "is-native-tile" not in panel
|
||||
assert "hasOfficialOffice()" in panel
|
||||
assert "office_save" in store
|
||||
assert "desktop_save" in store
|
||||
assert "--office-zoom" not in panel
|
||||
assert "zoom: 1" not in store
|
||||
assert 'callOffice("desktop")' in store
|
||||
assert "ensureDesktopSession" in store
|
||||
assert "handleOfficialOfficeClosed" in store
|
||||
|
|
@ -82,16 +86,16 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
|
|||
assert "officialOfficeUrl" in store
|
||||
assert "hasOfficialOffice" in store
|
||||
assert "isOfficeSocketData" in store
|
||||
assert "office_command" in store
|
||||
assert "office_key" in store
|
||||
assert "office_mouse" in store
|
||||
assert ".uno:Bold" in store
|
||||
assert "nativeTilesToHtml" in store
|
||||
assert "office_command" not in store
|
||||
assert "office_key" not in store
|
||||
assert "office_mouse" not in store
|
||||
assert ".uno:Bold" not in store
|
||||
assert "nativeTilesToHtml" not in store
|
||||
assert "editorContainsFocus" in store
|
||||
assert "_focusAttempts" in store
|
||||
assert "_nativeEventQueue" in store
|
||||
assert "await this.awaitNativeEvents()" in store
|
||||
assert "<p><br></p>" in store
|
||||
assert "_nativeEventQueue" not in store
|
||||
assert "await this.awaitNativeEvents()" not in store
|
||||
assert "<p><br></p>" not in store
|
||||
assert "setupTitle()" not in panel
|
||||
assert "Setup in progress" not in store
|
||||
assert "office-log" not in panel
|
||||
|
|
@ -112,7 +116,7 @@ def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session():
|
|||
assert "getModifierState: { value: getModifierState }" in store
|
||||
|
||||
|
||||
def test_office_dashboard_uses_cards_and_filters_tabs_to_desktop_and_markdown():
|
||||
def test_office_surface_filters_tabs_to_desktop_and_markdown_without_dashboard():
|
||||
panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
|
@ -120,14 +124,14 @@ def test_office_dashboard_uses_cards_and_filters_tabs_to_desktop_and_markdown():
|
|||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert "office-card-grid" in panel
|
||||
assert "office-document-card" in panel
|
||||
assert "office-card-grid" not in panel
|
||||
assert "office-document-card" not in panel
|
||||
assert "visibleTabs()" in panel
|
||||
assert "openCards()" in panel
|
||||
assert "recentCards()" in panel
|
||||
assert "openCards()" not in panel
|
||||
assert "recentCards()" not in panel
|
||||
assert "office-editor-head" not in panel
|
||||
assert "office-recent-row" not in panel
|
||||
assert "open_documents" in store
|
||||
assert "open_documents" not in store
|
||||
assert "installDesktopDocumentSession" in store
|
||||
assert "isDesktopOfficeDocument" in store
|
||||
assert "isVisibleOfficeTab" in store
|
||||
|
|
@ -248,6 +252,9 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
|
|||
assert "DESKTOP_FOLDER_LINKS" in desktop
|
||||
assert "HIDDEN_XPRA_DESKTOP_ENTRIES" in desktop
|
||||
assert "libreoffice-gtk3" in install
|
||||
assert "libreofficekit" not in install
|
||||
assert "gir1.2-lokdocview" not in install
|
||||
assert "python3-gi" not in install
|
||||
assert "xpra" in install
|
||||
assert "xpra-x11" in install
|
||||
assert "xpra-html5" in install
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import os
|
|||
import sys
|
||||
import types
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -23,9 +24,7 @@ from plugins._office.helpers import (
|
|||
document_store,
|
||||
libreoffice,
|
||||
libreoffice_desktop,
|
||||
libreofficekit_native,
|
||||
libreofficekit_sessions,
|
||||
libreofficekit_worker,
|
||||
markdown_sessions,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -87,9 +86,10 @@ def test_blank_docx_includes_editable_body_paragraph(office_state):
|
|||
doc = document_store.create_document("document", "Blank Memo", "docx", "")
|
||||
with zipfile.ZipFile(doc["path"]) as archive:
|
||||
xml = archive.read("word/document.xml").decode("utf-8")
|
||||
root = document_store.ET.fromstring(xml)
|
||||
root = ET.fromstring(xml)
|
||||
|
||||
assert len(list(root.iter(document_store._qn(document_store.W_NS, "p")))) >= 2
|
||||
word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
assert len(list(root.iter(f"{{{word_ns}}}p"))) >= 2
|
||||
assert 'xml:space="preserve"> </w:t>' in xml
|
||||
|
||||
|
||||
|
|
@ -239,16 +239,14 @@ def test_non_project_creation_uses_configured_workdir(office_state):
|
|||
assert Path(spreadsheet["path"]).parent == office_state.documents
|
||||
|
||||
|
||||
def test_sessions_recent_preview_and_canvas_context_are_neutral(office_state):
|
||||
def test_sessions_and_canvas_context_are_neutral(office_state):
|
||||
doc = document_store.create_document("document", "Canvas Context", "md", "Private body text.")
|
||||
session = document_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
|
||||
|
||||
open_docs = document_store.get_open_documents()
|
||||
recent = document_store.get_recent_documents()
|
||||
context = canvas_context.build_context()
|
||||
|
||||
assert open_docs[0]["file_id"] == doc["file_id"]
|
||||
assert recent[0]["preview"]["lines"]
|
||||
assert "document artifacts" in context
|
||||
assert "Private body text" not in context
|
||||
assert document_store.close_session(session_id=session["session_id"]) == 1
|
||||
|
|
@ -266,8 +264,8 @@ def test_markdown_save_tracks_version_history(office_state):
|
|||
|
||||
|
||||
def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
|
||||
manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
|
||||
monkeypatch.setattr(libreofficekit_sessions, "_manager", manager, raising=False)
|
||||
manager = markdown_sessions.MarkdownSessionManager()
|
||||
monkeypatch.setattr(markdown_sessions, "_manager", manager, raising=False)
|
||||
doc = document_store.create_document("document", "Receiver", "md", "First")
|
||||
session = manager.open(doc)
|
||||
|
||||
|
|
@ -276,64 +274,12 @@ def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeyp
|
|||
assert manager._sessions[session["session_id"]].text == "# Receiver\n\nSecond"
|
||||
|
||||
|
||||
def test_docx_session_dispatches_native_uno_commands(office_state, monkeypatch):
|
||||
calls = []
|
||||
def test_markdown_session_rejects_office_binaries(office_state):
|
||||
manager = markdown_sessions.MarkdownSessionManager()
|
||||
doc = document_store.create_document("document", "Desktop Only", "docx", "Native text")
|
||||
|
||||
class FakeNativeDocument:
|
||||
def metadata(self):
|
||||
return {"available": True, "doctype": 0, "parts": 1, "width_twips": 100, "height_twips": 200}
|
||||
|
||||
def post_uno_command(self, command, arguments=None, notify=True):
|
||||
calls.append((command, arguments, notify))
|
||||
return {"ok": True, "native": True, "command": command}
|
||||
|
||||
def command_values(self, command):
|
||||
return {"ok": True, "native": True, "command": command, "values": {"commandName": command}}
|
||||
|
||||
def close(self):
|
||||
calls.append(("close", None, None))
|
||||
|
||||
monkeypatch.setattr(libreofficekit_native, "open_document", lambda path: FakeNativeDocument())
|
||||
|
||||
manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
|
||||
doc = document_store.create_document("document", "Native", "docx", "Native text")
|
||||
session = manager.open(doc)
|
||||
result = manager.command(session["session_id"], ".uno:Bold", notify=True)
|
||||
values = manager.command_values(session["session_id"], ".uno:StyleApply")
|
||||
manager.close(session["session_id"])
|
||||
|
||||
assert session["native"]["available"] is True
|
||||
assert result["ok"] is True
|
||||
assert result["native"] is True
|
||||
assert values["values"]["commandName"] == ".uno:StyleApply"
|
||||
assert calls[0] == (".uno:Bold", None, True)
|
||||
assert calls[-1] == ("close", None, None)
|
||||
|
||||
|
||||
def test_lok_worker_serializes_concurrent_rpc_calls():
|
||||
import concurrent.futures
|
||||
import threading
|
||||
import time
|
||||
|
||||
document = object.__new__(libreofficekit_worker.WorkerLokDocument)
|
||||
document._lock = threading.RLock()
|
||||
active = 0
|
||||
max_active = 0
|
||||
|
||||
def fake_request_unlocked(action, payload=None, timeout=18):
|
||||
nonlocal active, max_active
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
time.sleep(0.01)
|
||||
active -= 1
|
||||
return {"ok": True, "action": action, "payload": payload}
|
||||
|
||||
document._request_unlocked = fake_request_unlocked
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
|
||||
results = list(pool.map(lambda index: document._request("key", {"index": index}), range(12)))
|
||||
|
||||
assert all(result["ok"] is True for result in results)
|
||||
assert max_active == 1
|
||||
with pytest.raises(ValueError, match="Open .docx files in the Desktop"):
|
||||
manager.open(doc)
|
||||
|
||||
|
||||
def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypatch):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue