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.

Alessandro committed May 2, 2026 at 19:24 UTC e64b9b2538941be906ebc68fc77299aea8f72502
15 files changed +351 -2592
docker/run/fs/ins/install_additional.sh
-4
@@ -59,10 +59,6 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
59 libreoffice-calc \
60 libreoffice-impress \
61 libreoffice-gtk3 \
62 - libreofficekit-data \
63 - libreofficekit-dev \
64 - gir1.2-lokdocview-0.1 \
65 - python3-gi \
62 python3-uno \
63 xpra \
64 xpra-x11 \
plugins/_office/api/office_session.py
+8 -75
@@ -1,7 +1,7 @@
1 from __future__ import annotations
2
3 from helpers.api import ApiHandler, Request
4 -from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, libreofficekit_sessions
4 +from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, markdown_sessions
5
6
7 class OfficeSession(ApiHandler):
@@ -13,24 +13,14 @@ class OfficeSession(ApiHandler):
13 return libreoffice.collect_status()
14 if action == "home":
15 return {"ok": True, "path": document_store.default_open_path(context_id)}
16 - if action == "recent":
17 - return {"ok": True, "documents": _public_docs(document_store.get_recent_documents())}
18 - if action == "open_documents":
19 - return {"ok": True, "documents": _public_docs(document_store.get_open_documents(limit=24))}
16 if action == "desktop":
17 return self._desktop()
22 - if action == "sync_open_sessions":
23 - session_ids = input.get("session_ids")
24 - if not isinstance(session_ids, list):
25 - session_ids = []
26 - closed = document_store.sync_open_sessions(session_ids)
27 - return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))}
18 if action == "close":
19 closed = document_store.close_session(
20 session_id=str(input.get("session_id") or ""),
21 file_id=str(input.get("file_id") or ""),
22 )
33 - return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))}
23 + return {"ok": True, "closed": closed}
24 if action == "create":
25 try:
26 doc = document_store.create_document(
@@ -65,32 +55,6 @@ class OfficeSession(ApiHandler):
55 return self._desktop_save(input)
56 if action == "desktop_sync":
57 return self._desktop_sync(input)
68 - if action == "desktop_close":
69 - return self._desktop_close(input)
70 - if action == "key":
71 - return libreofficekit_sessions.get_manager().key(
72 - str(input.get("session_id") or ""),
73 - input.get("key") if isinstance(input.get("key"), dict) else {},
74 - )
75 - if action == "mouse":
76 - return libreofficekit_sessions.get_manager().mouse(
77 - str(input.get("session_id") or ""),
78 - input.get("mouse") if isinstance(input.get("mouse"), dict) else {},
79 - )
80 - if action == "command":
81 - return libreofficekit_sessions.get_manager().command(
82 - str(input.get("session_id") or ""),
83 - str(input.get("command") or ""),
84 - arguments=input.get("arguments"),
85 - notify=bool(input.get("notify", True)),
86 - )
87 - if action == "command_values":
88 - return libreofficekit_sessions.get_manager().command_values(
89 - str(input.get("session_id") or ""),
90 - str(input.get("command") or ""),
91 - )
92 - if action == "export":
93 - return self._export(input)
58 return {"ok": False, "error": f"Unsupported office session action: {action}"}
59
60 async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
@@ -120,22 +84,21 @@ class OfficeSession(ApiHandler):
84 "extension": doc["extension"],
85 "path": doc["path"],
86 "text": "",
123 - "tiles": [],
87 "document": _public_doc(doc),
88 "version": document_store.item_version(doc),
126 - "libreoffice": libreoffice.collect_status(),
127 - "native": {"available": False, "mode": "desktop"},
89 "desktop": desktop,
90 "store_session_id": store_session["session_id"],
130 - "preview": document_store.build_preview(doc),
91 "mode": mode,
92 }
133 - editor = libreofficekit_sessions.get_manager().open(doc, sid="")
93 + try:
94 + editor = markdown_sessions.get_manager().open(doc, sid="")
95 + except ValueError as exc:
96 + document_store.close_session(session_id=store_session["session_id"])
97 + return {"ok": False, "error": str(exc)}
98 return {
99 **editor,
100 "store_session_id": store_session["session_id"],
101 "session_id": editor["session_id"],
138 - "preview": document_store.build_preview(doc),
102 "mode": mode,
103 }
104
@@ -143,7 +106,7 @@ class OfficeSession(ApiHandler):
106 session_id = str(input.get("session_id") or "").strip()
107 if not session_id:
108 return {"ok": False, "error": "session_id is required."}
146 - return libreofficekit_sessions.get_manager().save(session_id, text=input.get("text"))
109 + return markdown_sessions.get_manager().save(session_id, text=input.get("text"))
110
111 def _desktop(self) -> dict:
112 desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
@@ -162,7 +125,6 @@ class OfficeSession(ApiHandler):
125 "extension": "desktop",
126 "size": 0,
127 "version": 0,
165 - "preview": {},
128 }
129 return {
130 "ok": True,
@@ -173,14 +135,10 @@ class OfficeSession(ApiHandler):
135 "extension": "desktop",
136 "path": desktop["path"],
137 "text": "",
176 - "tiles": [],
138 "document": document,
139 "version": 0,
179 - "libreoffice": libreoffice.collect_status(),
180 - "native": {"available": False, "mode": "desktop"},
140 "desktop": desktop,
141 "store_session_id": "",
183 - "preview": {},
142 "mode": "desktop",
143 }
144
@@ -199,34 +157,10 @@ class OfficeSession(ApiHandler):
157 file_id=str(input.get("file_id") or ""),
158 )
159
202 - def _desktop_close(self, input: dict) -> dict:
203 - session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
204 - if not session_id:
205 - return {"ok": False, "error": "desktop_session_id is required."}
206 - return libreoffice_desktop.get_manager().close(
207 - session_id,
208 - save_first=bool(input.get("save_first", True)),
209 - )
210 -
211 - def _export(self, input: dict) -> dict:
212 - file_id = str(input.get("file_id") or "").strip()
213 - path = str(input.get("path") or "").strip()
214 - target_format = str(input.get("target_format") or input.get("format") or "pdf").lower().lstrip(".")
215 - doc = document_store.get_document(file_id) if file_id else document_store.register_document(path)
216 - result = libreoffice.convert_document(doc["path"], target_format)
217 - if not result.get("ok"):
218 - return result
219 - return {"ok": True, "path": document_store.display_path(result["path"]), "source": _public_doc(doc)}
220 -
160 def _origin(self, request: Request) -> str:
161 origin = request.headers.get("Origin") or request.host_url.rstrip("/")
162 return origin.rstrip("/")
163
225 -
226 -def _public_docs(docs: list[dict]) -> list[dict]:
227 - return [_public_doc(doc) for doc in docs]
228 -
229 -
164 def _public_doc(doc: dict) -> dict:
165 result = {
166 "file_id": doc["file_id"],
@@ -237,7 +171,6 @@ def _public_doc(doc: dict) -> dict:
171 "size": doc["size"],
172 "version": document_store.item_version(doc),
173 "last_modified": doc["last_modified"],
240 - "preview": doc.get("preview") or document_store.build_preview(doc),
174 }
175 for key in ("open_sessions", "last_opened_at", "session_expires_at"):
176 if key in doc:
plugins/_office/api/ws_office.py
+6 -41
@@ -4,12 +4,12 @@ from typing import Any
4
5 from helpers.ws import WsHandler
6 from helpers.ws_manager import WsResult
7 -from plugins._office.helpers import document_store, libreofficekit_sessions
7 +from plugins._office.helpers import document_store, markdown_sessions
8
9
10 class WsOffice(WsHandler):
11 async def on_disconnect(self, sid: str) -> None:
12 - libreofficekit_sessions.get_manager().close_sid(sid)
12 + markdown_sessions.get_manager().close_sid(sid)
13
14 async def process(self, event: str, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult | None:
15 if not event.startswith("office_"):
@@ -18,53 +18,18 @@ class WsOffice(WsHandler):
18 if event == "office_open":
19 return self._open(data, sid)
20 if event == "office_input":
21 - return libreofficekit_sessions.get_manager().input(
21 + return markdown_sessions.get_manager().input(
22 str(data.get("session_id") or ""),
23 text=data.get("text") if "text" in data else None,
24 patch=data.get("patch") if isinstance(data.get("patch"), dict) else None,
25 )
26 - if event == "office_key":
27 - return libreofficekit_sessions.get_manager().key(
28 - str(data.get("session_id") or ""),
29 - data.get("key") if isinstance(data.get("key"), dict) else {},
30 - )
31 - if event == "office_mouse":
32 - return libreofficekit_sessions.get_manager().mouse(
33 - str(data.get("session_id") or ""),
34 - data.get("mouse") if isinstance(data.get("mouse"), dict) else {},
35 - )
36 - if event == "office_cursor":
37 - return libreofficekit_sessions.get_manager().cursor(
38 - str(data.get("session_id") or ""),
39 - data.get("cursor") if isinstance(data.get("cursor"), dict) else {},
40 - )
41 - if event == "office_selection":
42 - return libreofficekit_sessions.get_manager().selection(
43 - str(data.get("session_id") or ""),
44 - data.get("selection") if isinstance(data.get("selection"), dict) else {},
45 - )
46 - if event == "office_invalidated_tiles":
47 - session_id = str(data.get("session_id") or "")
48 - return {"session_id": session_id, "tiles": libreofficekit_sessions.get_manager().tiles(session_id)}
49 - if event == "office_command":
50 - return libreofficekit_sessions.get_manager().command(
51 - str(data.get("session_id") or ""),
52 - str(data.get("command") or ""),
53 - arguments=data.get("arguments"),
54 - notify=bool(data.get("notify", True)),
55 - )
56 - if event == "office_command_values":
57 - return libreofficekit_sessions.get_manager().command_values(
58 - str(data.get("session_id") or ""),
59 - str(data.get("command") or ""),
60 - )
26 if event == "office_save":
62 - return libreofficekit_sessions.get_manager().save(
27 + return markdown_sessions.get_manager().save(
28 str(data.get("session_id") or ""),
29 text=data.get("text") if "text" in data else None,
30 )
31 if event == "office_close":
67 - return libreofficekit_sessions.get_manager().close(str(data.get("session_id") or ""))
32 + return markdown_sessions.get_manager().close(str(data.get("session_id") or ""))
33 except FileNotFoundError as exc:
34 return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
35 except Exception as exc:
@@ -92,4 +57,4 @@ class WsOffice(WsHandler):
57 content=str(data.get("content") or ""),
58 context_id=context_id,
59 )
95 - return libreofficekit_sessions.get_manager().open(doc, sid=sid)
60 + return markdown_sessions.get_manager().open(doc, sid=sid)
plugins/_office/helpers/artifact_editor.py
+2 -2
@@ -110,9 +110,9 @@ def edit_artifact(
110
111 def _refresh_open_editor_sessions(file_id: str) -> None:
112 try:
113 - from plugins._office.helpers import libreofficekit_sessions
113 + from plugins._office.helpers import markdown_sessions
114
115 - libreofficekit_sessions.get_manager().refresh_document(file_id)
115 + markdown_sessions.get_manager().refresh_document(file_id)
116 except Exception:
117 # Direct artifact edits should never fail just because no canvas is open.
118 return
plugins/_office/helpers/document_store.py
+1 -206
@@ -10,7 +10,6 @@ import sqlite3
10 import time
11 import uuid
12 import zipfile
13 -import xml.etree.ElementTree as ET
13 from contextlib import contextmanager
14 from pathlib import Path
15 from typing import Any
@@ -23,16 +22,7 @@ from plugins._office.helpers import pptx_writer
22 PLUGIN_NAME = "_office"
23 SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"}
24 DEFAULT_TTL_SECONDS = 8 * 60 * 60
26 -ORPHAN_SESSION_GRACE_SECONDS = 30
25 MAX_SAVE_BYTES = 512 * 1024 * 1024
28 -PREVIEW_LINE_LIMIT = 5
29 -PREVIEW_ROW_LIMIT = 5
30 -PREVIEW_COLUMN_LIMIT = 4
31 -PREVIEW_SLIDE_LIMIT = 2
32 -
33 -W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
34 -A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
35 -X_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
26
27 STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
28 DB_PATH = STATE_DIR / "documents.sqlite3"
@@ -278,18 +268,6 @@ def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[s
268 return _fetch(active)
269
270
281 -def get_recent_documents(limit: int = 12, include_preview: bool = True) -> list[dict[str, Any]]:
282 - with connect() as conn:
283 - rows = conn.execute(
284 - "SELECT * FROM documents ORDER BY updated_at DESC LIMIT ?",
285 - (limit,),
286 - ).fetchall()
287 - documents = [dict(row) for row in rows]
288 - if include_preview:
289 - return [with_preview(document) for document in documents]
290 - return documents
291 -
292 -
271 def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
272 with connect() as conn:
273 _clear_expired_sessions(conn)
@@ -309,7 +287,7 @@ def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
287 """,
288 (now(), limit),
289 ).fetchall()
312 - return [with_preview(dict(row)) for row in rows]
290 + return [dict(row) for row in rows]
291
292
293 def create_session(
@@ -365,121 +343,11 @@ def close_session(session_id: str = "", file_id: str = "") -> int:
343 return len(rows)
344
345
368 -def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str]) -> int:
369 - active_ids = {str(session_id).strip() for session_id in active_session_ids if str(session_id).strip()}
370 - with connect() as conn:
371 - _clear_expired_sessions(conn)
372 - cutoff = now() - ORPHAN_SESSION_GRACE_SECONDS
373 - if active_ids:
374 - placeholders = ",".join("?" for _ in active_ids)
375 - rows = conn.execute(
376 - f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders}) AND created_at < ?",
377 - (*tuple(active_ids), cutoff),
378 - ).fetchall()
379 - else:
380 - rows = conn.execute("SELECT session_id, file_id FROM sessions WHERE created_at < ?", (cutoff,)).fetchall()
381 -
382 - if not rows:
383 - return 0
384 -
385 - session_ids = tuple(row["session_id"] for row in rows)
386 - placeholders = ",".join("?" for _ in session_ids)
387 - conn.execute(f"DELETE FROM sessions WHERE session_id IN ({placeholders})", session_ids)
388 - for row in rows:
389 - conn.execute(
390 - "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
391 - (row["file_id"], "close_orphan_session", json.dumps({"session_id": row["session_id"]}), now()),
392 - )
393 - return len(rows)
394 -
395 -
396 -def with_preview(document: dict[str, Any]) -> dict[str, Any]:
397 - return {**document, "preview": build_preview(document)}
398 -
399 -
400 -def build_preview(document: dict[str, Any]) -> dict[str, Any]:
401 - ext = str(document.get("extension") or "").lower()
402 - path = Path(str(document.get("path") or ""))
403 - preview = {
404 - "available": False,
405 - "kind": _preview_kind(ext),
406 - "lines": [],
407 - "rows": [],
408 - "slides": [],
409 - }
410 - if not path.exists():
411 - return preview
412 - try:
413 - if ext == "md":
414 - lines = _preview_markdown(path)
415 - return {**preview, "available": bool(lines), "lines": lines}
416 - if ext == "docx":
417 - lines = _preview_docx(path)
418 - return {**preview, "available": bool(lines), "lines": lines}
419 - if ext == "xlsx":
420 - rows = _preview_xlsx(path)
421 - return {**preview, "available": bool(rows), "rows": rows}
422 - if ext == "pptx":
423 - slides = _preview_pptx(path)
424 - return {**preview, "available": bool(slides), "slides": slides}
425 - except Exception:
426 - return preview
427 - return preview
428 -
429 -
430 -def _preview_kind(ext: str) -> str:
431 - if ext == "xlsx":
432 - return "spreadsheet"
433 - if ext == "pptx":
434 - return "presentation"
435 - if ext in {"md", "docx"}:
436 - return "document"
437 - return "file"
438 -
439 -
440 -def _qn(namespace: str, tag: str) -> str:
441 - return f"{{{namespace}}}{tag}"
442 -
443 -
444 -def _clean_preview_text(value: Any) -> str:
445 - return re.sub(r"\s+", " ", str(value or "")).strip()
446 -
447 -
448 -def _preview_markdown(path: Path) -> list[str]:
449 - lines = []
450 - for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
451 - text = _clean_preview_text(raw.lstrip("#>-*0123456789.[]() "))
452 - if text:
453 - lines.append(text)
454 - if len(lines) >= PREVIEW_LINE_LIMIT:
455 - break
456 - return lines
457 -
458 -
459 -def _preview_docx(path: Path) -> list[str]:
460 - return _docx_paragraphs(path, limit=PREVIEW_LINE_LIMIT)
461 -
462 -
463 -def _docx_paragraphs(path: Path, limit: int | None = None) -> list[str]:
464 - with zipfile.ZipFile(path) as archive:
465 - root = ET.fromstring(archive.read("word/document.xml"))
466 - lines = []
467 - for paragraph in root.iter(_qn(W_NS, "p")):
468 - text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(W_NS, "t"))))
469 - if text:
470 - lines.append(text)
471 - if limit is not None and len(lines) >= limit:
472 - break
473 - return lines
474 -
475 -
346 def read_text_for_editor(doc: dict[str, Any]) -> str:
347 path = Path(doc["path"])
348 ext = str(doc["extension"]).lower()
349 if ext == "md":
350 return path.read_text(encoding="utf-8", errors="replace")
481 - if ext == "docx":
482 - return "\n\n".join(_docx_paragraphs(path))
351 raise ValueError(f"Text editing is not available for .{ext}.")
352
353
@@ -487,79 +355,6 @@ def write_markdown(file_id: str, content: str) -> dict[str, Any]:
355 return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="office:markdown")
356
357
490 -def _preview_xlsx(path: Path) -> list[list[str]]:
491 - with zipfile.ZipFile(path) as archive:
492 - shared_strings = _xlsx_shared_strings(archive)
493 - sheet_names = sorted(
494 - (name for name in archive.namelist() if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)),
495 - key=_natural_name_key,
496 - )
497 - if not sheet_names:
498 - return []
499 - root = ET.fromstring(archive.read(sheet_names[0]))
500 -
501 - rows = []
502 - for row in root.iter(_qn(X_NS, "row")):
503 - cells = []
504 - for cell in list(row)[:PREVIEW_COLUMN_LIMIT]:
505 - cells.append(_xlsx_cell_preview(cell, shared_strings))
506 - if any(cells):
507 - rows.append(cells)
508 - if len(rows) >= PREVIEW_ROW_LIMIT:
509 - break
510 - return rows
511 -
512 -
513 -def _xlsx_shared_strings(archive: zipfile.ZipFile) -> list[str]:
514 - try:
515 - root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
516 - except KeyError:
517 - return []
518 - strings = []
519 - for item in root.iter(_qn(X_NS, "si")):
520 - strings.append(_clean_preview_text("".join(node.text or "" for node in item.iter(_qn(X_NS, "t")))))
521 - return strings
522 -
523 -
524 -def _xlsx_cell_preview(cell: ET.Element, shared_strings: list[str]) -> str:
525 - cell_type = cell.attrib.get("t", "")
526 - if cell_type == "inlineStr":
527 - return _clean_preview_text("".join(node.text or "" for node in cell.iter(_qn(X_NS, "t"))))
528 - value_node = cell.find(_qn(X_NS, "v"))
529 - value = _clean_preview_text(value_node.text if value_node is not None else "")
530 - if cell_type == "s":
531 - try:
532 - return shared_strings[int(value)]
533 - except (ValueError, IndexError):
534 - return value
535 - if cell_type == "b":
536 - return "TRUE" if value == "1" else "FALSE"
537 - return value
538 -
539 -
540 -def _preview_pptx(path: Path) -> list[dict[str, Any]]:
541 - with zipfile.ZipFile(path) as archive:
542 - names = sorted(
543 - (name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)),
544 - key=_natural_name_key,
545 - )
546 - slides = []
547 - for name in names[:PREVIEW_SLIDE_LIMIT]:
548 - root = ET.fromstring(archive.read(name))
549 - lines = []
550 - for paragraph in root.iter(_qn(A_NS, "p")):
551 - text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(A_NS, "t"))))
552 - if text:
553 - lines.append(text)
554 - if lines:
555 - slides.append({"title": lines[0], "lines": lines[1:PREVIEW_LINE_LIMIT]})
556 - return slides
557 -
558 -
559 -def _natural_name_key(value: str) -> list[int | str]:
560 - return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)]
561 -
562 -
358 def replace_document_bytes(
359 file_id: str,
360 data: bytes,
plugins/_office/helpers/libreoffice.py
-26
@@ -3,10 +3,8 @@ from __future__ import annotations
3 import os
4 import shutil
5 import subprocess
6 -import sys
6 import tempfile
7 import zipfile
9 -from functools import lru_cache
8 from pathlib import Path
9 from typing import Any
10
@@ -30,7 +28,6 @@ def collect_status() -> dict[str, Any]:
28 "state": "healthy" if soffice else "missing",
29 "healthy": bool(soffice),
30 "soffice": soffice,
33 - "libreofficekit": _libreofficekit_available(),
31 "message": "LibreOffice is available." if soffice else "LibreOffice is not installed in this runtime.",
32 }
33 try:
@@ -42,29 +39,6 @@ def collect_status() -> dict[str, Any]:
39 return status
40
41
45 -@lru_cache(maxsize=1)
46 -def _libreofficekit_available() -> bool:
47 - system_dist_packages = Path("/usr/lib/python3/dist-packages")
48 - if system_dist_packages.exists() and str(system_dist_packages) not in sys.path:
49 - sys.path.append(str(system_dist_packages))
50 - try:
51 - import gi # type: ignore
52 -
53 - gi.require_version("LOKDocView", "0.1")
54 - return True
55 - except Exception:
56 - return _lokdocview_typelib_available()
57 -
58 -
59 -def _lokdocview_typelib_available() -> bool:
60 - candidates = [
61 - Path("/usr/lib/x86_64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"),
62 - Path("/usr/lib/aarch64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"),
63 - Path("/usr/share/gir-1.0/LOKDocView-0.1.gir"),
64 - ]
65 - return any(path.exists() for path in candidates)
66 -
67 -
42 def validate_docx(path: str | Path) -> dict[str, Any]:
43 source = Path(path)
44 if not source.exists():
plugins/_office/helpers/libreofficekit_native.py deleted
-423
@@ -1,423 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import ctypes
4 -import atexit
5 -import base64
6 -import math
7 -import json
8 -import os
9 -import shutil
10 -import struct
11 -import tempfile
12 -import zlib
13 -from pathlib import Path
14 -from typing import Any
15 -
16 -
17 -PROGRAM_DIR = Path(os.environ.get("A0_LIBREOFFICE_PROGRAM_DIR") or "/usr/lib/libreoffice/program")
18 -MERGED_LIBRARY = PROGRAM_DIR / "libmergedlo.so"
19 -DEFAULT_TILE_WIDTH_PX = 920
20 -MAX_TILE_HEIGHT_PX = 1800
21 -MAX_TILES = 12
22 -
23 -
24 -class LibreOfficeKitNativeError(RuntimeError):
25 - pass
26 -
27 -
28 -class _Office(ctypes.Structure):
29 - pass
30 -
31 -
32 -class _OfficeClass(ctypes.Structure):
33 - pass
34 -
35 -
36 -class _Document(ctypes.Structure):
37 - pass
38 -
39 -
40 -class _DocumentClass(ctypes.Structure):
41 - pass
42 -
43 -
44 -_OfficePtr = ctypes.POINTER(_Office)
45 -_DocumentPtr = ctypes.POINTER(_Document)
46 -
47 -_DestroyOffice = ctypes.CFUNCTYPE(None, _OfficePtr)
48 -_DocumentLoad = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p)
49 -_GetError = ctypes.CFUNCTYPE(ctypes.c_char_p, _OfficePtr)
50 -_DocumentLoadWithOptions = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p, ctypes.c_char_p)
51 -_FreeError = ctypes.CFUNCTYPE(None, ctypes.c_char_p)
52 -
53 -_DestroyDocument = ctypes.CFUNCTYPE(None, _DocumentPtr)
54 -_SaveAs = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p)
55 -_GetDocumentType = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
56 -_GetParts = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
57 -_GetPartPageRectangles = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr)
58 -_GetPart = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
59 -_SetPart = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int)
60 -_GetPartName = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_int)
61 -_SetPartMode = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int)
62 -_PaintTile = ctypes.CFUNCTYPE(
63 - None,
64 - _DocumentPtr,
65 - ctypes.POINTER(ctypes.c_ubyte),
66 - ctypes.c_int,
67 - ctypes.c_int,
68 - ctypes.c_int,
69 - ctypes.c_int,
70 - ctypes.c_int,
71 - ctypes.c_int,
72 -)
73 -_GetTileMode = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
74 -_GetDocumentSize = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.POINTER(ctypes.c_long), ctypes.POINTER(ctypes.c_long))
75 -_InitializeForRendering = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p)
76 -_RegisterDocumentCallback = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_void_p, ctypes.c_void_p)
77 -_PostKeyEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
78 -_PostMouseEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int)
79 -_PostUnoCommand = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool)
80 -_SetTextSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
81 -_GetTextSelection = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p))
82 -_Paste = ctypes.CFUNCTYPE(ctypes.c_bool, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t)
83 -_SetGraphicSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
84 -_ResetSelection = ctypes.CFUNCTYPE(None, _DocumentPtr)
85 -_GetCommandValues = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p)
86 -
87 -
88 -_Office._fields_ = [("pClass", ctypes.POINTER(_OfficeClass))]
89 -_OfficeClass._fields_ = [
90 - ("nSize", ctypes.c_size_t),
91 - ("destroy", _DestroyOffice),
92 - ("documentLoad", _DocumentLoad),
93 - ("getError", _GetError),
94 - ("documentLoadWithOptions", _DocumentLoadWithOptions),
95 - ("freeError", _FreeError),
96 -]
97 -
98 -_Document._fields_ = [("pClass", ctypes.POINTER(_DocumentClass))]
99 -_DocumentClass._fields_ = [
100 - ("nSize", ctypes.c_size_t),
101 - ("destroy", _DestroyDocument),
102 - ("saveAs", _SaveAs),
103 - ("getDocumentType", _GetDocumentType),
104 - ("getParts", _GetParts),
105 - ("getPartPageRectangles", _GetPartPageRectangles),
106 - ("getPart", _GetPart),
107 - ("setPart", _SetPart),
108 - ("getPartName", _GetPartName),
109 - ("setPartMode", _SetPartMode),
110 - ("paintTile", _PaintTile),
111 - ("getTileMode", _GetTileMode),
112 - ("getDocumentSize", _GetDocumentSize),
113 - ("initializeForRendering", _InitializeForRendering),
114 - ("registerCallback", _RegisterDocumentCallback),
115 - ("postKeyEvent", _PostKeyEvent),
116 - ("postMouseEvent", _PostMouseEvent),
117 - ("postUnoCommand", _PostUnoCommand),
118 - ("setTextSelection", _SetTextSelection),
119 - ("getTextSelection", _GetTextSelection),
120 - ("paste", _Paste),
121 - ("setGraphicSelection", _SetGraphicSelection),
122 - ("resetSelection", _ResetSelection),
123 - ("getCommandValues", _GetCommandValues),
124 -]
125 -
126 -
127 -def available() -> bool:
128 - return PROGRAM_DIR.exists() and MERGED_LIBRARY.exists() and os.environ.get("A0_OFFICE_DISABLE_NATIVE_LOK") != "1"
129 -
130 -
131 -def open_document(path: str | Path) -> Any:
132 - from plugins._office.helpers import libreofficekit_worker
133 -
134 - return libreofficekit_worker.open_document(path)
135 -
136 -
137 -def open_document_in_process(path: str | Path) -> "NativeLokDocument":
138 - return get_office().open_document(path)
139 -
140 -
141 -def get_office() -> "NativeLokOffice":
142 - global _office
143 - try:
144 - return _office
145 - except NameError:
146 - _office = NativeLokOffice()
147 - atexit.register(_close_global_office)
148 - return _office
149 -
150 -
151 -def _close_global_office() -> None:
152 - office = globals().get("_office")
153 - if office:
154 - try:
155 - office.close()
156 - except Exception:
157 - pass
158 -
159 -
160 -class NativeLokOffice:
161 - def __init__(self) -> None:
162 - if not available():
163 - raise LibreOfficeKitNativeError("LibreOfficeKit native library is not available.")
164 -
165 - os.environ.setdefault("HOME", "/tmp")
166 - os.environ.setdefault("SAL_USE_VCLPLUGIN", "gen")
167 - self._profile_dir = Path(tempfile.mkdtemp(prefix="a0-lok-profile-"))
168 - self._library = ctypes.CDLL(str(MERGED_LIBRARY))
169 - self._library.libreofficekit_hook_2.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
170 - self._library.libreofficekit_hook_2.restype = _OfficePtr
171 - profile_url = f"file://{self._profile_dir}".encode("utf-8")
172 - self._office = self._library.libreofficekit_hook_2(str(PROGRAM_DIR).encode("utf-8"), profile_url)
173 - if not self._office:
174 - raise LibreOfficeKitNativeError("LibreOfficeKit hook returned no office instance.")
175 -
176 - def open_document(self, path: str | Path) -> "NativeLokDocument":
177 - source = Path(path)
178 - if not source.exists():
179 - raise FileNotFoundError(str(source))
180 - loaded = self._office.contents.pClass.contents.documentLoad(self._office, str(source).encode("utf-8"))
181 - if not loaded:
182 - raise LibreOfficeKitNativeError(self.error() or f"LibreOfficeKit could not load {source}.")
183 - document = NativeLokDocument(loaded, source)
184 - document.initialize_for_rendering()
185 - return document
186 -
187 - def error(self) -> str:
188 - get_error = self._office.contents.pClass.contents.getError
189 - if not get_error:
190 - return ""
191 - value = get_error(self._office)
192 - return _decode_c_string(value)
193 -
194 - def close(self) -> None:
195 - office = getattr(self, "_office", None)
196 - if office:
197 - office.contents.pClass.contents.destroy(office)
198 - self._office = None
199 -
200 -
201 -class NativeLokDocument:
202 - def __init__(self, document: _DocumentPtr, path: Path) -> None:
203 - self._document = document
204 - self.path = path
205 -
206 - @property
207 - def _class(self) -> _DocumentClass:
208 - return self._document.contents.pClass.contents
209 -
210 - def initialize_for_rendering(self) -> None:
211 - self._class.initializeForRendering(self._document, None)
212 -
213 - def metadata(self) -> dict[str, Any]:
214 - width = ctypes.c_long()
215 - height = ctypes.c_long()
216 - self._class.getDocumentSize(self._document, ctypes.byref(width), ctypes.byref(height))
217 - page_rectangles = self.page_rectangles(width.value, height.value)
218 - return {
219 - "available": True,
220 - "doctype": self._class.getDocumentType(self._document),
221 - "parts": self._class.getParts(self._document),
222 - "part": self._class.getPart(self._document),
223 - "tile_mode": self._class.getTileMode(self._document),
224 - "width_twips": int(width.value),
225 - "height_twips": int(height.value),
226 - "page_rectangles": page_rectangles,
227 - }
228 -
229 - def page_rectangles(self, width: int = 0, height: int = 0) -> list[dict[str, int]]:
230 - raw = self._class.getPartPageRectangles(self._document)
231 - rectangles = _parse_rectangles(_decode_c_string(raw))
232 - if rectangles:
233 - return rectangles
234 - if not width or not height:
235 - width_ref = ctypes.c_long()
236 - height_ref = ctypes.c_long()
237 - self._class.getDocumentSize(self._document, ctypes.byref(width_ref), ctypes.byref(height_ref))
238 - width = int(width_ref.value)
239 - height = int(height_ref.value)
240 - return [{"x": 0, "y": 0, "width": int(width), "height": int(height)}]
241 -
242 - def render_tiles(self, pixel_width: int = DEFAULT_TILE_WIDTH_PX, max_tiles: int = MAX_TILES) -> list[dict[str, Any]]:
243 - tile_mode = int(self._class.getTileMode(self._document))
244 - tiles: list[dict[str, Any]] = []
245 - for index, rectangle in enumerate(self.page_rectangles()[:max_tiles]):
246 - width_twips = max(1, int(rectangle["width"]))
247 - height_twips = max(1, int(rectangle["height"]))
248 - width_px = max(320, min(int(pixel_width), 1400))
249 - height_px = max(320, min(MAX_TILE_HEIGHT_PX, math.ceil(width_px * (height_twips / width_twips))))
250 - buffer = (ctypes.c_ubyte * (width_px * height_px * 4))()
251 - self._class.paintTile(
252 - self._document,
253 - buffer,
254 - width_px,
255 - height_px,
256 - int(rectangle["x"]),
257 - int(rectangle["y"]),
258 - width_twips,
259 - height_twips,
260 - )
261 - png = _png_from_lok_buffer(buffer, width_px, height_px, tile_mode)
262 - tiles.append({
263 - "index": index,
264 - "kind": "lok-tile",
265 - "width": width_px,
266 - "height": height_px,
267 - "twips": rectangle,
268 - "image": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}",
269 - })
270 - return tiles
271 -
272 - def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]:
273 - normalized = normalize_uno_command(command)
274 - payload = _encode_arguments(arguments)
275 - self._class.postUnoCommand(
276 - self._document,
277 - normalized.encode("utf-8"),
278 - payload,
279 - bool(notify),
280 - )
281 - return {"ok": True, "native": True, "command": normalized}
282 -
283 - def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]:
284 - event_type = 1 if str(kind or "").lower() in {"up", "keyup"} else 0
285 - self._class.postKeyEvent(self._document, event_type, int(char_code or 0), int(key_code or 0))
286 - return {"ok": True, "native": True, "event": "key", "type": event_type}
287 -
288 - def type_text(self, text: str) -> dict[str, Any]:
289 - inserted = 0
290 - for character in str(text or ""):
291 - code = ord(character)
292 - self._class.postKeyEvent(self._document, 0, code, code)
293 - self._class.postKeyEvent(self._document, 1, code, code)
294 - inserted += 1
295 - return {"ok": True, "native": True, "event": "text", "inserted": inserted}
296 -
297 - def post_mouse_event(
298 - self,
299 - kind: str,
300 - x: int,
301 - y: int,
302 - count: int = 1,
303 - buttons: int = 1,
304 - modifier: int = 0,
305 - ) -> dict[str, Any]:
306 - mapping = {"down": 0, "mousedown": 0, "up": 1, "mouseup": 1, "move": 2, "mousemove": 2}
307 - event_type = mapping.get(str(kind or "").lower(), 0)
308 - self._class.postMouseEvent(
309 - self._document,
310 - event_type,
311 - int(x),
312 - int(y),
313 - int(count or 1),
314 - int(buttons or 1),
315 - int(modifier or 0),
316 - )
317 - return {"ok": True, "native": True, "event": "mouse", "type": event_type}
318 -
319 - def command_values(self, command: str) -> dict[str, Any]:
320 - normalized = normalize_uno_command(command)
321 - raw = self._class.getCommandValues(self._document, normalized.encode("utf-8"))
322 - text = _decode_c_string(raw)
323 - try:
324 - parsed = json.loads(text) if text else {}
325 - except json.JSONDecodeError:
326 - parsed = {"raw": text}
327 - return {"ok": True, "native": True, "command": normalized, "values": parsed}
328 -
329 - def save_as(self, path: str | Path | None = None, fmt: str | None = None) -> bool:
330 - target = Path(path) if path else self.path
331 - result = self._class.saveAs(
332 - self._document,
333 - str(target).encode("utf-8"),
334 - fmt.encode("utf-8") if fmt else None,
335 - None,
336 - )
337 - return result != 0
338 -
339 - def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes:
340 - temp_dir = Path(tempfile.mkdtemp(prefix="a0-lok-save-"))
341 - try:
342 - target = temp_dir / f"document{suffix}"
343 - if not self.save_as(target, fmt):
344 - raise LibreOfficeKitNativeError("LibreOfficeKit saveAs failed.")
345 - return target.read_bytes()
346 - finally:
347 - shutil.rmtree(temp_dir, ignore_errors=True)
348 -
349 - def close(self) -> None:
350 - document = getattr(self, "_document", None)
351 - if document:
352 - self._class.destroy(document)
353 - self._document = None
354 -
355 -
356 -def normalize_uno_command(command: str) -> str:
357 - value = str(command or "").strip()
358 - if not value:
359 - raise ValueError("UNO command is required.")
360 - return value if value.startswith(".uno:") else f".uno:{value}"
361 -
362 -
363 -def _encode_arguments(arguments: dict[str, Any] | str | None) -> bytes | None:
364 - if arguments is None or arguments == "":
365 - return None
366 - if isinstance(arguments, str):
367 - return arguments.encode("utf-8")
368 - return json.dumps(arguments, separators=(",", ":")).encode("utf-8")
369 -
370 -
371 -def _decode_c_string(value: bytes | int | None) -> str:
372 - if not value:
373 - return ""
374 - if isinstance(value, bytes):
375 - return value.decode("utf-8", errors="replace")
376 - return ctypes.string_at(value).decode("utf-8", errors="replace")
377 -
378 -
379 -def _parse_rectangles(payload: str) -> list[dict[str, int]]:
380 - rectangles: list[dict[str, int]] = []
381 - for item in str(payload or "").split(";"):
382 - numbers = [part.strip() for part in item.split(",")]
383 - if len(numbers) < 4:
384 - continue
385 - try:
386 - x, y, width, height = [int(float(value)) for value in numbers[:4]]
387 - except ValueError:
388 - continue
389 - if width > 0 and height > 0:
390 - rectangles.append({"x": x, "y": y, "width": width, "height": height})
391 - return rectangles
392 -
393 -
394 -def _png_from_lok_buffer(buffer: Any, width: int, height: int, tile_mode: int) -> bytes:
395 - raw = bytes(buffer)
396 - rows = []
397 - stride = width * 4
398 - for y in range(height):
399 - source = raw[y * stride:(y + 1) * stride]
400 - if tile_mode == 1:
401 - row = bytearray(stride)
402 - for index in range(0, stride, 4):
403 - blue = source[index]
404 - green = source[index + 1]
405 - red = source[index + 2]
406 - alpha = source[index + 3]
407 - row[index:index + 4] = bytes((red, green, blue, alpha))
408 - source = bytes(row)
409 - rows.append(b"\x00" + source)
410 - return _png_rgba(width, height, b"".join(rows))
411 -
412 -
413 -def _png_rgba(width: int, height: int, scanlines: bytes) -> bytes:
414 - def chunk(kind: bytes, payload: bytes) -> bytes:
415 - return (
416 - struct.pack(">I", len(payload))
417 - + kind
418 - + payload
419 - + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
420 - )
421 -
422 - header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
423 - return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(scanlines, 6)) + chunk(b"IEND", b"")
plugins/_office/helpers/libreofficekit_sessions.py deleted
-348
@@ -1,348 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import time
4 -import uuid
5 -from dataclasses import dataclass, field
6 -from pathlib import Path
7 -from typing import Any
8 -
9 -from plugins._office.helpers import document_store, libreoffice, libreofficekit_native
10 -
11 -
12 -@dataclass
13 -class EditorSession:
14 - session_id: str
15 - file_id: str
16 - sid: str
17 - extension: str
18 - path: str
19 - title: str
20 - text: str = ""
21 - native_document: Any | None = None
22 - native_metadata: dict[str, Any] = field(default_factory=dict)
23 - native_error: str = ""
24 - cursor: dict[str, Any] = field(default_factory=dict)
25 - selection: dict[str, Any] = field(default_factory=dict)
26 - opened_at: float = field(default_factory=time.time)
27 - updated_at: float = field(default_factory=time.time)
28 -
29 -
30 -class LibreOfficeKitSessionManager:
31 - """Small session facade for the right canvas.
32 -
33 - The public contract is shaped around LibreOfficeKit-style events: open,
34 - input, cursor/selection, invalidated tiles, save, and close. When the native
35 - Python LOK bridge is available the rendering path can be swapped underneath
36 - this manager without changing the browser or tool APIs.
37 - """
38 -
39 - def __init__(self) -> None:
40 - self._sessions: dict[str, EditorSession] = {}
41 -
42 - def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]:
43 - ext = str(doc["extension"]).lower()
44 - session_id = uuid.uuid4().hex
45 - text = ""
46 - if ext in {"md", "docx"}:
47 - text = document_store.read_text_for_editor(doc)
48 - native_document = None
49 - native_metadata: dict[str, Any] = {}
50 - native_error = ""
51 - if ext == "docx":
52 - try:
53 - native_document = libreofficekit_native.open_document(doc["path"])
54 - native_metadata = native_document.metadata()
55 - except Exception as exc:
56 - native_error = str(exc)
57 -
58 - session = EditorSession(
59 - session_id=session_id,
60 - file_id=doc["file_id"],
61 - sid=sid,
62 - extension=ext,
63 - path=doc["path"],
64 - title=doc["basename"],
65 - text=text,
66 - native_document=native_document,
67 - native_metadata=native_metadata,
68 - native_error=native_error,
69 - )
70 - self._sessions[session_id] = session
71 - return self._payload(session, doc)
72 -
73 - def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]:
74 - session = self._require(session_id)
75 - if text is not None:
76 - session.text = str(text)
77 - elif patch:
78 - session.text = _apply_text_patch(session.text, patch)
79 - session.updated_at = time.time()
80 - return {"ok": True, "session_id": session_id, "invalidated_tiles": self.tiles(session_id)}
81 -
82 - def key(self, session_id: str, key: dict[str, Any]) -> dict[str, Any]:
83 - session = self._require(session_id)
84 - native_document = session.native_document
85 - if not native_document:
86 - return {"ok": False, "native": False, "error": session.native_error or "Native key input is not available."}
87 -
88 - text = str(key.get("text") or "")
89 - if text:
90 - result = native_document.type_text(text)
91 - else:
92 - result = native_document.post_key_event(
93 - str(key.get("type") or "down"),
94 - char_code=int(key.get("char_code") or 0),
95 - key_code=int(key.get("key_code") or 0),
96 - )
97 - session.native_metadata = native_document.metadata()
98 - session.updated_at = time.time()
99 - return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
100 -
101 - def mouse(self, session_id: str, mouse: dict[str, Any]) -> dict[str, Any]:
102 - session = self._require(session_id)
103 - native_document = session.native_document
104 - if not native_document:
105 - return {"ok": False, "native": False, "error": session.native_error or "Native mouse input is not available."}
106 -
107 - result = native_document.post_mouse_event(
108 - str(mouse.get("type") or "down"),
109 - int(mouse.get("x") or 0),
110 - int(mouse.get("y") or 0),
111 - count=int(mouse.get("count") or 1),
112 - buttons=int(mouse.get("buttons") or 1),
113 - modifier=int(mouse.get("modifier") or 0),
114 - )
115 - session.native_metadata = native_document.metadata()
116 - session.updated_at = time.time()
117 - return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
118 -
119 - def cursor(self, session_id: str, cursor: dict[str, Any]) -> dict[str, Any]:
120 - session = self._require(session_id)
121 - session.cursor = dict(cursor or {})
122 - session.updated_at = time.time()
123 - return {"ok": True, "session_id": session_id, "cursor": session.cursor}
124 -
125 - def selection(self, session_id: str, selection: dict[str, Any]) -> dict[str, Any]:
126 - session = self._require(session_id)
127 - session.selection = dict(selection or {})
128 - session.updated_at = time.time()
129 - return {"ok": True, "session_id": session_id, "selection": session.selection}
130 -
131 - def tiles(self, session_id: str) -> list[dict[str, Any]]:
132 - session = self._require(session_id)
133 - if session.extension == "docx" and session.native_document:
134 - try:
135 - return session.native_document.render_tiles()
136 - except Exception as exc:
137 - session.native_error = str(exc)
138 - if session.extension == "docx":
139 - return _docx_text_tiles(session.text)
140 - if session.extension == "md":
141 - return _markdown_text_tiles(session.text)
142 - doc = document_store.get_document(session.file_id)
143 - preview = document_store.build_preview(doc)
144 - return [{"index": 0, "kind": preview.get("kind") or "file", "preview": preview}]
145 -
146 - def save(self, session_id: str, text: str | None = None) -> dict[str, Any]:
147 - session = self._require(session_id)
148 - if text is not None:
149 - session.text = str(text)
150 -
151 - doc = document_store.get_document(session.file_id)
152 - if session.extension == "md":
153 - updated = document_store.write_markdown(session.file_id, session.text)
154 - session.updated_at = time.time()
155 - return {"ok": True, "document": _public_doc(updated), "tiles": self.tiles(session_id), "native": self._native_payload(session)}
156 -
157 - if session.extension == "docx":
158 - from plugins._office.helpers import artifact_editor
159 -
160 - if session.native_document and text is None:
161 - updated = document_store.replace_document_bytes(
162 - session.file_id,
163 - session.native_document.save_to_bytes(".docx", "docx"),
164 - actor="libreofficekit:save",
165 - invalidate_sessions=False,
166 - )
167 - else:
168 - updated, _payload = artifact_editor.edit_artifact(
169 - doc,
170 - operation="set_text",
171 - content=session.text,
172 - invalidate_sessions=False,
173 - )
174 - validation = libreoffice.validate_docx(updated["path"])
175 - if not validation.get("ok"):
176 - return {"ok": False, "error": validation.get("error") or "DOCX save verification failed."}
177 - self._reopen_native_document(session, updated["path"])
178 - session.updated_at = time.time()
179 - return {
180 - "ok": True,
181 - "document": _public_doc(updated),
182 - "tiles": self.tiles(session_id),
183 - "validation": validation,
184 - "native": self._native_payload(session),
185 - }
186 -
187 - return {"ok": False, "error": f"Canvas editing is not available for .{session.extension}."}
188 -
189 - def command(self, session_id: str, command: str, arguments: Any = None, notify: bool = True) -> dict[str, Any]:
190 - session = self._require(session_id)
191 - native_document = session.native_document
192 - if not native_document:
193 - return {
194 - "ok": False,
195 - "native": False,
196 - "error": session.native_error or f"Native LibreOfficeKit commands are not available for .{session.extension}.",
197 - }
198 - result = native_document.post_uno_command(command, arguments=arguments, notify=notify)
199 - session.native_metadata = native_document.metadata()
200 - session.updated_at = time.time()
201 - return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
202 -
203 - def command_values(self, session_id: str, command: str) -> dict[str, Any]:
204 - session = self._require(session_id)
205 - native_document = session.native_document
206 - if not native_document:
207 - return {
208 - "ok": False,
209 - "native": False,
210 - "error": session.native_error or f"Native LibreOfficeKit command values are not available for .{session.extension}.",
211 - }
212 - return native_document.command_values(command)
213 -
214 - def refresh_document(self, file_id: str) -> dict[str, Any]:
215 - normalized = str(file_id or "").strip()
216 - if not normalized:
217 - return {"ok": True, "refreshed": 0, "sessions": []}
218 - try:
219 - doc = document_store.get_document(normalized)
220 - except Exception:
221 - return {"ok": False, "refreshed": 0, "sessions": []}
222 -
223 - refreshed: list[str] = []
224 - for session in self._sessions.values():
225 - if session.file_id != normalized:
226 - continue
227 - if session.extension in {"md", "docx"}:
228 - session.text = document_store.read_text_for_editor(doc)
229 - if session.extension == "docx":
230 - self._reopen_native_document(session, doc["path"])
231 - session.updated_at = time.time()
232 - refreshed.append(session.session_id)
233 - return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
234 -
235 - def close(self, session_id: str) -> dict[str, Any]:
236 - session = self._sessions.pop(str(session_id or ""), None)
237 - if not session:
238 - return {"ok": True, "closed": 0}
239 - self._close_native_document(session)
240 - return {"ok": True, "closed": 1, "session_id": session_id}
241 -
242 - def close_sid(self, sid: str) -> int:
243 - doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid]
244 - for session_id in doomed:
245 - session = self._sessions.pop(session_id, None)
246 - if session:
247 - self._close_native_document(session)
248 - return len(doomed)
249 -
250 - def _payload(self, session: EditorSession, doc: dict[str, Any]) -> dict[str, Any]:
251 - return {
252 - "ok": True,
253 - "session_id": session.session_id,
254 - "file_id": session.file_id,
255 - "title": session.title,
256 - "extension": session.extension,
257 - "path": session.path,
258 - "text": session.text,
259 - "tiles": self.tiles(session.session_id),
260 - "document": _public_doc(doc),
261 - "version": document_store.item_version(doc),
262 - "libreoffice": libreoffice.collect_status(),
263 - "native": self._native_payload(session),
264 - }
265 -
266 - def _require(self, session_id: str) -> EditorSession:
267 - normalized = str(session_id or "").strip()
268 - session = self._sessions.get(normalized)
269 - if not session:
270 - raise FileNotFoundError(f"Editor session not found: {normalized}")
271 - return session
272 -
273 - def _native_payload(self, session: EditorSession) -> dict[str, Any]:
274 - if session.native_document:
275 - return {"available": True, **session.native_metadata}
276 - return {"available": False, "error": session.native_error}
277 -
278 - def _reopen_native_document(self, session: EditorSession, path: str) -> None:
279 - self._close_native_document(session)
280 - try:
281 - session.native_document = libreofficekit_native.open_document(path)
282 - session.native_metadata = session.native_document.metadata()
283 - session.native_error = ""
284 - except Exception as exc:
285 - session.native_document = None
286 - session.native_metadata = {}
287 - session.native_error = str(exc)
288 -
289 - def _close_native_document(self, session: EditorSession) -> None:
290 - native_document = session.native_document
291 - if native_document:
292 - try:
293 - native_document.close()
294 - except Exception:
295 - pass
296 - session.native_document = None
297 -
298 -
299 -def get_manager() -> LibreOfficeKitSessionManager:
300 - global _manager
301 - try:
302 - return _manager
303 - except NameError:
304 - _manager = LibreOfficeKitSessionManager()
305 - return _manager
306 -
307 -
308 -def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
309 - return {
310 - "file_id": doc["file_id"],
311 - "path": document_store.display_path(doc["path"]),
312 - "basename": doc["basename"],
313 - "extension": doc["extension"],
314 - "size": doc["size"],
315 - "version": document_store.item_version(doc),
316 - "last_modified": doc["last_modified"],
317 - "exists": Path(doc["path"]).exists(),
318 - }
319 -
320 -
321 -def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
322 - if "content" in patch:
323 - return str(patch.get("content") or "")
324 - start = int(patch.get("start") or 0)
325 - end = int(patch.get("end") if patch.get("end") is not None else start)
326 - replacement = str(patch.get("text") or "")
327 - start = max(0, min(len(text), start))
328 - end = max(start, min(len(text), end))
329 - return text[:start] + replacement + text[end:]
330 -
331 -
332 -def _markdown_text_tiles(text: str) -> list[dict[str, Any]]:
333 - lines = [line for line in str(text or "").splitlines() if line.strip()]
334 - return [{"index": 0, "kind": "markdown", "lines": lines[:36]}]
335 -
336 -
337 -def _docx_text_tiles(text: str) -> list[dict[str, Any]]:
338 - paragraphs = [line.strip() for line in str(text or "").splitlines() if line.strip()]
339 - if not paragraphs:
340 - paragraphs = [""]
341 - pages = []
342 - for index in range(0, len(paragraphs), 18):
343 - pages.append({
344 - "index": len(pages),
345 - "kind": "docx",
346 - "lines": paragraphs[index:index + 18],
347 - })
348 - return pages
plugins/_office/helpers/libreofficekit_worker.py deleted
-214
@@ -1,214 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import base64
4 -import json
5 -import os
6 -import select
7 -import subprocess
8 -import sys
9 -import threading
10 -import time
11 -from pathlib import Path
12 -from typing import Any
13 -
14 -
15 -REQUEST_TIMEOUT_SECONDS = 18
16 -
17 -
18 -def open_document(path: str | Path) -> "WorkerLokDocument":
19 - return WorkerLokDocument(path)
20 -
21 -
22 -class WorkerLokDocument:
23 - def __init__(self, path: str | Path) -> None:
24 - self.path = Path(path)
25 - self._counter = 0
26 - self._lock = threading.RLock()
27 - self._process = subprocess.Popen(
28 - [sys.executable, "-m", "plugins._office.helpers.libreofficekit_worker", "--worker"],
29 - cwd=str(Path(__file__).resolve().parents[3]),
30 - stdin=subprocess.PIPE,
31 - stdout=subprocess.PIPE,
32 - stderr=subprocess.PIPE,
33 - text=True,
34 - bufsize=1,
35 - env={**os.environ, "PYTHONUNBUFFERED": "1", "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN", "gen")},
36 - )
37 - opened = self._request("open", {"path": str(self.path)}, timeout=REQUEST_TIMEOUT_SECONDS)
38 - if not opened.get("ok"):
39 - raise RuntimeError(opened.get("error") or "LibreOfficeKit worker could not open document.")
40 - self._metadata = opened.get("metadata") or {}
41 -
42 - def metadata(self) -> dict[str, Any]:
43 - response = self._request("metadata")
44 - if response.get("metadata"):
45 - self._metadata = response["metadata"]
46 - return dict(self._metadata)
47 -
48 - def render_tiles(self, pixel_width: int = 920, max_tiles: int = 12) -> list[dict[str, Any]]:
49 - response = self._request("tiles", {"pixel_width": pixel_width, "max_tiles": max_tiles}, timeout=REQUEST_TIMEOUT_SECONDS)
50 - return response.get("tiles") or []
51 -
52 - def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]:
53 - return self._request("command", {"command": command, "arguments": arguments, "notify": notify})
54 -
55 - def command_values(self, command: str) -> dict[str, Any]:
56 - return self._request("command_values", {"command": command})
57 -
58 - def post_mouse_event(
59 - self,
60 - kind: str,
61 - x: int,
62 - y: int,
63 - count: int = 1,
64 - buttons: int = 1,
65 - modifier: int = 0,
66 - ) -> dict[str, Any]:
67 - return self._request("mouse", {
68 - "type": kind,
69 - "x": x,
70 - "y": y,
71 - "count": count,
72 - "buttons": buttons,
73 - "modifier": modifier,
74 - })
75 -
76 - def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]:
77 - return self._request("key", {
78 - "type": kind,
79 - "char_code": char_code,
80 - "key_code": key_code,
81 - })
82 -
83 - def type_text(self, text: str) -> dict[str, Any]:
84 - return self._request("text", {"text": text})
85 -
86 - def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes:
87 - response = self._request("save", {"suffix": suffix, "format": fmt}, timeout=REQUEST_TIMEOUT_SECONDS)
88 - data = response.get("bytes") or ""
89 - return base64.b64decode(data.encode("ascii"))
90 -
91 - def close(self) -> None:
92 - process = self._process
93 - if process.poll() is not None:
94 - return
95 - try:
96 - self._request("close", timeout=3)
97 - process.wait(timeout=3)
98 - except Exception:
99 - process.kill()
100 - process.wait(timeout=3)
101 -
102 - def _request(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]:
103 - with self._lock:
104 - return self._request_unlocked(action, payload=payload, timeout=timeout)
105 -
106 - def _request_unlocked(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]:
107 - process = self._process
108 - if process.poll() is not None:
109 - stderr = process.stderr.read() if process.stderr else ""
110 - raise RuntimeError(f"LibreOfficeKit worker exited with {process.returncode}: {stderr.strip()}")
111 - self._counter += 1
112 - message = {"id": self._counter, "action": action, **(payload or {})}
113 - assert process.stdin is not None
114 - process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
115 - process.stdin.flush()
116 - assert process.stdout is not None
117 - deadline = time.time() + timeout
118 - while time.time() < deadline:
119 - ready, _, _ = select.select([process.stdout], [], [], max(0.05, min(0.5, deadline - time.time())))
120 - if not ready:
121 - continue
122 - line = process.stdout.readline()
123 - if not line:
124 - break
125 - try:
126 - response = json.loads(line)
127 - except json.JSONDecodeError:
128 - continue
129 - if response.get("id") == self._counter:
130 - if response.get("ok") is False:
131 - raise RuntimeError(response.get("error") or f"LibreOfficeKit worker {action} failed.")
132 - return response
133 - process.kill()
134 - raise TimeoutError(f"LibreOfficeKit worker timed out during {action}.")
135 -
136 -
137 -def _worker_loop() -> None:
138 - from plugins._office.helpers import libreofficekit_native
139 -
140 - document = None
141 - for line in sys.stdin:
142 - try:
143 - request = json.loads(line)
144 - action = request.get("action")
145 - if action == "open":
146 - document = libreofficekit_native.open_document_in_process(request["path"])
147 - _respond(request, {"ok": True, "metadata": document.metadata()})
148 - elif not document:
149 - _respond(request, {"ok": False, "error": "Document is not open."})
150 - elif action == "metadata":
151 - _respond(request, {"ok": True, "metadata": document.metadata()})
152 - elif action == "tiles":
153 - _respond(request, {
154 - "ok": True,
155 - "tiles": document.render_tiles(
156 - pixel_width=int(request.get("pixel_width") or 920),
157 - max_tiles=int(request.get("max_tiles") or 12),
158 - ),
159 - })
160 - elif action == "command":
161 - result = document.post_uno_command(
162 - str(request.get("command") or ""),
163 - arguments=request.get("arguments"),
164 - notify=bool(request.get("notify", True)),
165 - )
166 - _respond(request, {"ok": True, **result, "metadata": document.metadata()})
167 - elif action == "command_values":
168 - _respond(request, document.command_values(str(request.get("command") or "")))
169 - elif action == "mouse":
170 - result = document.post_mouse_event(
171 - str(request.get("type") or "down"),
172 - int(request.get("x") or 0),
173 - int(request.get("y") or 0),
174 - count=int(request.get("count") or 1),
175 - buttons=int(request.get("buttons") or 1),
176 - modifier=int(request.get("modifier") or 0),
177 - )
178 - _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
179 - elif action == "key":
180 - result = document.post_key_event(
181 - str(request.get("type") or "down"),
182 - char_code=int(request.get("char_code") or 0),
183 - key_code=int(request.get("key_code") or 0),
184 - )
185 - _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
186 - elif action == "text":
187 - result = document.type_text(str(request.get("text") or ""))
188 - _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
189 - elif action == "save":
190 - data = document.save_to_bytes(
191 - suffix=str(request.get("suffix") or ".docx"),
192 - fmt=request.get("format") or "docx",
193 - )
194 - _respond(request, {"ok": True, "bytes": base64.b64encode(data).decode("ascii"), "metadata": document.metadata()})
195 - elif action == "close":
196 - if document:
197 - document.close()
198 - _respond(request, {"ok": True, "closed": True})
199 - sys.stdout.flush()
200 - os._exit(0)
201 - else:
202 - _respond(request, {"ok": False, "error": f"Unknown worker action: {action}"})
203 - except Exception as exc:
204 - _respond(json.loads(line) if line.strip().startswith("{") else {}, {"ok": False, "error": str(exc)})
205 - os._exit(0)
206 -
207 -
208 -def _respond(request: dict[str, Any], response: dict[str, Any]) -> None:
209 - sys.stdout.write(json.dumps({"id": request.get("id"), **response}, separators=(",", ":")) + "\n")
210 - sys.stdout.flush()
211 -
212 -
213 -if __name__ == "__main__" and "--worker" in sys.argv:
214 - _worker_loop()
plugins/_office/helpers/markdown_sessions.py new
+157
@@ -0,0 +1,157 @@
1 +from __future__ import annotations
2 +
3 +import time
4 +import uuid
5 +from dataclasses import dataclass, field
6 +from pathlib import Path
7 +from typing import Any
8 +
9 +from plugins._office.helpers import document_store
10 +
11 +
12 +@dataclass
13 +class MarkdownSession:
14 + session_id: str
15 + file_id: str
16 + sid: str
17 + extension: str
18 + path: str
19 + title: str
20 + text: str = ""
21 + opened_at: float = field(default_factory=time.time)
22 + updated_at: float = field(default_factory=time.time)
23 +
24 +
25 +class MarkdownSessionManager:
26 + """Owns source-editor sessions for Markdown documents."""
27 +
28 + def __init__(self) -> None:
29 + self._sessions: dict[str, MarkdownSession] = {}
30 +
31 + def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]:
32 + ext = str(doc["extension"]).lower()
33 + if ext != "md":
34 + raise ValueError(f"Canvas editing is only available for Markdown. Open .{ext} files in the Desktop.")
35 +
36 + session = MarkdownSession(
37 + session_id=uuid.uuid4().hex,
38 + file_id=doc["file_id"],
39 + sid=sid,
40 + extension=ext,
41 + path=doc["path"],
42 + title=doc["basename"],
43 + text=document_store.read_text_for_editor(doc),
44 + )
45 + self._sessions[session.session_id] = session
46 + return self._payload(session, doc)
47 +
48 + def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]:
49 + session = self._require(session_id)
50 + if text is not None:
51 + session.text = str(text)
52 + elif patch:
53 + session.text = _apply_text_patch(session.text, patch)
54 + session.updated_at = time.time()
55 + return {"ok": True, "session_id": session.session_id}
56 +
57 + def save(self, session_id: str, text: str | None = None) -> dict[str, Any]:
58 + session = self._require(session_id)
59 + if text is not None:
60 + session.text = str(text)
61 +
62 + updated = document_store.write_markdown(session.file_id, session.text)
63 + session.updated_at = time.time()
64 + session.path = updated["path"]
65 + session.title = updated["basename"]
66 + return {
67 + "ok": True,
68 + "document": _public_doc(updated),
69 + "version": document_store.item_version(updated),
70 + }
71 +
72 + def refresh_document(self, file_id: str) -> dict[str, Any]:
73 + normalized = str(file_id or "").strip()
74 + if not normalized:
75 + return {"ok": True, "refreshed": 0, "sessions": []}
76 + try:
77 + doc = document_store.get_document(normalized)
78 + except Exception:
79 + return {"ok": False, "refreshed": 0, "sessions": []}
80 + if str(doc.get("extension") or "").lower() != "md":
81 + return {"ok": True, "refreshed": 0, "sessions": []}
82 +
83 + refreshed: list[str] = []
84 + for session in self._sessions.values():
85 + if session.file_id != normalized:
86 + continue
87 + session.text = document_store.read_text_for_editor(doc)
88 + session.path = doc["path"]
89 + session.title = doc["basename"]
90 + session.updated_at = time.time()
91 + refreshed.append(session.session_id)
92 + return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
93 +
94 + def close(self, session_id: str) -> dict[str, Any]:
95 + session = self._sessions.pop(str(session_id or ""), None)
96 + if not session:
97 + return {"ok": True, "closed": 0}
98 + return {"ok": True, "closed": 1, "session_id": session_id}
99 +
100 + def close_sid(self, sid: str) -> int:
101 + doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid]
102 + for session_id in doomed:
103 + self._sessions.pop(session_id, None)
104 + return len(doomed)
105 +
106 + def _payload(self, session: MarkdownSession, doc: dict[str, Any]) -> dict[str, Any]:
107 + return {
108 + "ok": True,
109 + "session_id": session.session_id,
110 + "file_id": session.file_id,
111 + "title": session.title,
112 + "extension": session.extension,
113 + "path": session.path,
114 + "text": session.text,
115 + "document": _public_doc(doc),
116 + "version": document_store.item_version(doc),
117 + }
118 +
119 + def _require(self, session_id: str) -> MarkdownSession:
120 + normalized = str(session_id or "").strip()
121 + session = self._sessions.get(normalized)
122 + if not session:
123 + raise FileNotFoundError(f"Editor session not found: {normalized}")
124 + return session
125 +
126 +
127 +def get_manager() -> MarkdownSessionManager:
128 + global _manager
129 + try:
130 + return _manager
131 + except NameError:
132 + _manager = MarkdownSessionManager()
133 + return _manager
134 +
135 +
136 +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
137 + return {
138 + "file_id": doc["file_id"],
139 + "path": document_store.display_path(doc["path"]),
140 + "basename": doc["basename"],
141 + "extension": doc["extension"],
142 + "size": doc["size"],
143 + "version": document_store.item_version(doc),
144 + "last_modified": doc["last_modified"],
145 + "exists": Path(doc["path"]).exists(),
146 + }
147 +
148 +
149 +def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
150 + if "content" in patch:
151 + return str(patch.get("content") or "")
152 + start = int(patch.get("start") or 0)
153 + end = int(patch.get("end") if patch.get("end") is not None else start)
154 + replacement = str(patch.get("text") or "")
155 + start = max(0, min(len(text), start))
156 + end = max(start, min(len(text), end))
157 + return text[:start] + replacement + text[end:]
plugins/_office/hooks.py
-4
@@ -43,10 +43,6 @@ RUNTIME_PACKAGES = (
43 "libreoffice-calc",
44 "libreoffice-impress",
45 "libreoffice-gtk3",
46 - "libreofficekit-data",
47 - "libreofficekit-dev",
48 - "gir1.2-lokdocview-0.1",
49 - "python3-gi",
46 "python3-uno",
47 "xpra",
48 "xpra-x11",
plugins/_office/webui/office-panel.html
+116 -558
@@ -9,99 +9,71 @@
9 <template x-if="$store.office">
10 <div class="office-shell">
11 <div class="office-toolbar">
12 - <div class="office-tool-group">
13 - <button type="button" class="office-icon-button office-command-button" aria-label="Open" @click="$store.office.openPrompt()">
14 - <span class="material-symbols-outlined">folder_open</span>
15 - <span class="office-button-label">Open</span>
16 - </button>
17 - </div>
12 + <div class="office-toolbar-row is-primary">
13 + <div class="office-tool-group">
14 + <button type="button" class="office-icon-button" title="Open" aria-label="Open" @click="$store.office.openFileBrowser()">
15 + <span class="material-symbols-outlined">folder_open</span>
16 + </button>
17 + </div>
18
19 - <div class="office-tool-group">
20 - <button type="button" class="office-icon-button office-command-button" aria-label="New Markdown" @click="$store.office.create('document', 'md')">
21 - <span class="material-symbols-outlined">article</span>
22 - <span class="office-button-label">Markdown</span>
23 - </button>
24 - <button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
25 - <span class="material-symbols-outlined">description</span>
26 - <span class="office-button-label">DOCX</span>
27 - </button>
28 - <button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
29 - <span class="material-symbols-outlined">table_chart</span>
30 - <span class="office-button-label">Spreadsheet</span>
31 - </button>
32 - <button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
33 - <span class="material-symbols-outlined">co_present</span>
34 - <span class="office-button-label">Presentation</span>
35 - </button>
36 - </div>
19 + <div class="office-tool-group">
20 + <button type="button" class="office-icon-button office-command-button" aria-label="New Markdown" @click="$store.office.create('document', 'md')">
21 + <span class="material-symbols-outlined">article</span>
22 + <span class="office-button-label">Markdown</span>
23 + </button>
24 + <button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
25 + <span class="material-symbols-outlined">description</span>
26 + <span class="office-button-label">DOCX</span>
27 + </button>
28 + <button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
29 + <span class="material-symbols-outlined">table_chart</span>
30 + <span class="office-button-label">Spreadsheet</span>
31 + </button>
32 + <button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
33 + <span class="material-symbols-outlined">co_present</span>
34 + <span class="office-button-label">Presentation</span>
35 + </button>
36 + </div>
37
38 - <div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
39 - <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
40 - <span class="material-symbols-outlined">undo</span>
41 - </button>
42 - <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
43 - <span class="material-symbols-outlined">redo</span>
44 - </button>
45 - </div>
38 + <span class="office-toolbar-spacer"></span>
39
47 - <div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
48 - <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
49 - <span class="material-symbols-outlined">format_bold</span>
50 - </button>
51 - <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
52 - <span class="material-symbols-outlined">format_italic</span>
53 - </button>
54 - <button type="button" class="office-icon-button" title="Underline" aria-label="Underline" @click="$store.office.format('underline')">
55 - <span class="material-symbols-outlined">format_underlined</span>
56 - </button>
57 - <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
58 - <span class="material-symbols-outlined">format_list_bulleted</span>
59 - </button>
60 - <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
61 - <span class="material-symbols-outlined">format_list_numbered</span>
62 - </button>
63 - <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
64 - <span class="material-symbols-outlined">table</span>
65 - </button>
66 - <button type="button" class="office-icon-button" title="Align left" aria-label="Align left" @click="$store.office.format('alignLeft')">
67 - <span class="material-symbols-outlined">format_align_left</span>
68 - </button>
69 - <button type="button" class="office-icon-button" title="Align center" aria-label="Align center" @click="$store.office.format('alignCenter')">
70 - <span class="material-symbols-outlined">format_align_center</span>
71 - </button>
72 - <button type="button" class="office-icon-button" title="Align right" aria-label="Align right" @click="$store.office.format('alignRight')">
73 - <span class="material-symbols-outlined">format_align_right</span>
74 - </button>
75 - <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()">
76 - <span class="material-symbols-outlined">code</span>
77 - </button>
40 + <div class="office-tool-group office-tool-actions" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
41 + <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()">
42 + <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
43 + </button>
44 + </div>
45 </div>
46
80 - <div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice()" style="display: none;">
81 - <button type="button" class="office-icon-button" title="Zoom out" aria-label="Zoom out" @click="$store.office.zoomOut()">
82 - <span class="material-symbols-outlined">zoom_out</span>
83 - </button>
84 - <span class="office-zoom" x-text="$store.office.zoomLabel()"></span>
85 - <button type="button" class="office-icon-button" title="Zoom in" aria-label="Zoom in" @click="$store.office.zoomIn()">
86 - <span class="material-symbols-outlined">zoom_in</span>
87 - </button>
88 - </div>
47 + <div class="office-toolbar-row is-editor" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
48 + <div class="office-tool-group">
49 + <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
50 + <span class="material-symbols-outlined">undo</span>
51 + </button>
52 + <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
53 + <span class="material-symbols-outlined">redo</span>
54 + </button>
55 + </div>
56 +
57 + <div class="office-tool-group">
58 + <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
59 + <span class="material-symbols-outlined">format_bold</span>
60 + </button>
61 + <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
62 + <span class="material-symbols-outlined">format_italic</span>
63 + </button>
64 + </div>
65
90 - <span class="office-toolbar-spacer"></span>
91 -
92 - <div class="office-tool-group" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
93 - <button type="button" class="office-icon-button office-command-button" aria-label="Export PDF" :disabled="$store.office.loading" @click="$store.office.exportPdf()">
94 - <span class="material-symbols-outlined">picture_as_pdf</span>
95 - <span class="office-button-label">Export PDF</span>
96 - </button>
97 - <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()">
98 - <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
99 - <span class="office-button-label">Save</span>
100 - </button>
101 - <button type="button" class="office-icon-button office-command-button" aria-label="Close" @click="$confirmClick($event, () => $store.office.closeFile())">
102 - <span class="material-symbols-outlined">close</span>
103 - <span class="office-button-label">Close</span>
104 - </button>
66 + <div class="office-tool-group">
67 + <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
68 + <span class="material-symbols-outlined">format_list_bulleted</span>
69 + </button>
70 + <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
71 + <span class="material-symbols-outlined">format_list_numbered</span>
72 + </button>
73 + <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
74 + <span class="material-symbols-outlined">table</span>
75 + </button>
76 + </div>
77 </div>
78 </div>
79
@@ -124,100 +96,9 @@
96 <span x-text="$store.office.error || $store.office.message || 'Working'"></span>
97 </div>
98
127 - <div class="office-body">
128 - <div class="office-start" x-show="!$store.office.session" style="display: none;">
129 - <section class="office-dashboard-section" aria-label="Create">
130 - <div class="office-dashboard-heading">New</div>
131 - <div class="office-template-grid">
132 - <button type="button" class="office-create-tile is-markdown" @click="$store.office.create('document', 'md')">
133 - <span class="material-symbols-outlined">article</span>
134 - <strong>Markdown</strong>
135 - <small>.md</small>
136 - </button>
137 - <button type="button" class="office-create-tile is-docx" @click="$store.office.create('document', 'docx')">
138 - <span class="material-symbols-outlined">description</span>
139 - <strong>DOCX</strong>
140 - <small>.docx</small>
141 - </button>
142 - <button type="button" class="office-create-tile is-sheet" @click="$store.office.create('spreadsheet', 'xlsx')">
143 - <span class="material-symbols-outlined">table_chart</span>
144 - <strong>Sheet</strong>
145 - <small>.xlsx</small>
146 - </button>
147 - <button type="button" class="office-create-tile is-deck" @click="$store.office.create('presentation', 'pptx')">
148 - <span class="material-symbols-outlined">co_present</span>
149 - <strong>Deck</strong>
150 - <small>.pptx</small>
151 - </button>
152 - </div>
153 - </section>
154 -
155 - <section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open" style="display: none;">
156 - <div class="office-dashboard-heading">Open</div>
157 - <div class="office-card-grid">
158 - <template x-for="doc in $store.office.openCards()" :key="doc.tab_id || doc.file_id || doc.path">
159 - <button type="button" class="office-document-card is-open" :title="doc.path" @click="$store.office.selectTab(doc.tab_id)">
160 - <span class="office-card-badge">Open</span>
161 - <div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
162 - <template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
163 - <div class="office-sheet-preview">
164 - <template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
165 - <div class="office-sheet-row">
166 - <template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
167 - </div>
168 - </template>
169 - </div>
170 - </template>
171 - <template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
172 - <div class="office-page-preview">
173 - <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
174 - </div>
175 - </template>
176 - <template x-if="!$store.office.hasPreview(doc)">
177 - <div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
178 - </template>
179 - </div>
180 - <span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
181 - <small x-text="$store.office.dashboardMeta(doc)"></small>
182 - </button>
183 - </template>
184 - </div>
185 - </section>
186 -
187 - <section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent" style="display: none;">
188 - <div class="office-dashboard-heading">Recent</div>
189 - <div class="office-card-grid">
190 - <template x-for="doc in $store.office.recentCards()" :key="doc.file_id || doc.path">
191 - <button type="button" class="office-document-card" :title="doc.path" @click="$store.office.openPath(doc.path)">
192 - <div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
193 - <template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
194 - <div class="office-sheet-preview">
195 - <template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
196 - <div class="office-sheet-row">
197 - <template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
198 - </div>
199 - </template>
200 - </div>
201 - </template>
202 - <template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
203 - <div class="office-page-preview">
204 - <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
205 - </div>
206 - </template>
207 - <template x-if="!$store.office.hasPreview(doc)">
208 - <div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
209 - </template>
210 - </div>
211 - <span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
212 - <small x-text="$store.office.dashboardMeta(doc)"></small>
213 - </button>
214 - </template>
215 - </div>
216 - </section>
217 - </div>
218 -
219 - <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
220 - <div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice() }" :style="`--office-zoom: ${$store.office.zoom}`" @click.self="$store.office.focusEditor()">
99 + <div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
100 + <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
101 + <div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
102 <template x-if="$store.office.hasOfficialOffice()">
103 <div class="office-desktop-wrap">
104 <iframe
@@ -236,7 +117,7 @@
117 class="office-source-editor"
118 data-office-source
119 aria-label="Markdown source"
239 - x-show="$store.office.isMarkdown() && $store.office.sourceMode"
120 + x-show="$store.office.isMarkdown()"
121 x-model="$store.office.editorText"
122 @input="$store.office.onSourceInput()"
123 @blur="$store.office.flushInput()"
@@ -244,64 +125,6 @@
125 style="display: none;"
126 ></textarea>
127
247 - <article
248 - class="office-rich-editor"
249 - x-show="$store.office.isMarkdown() && !$store.office.sourceMode"
250 - x-init="$store.office.bindEditorElement($el, 'markdown')"
251 - contenteditable="true"
252 - tabindex="0"
253 - role="textbox"
254 - aria-label="Markdown document"
255 - spellcheck="true"
256 - @input="$store.office.onRichInput($el)"
257 - @blur="$store.office.flushInput()"
258 - style="display: none;"
259 - ></article>
260 -
261 - <div
262 - class="office-docx-stage"
263 - x-show="$store.office.isDocx() && !$store.office.hasOfficialOffice()"
264 - style="display: none;"
265 - >
266 - <div
267 - class="office-docx-pages"
268 - :class="{ 'is-native': $store.office.hasNativeDocxTiles() }"
269 - x-init="$store.office.bindEditorElement($el, 'docx')"
270 - :contenteditable="$store.office.hasNativeDocxTiles() ? 'false' : 'true'"
271 - tabindex="0"
272 - role="textbox"
273 - aria-label="DOCX document"
274 - spellcheck="true"
275 - @click="$store.office.onNativeDocxClick($event)"
276 - @keydown="$store.office.onNativeDocxKeydown($event)"
277 - @input="$store.office.onDocxInput($el)"
278 - @blur="$store.office.flushInput()"
279 - ></div>
280 - </div>
281 -
282 - <div class="office-preview-editor" x-show="$store.office.isPreviewOnly() && !$store.office.hasOfficialOffice()" style="display: none;">
283 - <div class="office-card-preview is-large" :class="`is-${$store.office.previewKind($store.office.session || {})}`">
284 - <template x-if="$store.office.previewKind($store.office.session || {}) === 'spreadsheet' && $store.office.hasPreview($store.office.session || {})">
285 - <div class="office-sheet-preview">
286 - <template x-for="(row, rowIndex) in $store.office.previewRows($store.office.session || {})" :key="rowIndex">
287 - <div class="office-sheet-row">
288 - <template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
289 - </div>
290 - </template>
291 - </div>
292 - </template>
293 - <template x-if="$store.office.previewKind($store.office.session || {}) === 'presentation'">
294 - <div class="office-slide-preview">
295 - <template x-for="(slide, index) in $store.office.previewSlides($store.office.session || {})" :key="index">
296 - <div class="office-slide-line">
297 - <strong x-text="slide.title"></strong>
298 - <span x-text="(slide.lines || []).join(' / ')"></span>
299 - </div>
300 - </template>
301 - </div>
302 - </template>
303 - </div>
304 - </div>
128 </div>
129 </div>
130 </div>
@@ -429,36 +252,47 @@
252
253 .office-toolbar {
254 display: flex;
432 - align-items: center;
255 + flex-direction: column;
256 + align-items: stretch;
257 flex-wrap: nowrap;
434 - gap: 10px;
435 - min-height: 58px;
436 - padding: 9px 12px;
437 - overflow-x: auto;
438 - overflow-y: hidden;
439 - scrollbar-width: thin;
258 + gap: 8px;
259 + min-height: 0;
260 + padding: 8px 10px;
261 + overflow: visible;
262 border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
263 background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
264 }
265
266 + .office-toolbar-row {
267 + display: flex;
268 + align-items: center;
269 + flex: 0 0 auto;
270 + flex-wrap: wrap;
271 + gap: 8px;
272 + min-width: 0;
273 + }
274 +
275 + .office-toolbar-row.is-editor {
276 + gap: 6px 8px;
277 + padding-top: 1px;
278 + }
279 +
280 .office-tool-group {
281 display: flex;
282 align-items: center;
283 + flex-wrap: wrap;
284 flex: 0 0 auto;
285 gap: 6px;
286 min-width: 0;
287 }
288
452 - .office-toolbar-spacer {
453 - flex: 1 1 auto;
454 - min-width: 8px;
289 + .office-tool-actions {
290 + justify-content: flex-end;
291 }
292
457 - .office-toolbar-divider {
458 - width: 1px;
459 - height: 24px;
460 - margin: 0 4px;
461 - background: color-mix(in srgb, var(--color-border), transparent 15%);
293 + .office-toolbar-spacer {
294 + flex: 1 1 80px;
295 + min-width: 0;
296 }
297
298 .office-icon-button,
@@ -523,20 +357,11 @@
357 }
358
359 .office-icon-button .material-symbols-outlined,
526 - .office-tab-icon,
527 - .office-create-tile .material-symbols-outlined {
360 + .office-tab-icon {
361 font-size: 21px;
362 line-height: 1;
363 }
364
532 - .office-zoom {
533 - min-width: 44px;
534 - text-align: center;
535 - font-size: 12px;
536 - color: var(--color-text-secondary);
537 - font-variant-numeric: tabular-nums;
538 - }
539 -
365 .office-tabs {
366 display: flex;
367 gap: 6px;
@@ -636,170 +461,10 @@
461 linear-gradient(90deg, rgba(44, 123, 229, 0.05), transparent 38%),
462 linear-gradient(180deg, rgba(44, 165, 141, 0.04), transparent 46%),
463 #eef2f7;
639 - color: #172033;
640 - }
641 -
642 - .office-start {
643 - flex: 1 1 auto;
644 - min-width: 0;
645 - overflow: auto;
646 - padding: 22px;
647 - }
648 -
649 - .office-dashboard-section {
650 - margin: 0 0 22px;
651 - }
652 -
653 - .office-dashboard-heading {
654 - margin: 0 0 9px;
655 - color: #536274;
656 - font-size: 12px;
657 - font-weight: 700;
658 - text-transform: uppercase;
659 - }
660 -
661 - .office-template-grid,
662 - .office-card-grid {
663 - display: grid;
664 - grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
665 - gap: 10px;
666 - }
667 -
668 - .office-create-tile {
669 - display: grid;
670 - grid-template-rows: 34px auto auto;
671 - align-items: center;
672 - min-height: 122px;
673 - padding: 14px;
674 - border: 1px solid #d9dee7;
675 - border-radius: 8px;
676 - background: #ffffff;
677 - color: #172033;
678 - box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
679 - text-align: left;
680 - transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
464 }
465
683 - .office-create-tile:hover,
684 - .office-document-card:hover {
685 - border-color: #8db5ef;
686 - box-shadow: 0 18px 42px rgba(35, 48, 68, 0.13);
687 - transform: translateY(-1px);
688 - }
689 -
690 - .office-create-tile strong,
691 - .office-card-title {
692 - min-width: 0;
693 - overflow: hidden;
694 - text-overflow: ellipsis;
695 - white-space: nowrap;
696 - font-size: 13px;
697 - }
698 -
699 - .office-create-tile small,
700 - .office-document-card small {
701 - min-width: 0;
702 - overflow: hidden;
703 - text-overflow: ellipsis;
704 - white-space: nowrap;
705 - color: #536274;
706 - font-size: 11px;
707 - }
708 -
709 - .office-create-tile.is-markdown .material-symbols-outlined { color: #2ca58d; }
710 - .office-create-tile.is-docx .material-symbols-outlined { color: #2c7be5; }
711 - .office-create-tile.is-sheet .material-symbols-outlined { color: #8f6f19; }
712 - .office-create-tile.is-deck .material-symbols-outlined { color: #b84a62; }
713 -
714 - .office-document-card {
715 - position: relative;
716 - display: grid;
717 - grid-template-rows: 118px 18px 16px;
718 - gap: 7px;
719 - min-height: 172px;
720 - padding: 10px;
721 - border: 1px solid #d9dee7;
722 - border-radius: 8px;
723 - background: #ffffff;
724 - color: #172033;
725 - box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
726 - text-align: left;
727 - transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
728 - }
729 -
730 - .office-card-badge {
731 - position: absolute;
732 - top: 9px;
733 - right: 9px;
734 - z-index: 1;
735 - border-radius: 999px;
736 - padding: 2px 7px;
737 - background: color-mix(in srgb, #2ca58d, var(--color-panel) 20%);
738 - color: white;
739 - font-size: 10px;
740 - font-weight: 700;
741 - }
742 -
743 - .office-card-preview {
744 - min-width: 0;
745 - min-height: 0;
746 - overflow: hidden;
747 - border: 1px solid #d9dee7;
748 - border-radius: 6px;
749 - background: #f8fafc;
750 - color: #172033;
751 - }
752 -
753 - .office-card-preview.is-large {
754 - width: min(720px, 100%);
755 - min-height: 340px;
756 - border-color: #d2d8e3;
757 - background: #ffffff;
758 - box-shadow: 0 18px 44px rgba(35, 48, 68, 0.12);
759 - }
760 -
761 - .office-page-preview,
762 - .office-sheet-preview,
763 - .office-slide-preview,
764 - .office-preview-fallback {
765 - display: flex;
766 - flex-direction: column;
767 - gap: 5px;
768 - height: 100%;
769 - padding: 10px;
770 - font-size: 11px;
771 - line-height: 1.35;
772 - }
773 -
774 - .office-page-preview span,
775 - .office-sheet-row span,
776 - .office-slide-line span,
777 - .office-slide-line strong {
778 - min-width: 0;
779 - overflow: hidden;
780 - text-overflow: ellipsis;
781 - white-space: nowrap;
782 - }
783 -
784 - .office-sheet-row {
785 - display: grid;
786 - grid-template-columns: repeat(3, minmax(0, 1fr));
787 - gap: 5px;
788 - }
789 -
790 - .office-sheet-row span {
791 - border-bottom: 1px solid #d9dee7;
792 - padding-bottom: 2px;
793 - }
794 -
795 - .office-preview-fallback {
796 - align-items: center;
797 - justify-content: center;
798 - color: #64748b;
799 - }
800 -
801 - .office-preview-fallback .material-symbols-outlined {
802 - font-size: 36px;
466 + .office-body.is-source {
467 + background: transparent;
468 }
469
470 .office-editor-wrap {
@@ -817,6 +482,13 @@
482 padding: 30px 24px;
483 }
484
485 + .office-editor-scroll.is-source {
486 + display: flex;
487 + overflow: hidden;
488 + padding: var(--spacing-md);
489 + background: transparent;
490 + }
491 +
492 .office-editor-scroll.is-desktop {
493 display: flex;
494 overflow: hidden;
@@ -845,136 +517,28 @@
517 background: #20242a;
518 }
519
848 - .office-rich-editor,
849 - .office-source-editor,
850 - .office-docx-stage,
851 - .office-preview-editor {
852 - transform: scale(var(--office-zoom));
853 - transform-origin: top center;
854 - margin: 0 auto;
855 - }
856 -
857 - .office-rich-editor {
858 - box-sizing: border-box;
859 - width: min(760px, 100%);
860 - min-height: min(980px, calc(100vh - 170px));
861 - padding: 54px 58px;
862 - border: 1px solid #d2d8e3;
863 - border-radius: 8px;
864 - outline: none;
865 - background: #ffffff;
866 - box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
867 - color: #1f2937;
868 - font-size: 15px;
869 - line-height: 1.7;
870 - }
871 -
872 - .office-rich-editor:focus,
873 - .office-source-editor:focus,
874 - .office-docx-pages:focus-within .office-docx-page:first-child,
875 - .office-docx-pages:focus .office-docx-page:first-child {
876 - border-color: #8db5ef;
877 - box-shadow:
878 - 0 18px 44px rgba(35, 48, 68, 0.16),
879 - 0 0 0 3px rgba(44, 123, 229, 0.16);
880 - }
881 -
882 - .office-rich-editor h1,
883 - .office-rich-editor h2,
884 - .office-rich-editor h3 {
885 - line-height: 1.25;
886 - margin: 0 0 0.65em;
887 - }
888 -
889 - .office-rich-editor p,
890 - .office-rich-editor ul,
891 - .office-rich-editor table {
892 - margin: 0 0 1em;
893 - }
894 -
895 - .office-rich-editor table {
896 - width: 100%;
897 - border-collapse: collapse;
898 - }
899 -
900 - .office-rich-editor td,
901 - .office-rich-editor th {
902 - border: 1px solid #d9dee7;
903 - padding: 6px 8px;
904 - }
905 -
520 .office-source-editor {
521 box-sizing: border-box;
908 - width: min(920px, 100%);
909 - min-height: min(980px, calc(100vh - 170px));
910 - padding: 22px;
911 - border: 1px solid #d2d8e3;
912 - border-radius: 8px;
522 + flex: 1 1 auto;
523 + width: 100%;
524 + height: 100%;
525 + min-width: 0;
526 + min-height: 0;
527 + margin: 0;
528 + padding: 0;
529 + border: 0;
530 outline: none;
914 - background: #ffffff;
915 - color: #172033;
531 + background: transparent;
532 + box-shadow: none;
533 font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
534 font-size: 13px;
535 line-height: 1.65;
536 resize: none;
537 }
538
922 - .office-docx-stage {
923 - width: min(860px, 100%);
924 - outline: none;
925 - }
926 -
927 - .office-docx-pages {
928 - outline: none;
929 - }
930 -
931 - .office-docx-pages.is-native {
932 - display: grid;
933 - gap: 20px;
934 - justify-items: center;
935 - }
936 -
937 - .office-docx-page {
938 - box-sizing: border-box;
939 - width: min(760px, 100%);
940 - min-height: 980px;
941 - margin: 0 auto 20px;
942 - padding: 70px 74px;
943 - border: 1px solid #d2d8e3;
944 - border-radius: 6px;
945 - background: #ffffff;
946 - box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
947 - color: #1f2937;
948 - font-family: "Liberation Serif", "Times New Roman", serif;
949 - font-size: 16px;
950 - line-height: 1.55;
951 - }
952 -
953 - .office-docx-page.is-native-tile {
954 - width: auto;
955 - min-height: 0;
956 - padding: 0;
957 - overflow: hidden;
958 - line-height: 0;
959 - }
960 -
961 - .office-docx-page.is-native-tile img {
962 - display: block;
963 - width: min(920px, 100%);
964 - height: auto;
965 - user-select: none;
966 - }
967 -
968 - .office-docx-page p {
969 - margin: 0 0 0.85em;
970 - }
971 -
972 - .office-preview-editor {
973 - display: grid;
974 - place-items: start center;
975 - width: min(860px, 100%);
976 - min-height: 420px;
977 - padding: 18px;
539 + textarea:focus {
540 + background: transparent;
541 + filter: brightness(1) !important;
542 }
543
544 .office-panel .spinning {
@@ -991,21 +555,15 @@
555 padding-inline: 8px;
556 }
557
558 + .office-toolbar-row {
559 + gap: 6px;
560 + }
561 +
562 .office-command-button {
563 max-width: 132px;
564 padding-inline: 9px;
565 }
566
999 - .office-template-grid,
1000 - .office-card-grid {
1001 - grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
1002 - }
1003 -
1004 - .office-rich-editor,
1005 - .office-docx-page {
1006 - min-height: 720px;
1007 - padding: 34px 28px;
1008 - }
567 }
568 </style>
569 </body>
plugins/_office/webui/office-store.js
+22 -605
@@ -1,6 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5
6 const officeSocket = getNamespacedClient("/ws");
7 officeSocket.addHandlers(["ws_webui"]);
@@ -22,20 +23,6 @@ function currentContextId() {
23 }
24 }
25
25 -function formatBytes(value) {
26 - const size = Number(value || 0);
27 - if (!Number.isFinite(size) || size <= 0) return "";
28 - const units = ["B", "KB", "MB", "GB"];
29 - let amount = size;
30 - let index = 0;
31 - while (amount >= 1024 && index < units.length - 1) {
32 - amount /= 1024;
33 - index += 1;
34 - }
35 - const digits = amount >= 10 || index === 0 ? 0 : 1;
36 - return `${amount.toFixed(digits)} ${units[index]}`;
37 -}
38 -
26 function basename(path = "") {
27 const value = String(path || "").split("?")[0].split("#")[0];
28 return value.split("/").filter(Boolean).pop() || "Untitled";
@@ -51,163 +38,6 @@ function uniqueTabId(session = {}) {
38 return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
39 }
40
54 -function escapeHtml(value = "") {
55 - return String(value)
56 - .replaceAll("&", "&amp;")
57 - .replaceAll("<", "&lt;")
58 - .replaceAll(">", "&gt;")
59 - .replaceAll('"', "&quot;");
60 -}
61 -
62 -function inlineMarkdown(value = "") {
63 - return escapeHtml(value)
64 - .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
65 - .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
66 - .replace(/`([^`]+)`/g, "<code>$1</code>")
67 - .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
68 -}
69 -
70 -function markdownToHtml(markdown = "") {
71 - const normalized = String(markdown || "").replace(/\r\n?/g, "\n");
72 - const lines = normalized.split("\n");
73 - const html = [];
74 - let paragraph = [];
75 - let list = [];
76 -
77 - const flushParagraph = () => {
78 - if (!paragraph.length) return;
79 - html.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
80 - paragraph = [];
81 - };
82 - const flushList = () => {
83 - if (!list.length) return;
84 - html.push(`<ul>${list.map((line) => `<li>${inlineMarkdown(line)}</li>`).join("")}</ul>`);
85 - list = [];
86 - };
87 -
88 - for (let index = 0; index < lines.length; index += 1) {
89 - const raw = lines[index];
90 - const line = raw.trimEnd();
91 - if (!line.trim()) {
92 - flushParagraph();
93 - flushList();
94 - continue;
95 - }
96 - const heading = /^(#{1,4})\s+(.+)$/.exec(line);
97 - if (heading) {
98 - flushParagraph();
99 - flushList();
100 - const level = Math.min(4, heading[1].length);
101 - html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
102 - continue;
103 - }
104 - const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
105 - if (bullet) {
106 - flushParagraph();
107 - list.push(bullet[1]);
108 - continue;
109 - }
110 - flushList();
111 - paragraph.push(line.trim());
112 - }
113 -
114 - flushParagraph();
115 - flushList();
116 - if (!html.length || /\n\s*$/.test(normalized)) {
117 - html.push("<p><br></p>");
118 - }
119 - return html.join("") || "<p></p>";
120 -}
121 -
122 -function htmlToMarkdown(root) {
123 - if (!root) return "";
124 -
125 - const walk = (node) => {
126 - if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
127 - if (node.nodeType !== Node.ELEMENT_NODE) return "";
128 - const tag = node.tagName.toLowerCase();
129 - const childText = () => Array.from(node.childNodes).map(walk).join("");
130 -
131 - if (tag === "br") return "\n";
132 - if (tag === "strong" || tag === "b") return `**${childText().trim()}**`;
133 - if (tag === "em" || tag === "i") return `*${childText().trim()}*`;
134 - if (tag === "code") return `\`${childText().trim()}\``;
135 - if (tag === "a") {
136 - const href = node.getAttribute("href") || "";
137 - const label = childText().trim() || href;
138 - return href ? `[${label}](${href})` : label;
139 - }
140 - if (/^h[1-6]$/.test(tag)) return `\n${"#".repeat(Number(tag[1]))} ${childText().trim()}\n\n`;
141 - if (tag === "li") return `- ${childText().trim()}\n`;
142 - if (tag === "ul" || tag === "ol") return `\n${childText()}\n`;
143 - if (tag === "tr") {
144 - const cells = Array.from(node.children).map((cell) => cell.textContent?.trim() || "");
145 - return `| ${cells.join(" | ")} |\n`;
146 - }
147 - if (tag === "table") return `\n${Array.from(node.querySelectorAll("tr")).map(walk).join("")}\n`;
148 - if (tag === "p" || tag === "div" || tag === "section" || tag === "article") {
149 - const text = childText().trim();
150 - return text ? `${text}\n\n` : "";
151 - }
152 - return childText();
153 - };
154 -
155 - return Array.from(root.childNodes)
156 - .map(walk)
157 - .join("")
158 - .replace(/\n{3,}/g, "\n\n")
159 - .trimEnd();
160 -}
161 -
162 -function textToPageHtml(text = "") {
163 - const paragraphs = String(text || "")
164 - .replace(/\r\n?/g, "\n")
165 - .split(/\n+/)
166 - .map((line) => line.trim())
167 - .filter(Boolean);
168 - const lines = paragraphs.length ? paragraphs : [""];
169 - const pages = [];
170 - for (let index = 0; index < lines.length; index += 18) {
171 - pages.push(lines.slice(index, index + 18));
172 - }
173 - return pages
174 - .map((page, index) => (
175 - `<section class="office-docx-page" data-page="${index + 1}">`
176 - + page.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
177 - + "</section>"
178 - ))
179 - .join("");
180 -}
181 -
182 -function nativeTilesToHtml(tiles = []) {
183 - return tiles
184 - .filter((tile) => tile?.image)
185 - .map((tile) => {
186 - const twips = encodeURIComponent(JSON.stringify(tile.twips || {}));
187 - const width = Number(tile.width || 1);
188 - const height = Number(tile.height || 1);
189 - return (
190 - `<section class="office-docx-page is-native-tile" data-tile-index="${Number(tile.index || 0)}" data-twips="${twips}">`
191 - + `<img src="${escapeHtml(tile.image)}" width="${width}" height="${height}" alt="" draggable="false">`
192 - + "</section>"
193 - );
194 - })
195 - .join("");
196 -}
197 -
198 -function docxEditorText(element) {
199 - if (!element) return "";
200 - const pages = Array.from(element.querySelectorAll(".office-docx-page"));
201 - if (!pages.length) return element.innerText || "";
202 - return pages
203 - .map((page) => Array.from(page.querySelectorAll("p"))
204 - .map((p) => p.innerText.trim())
205 - .filter(Boolean)
206 - .join("\n"))
207 - .filter(Boolean)
208 - .join("\n\n");
209 -}
210 -
41 function editorContainsFocus(element) {
42 const active = document.activeElement;
43 return Boolean(element && active && (element === active || element.contains(active)));
@@ -263,9 +93,6 @@ function normalizeSession(payload = {}) {
93 title: payload.title || document.title || document.basename || basename(document.path),
94 tab_id: uniqueTabId(payload),
95 text: String(payload.text || ""),
266 - tiles: Array.isArray(payload.tiles) ? payload.tiles : [],
267 - preview: payload.preview || document.preview || {},
268 - native: payload.native || {},
96 desktop: payload.desktop || null,
97 desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
98 dirty: false,
@@ -306,8 +133,6 @@ function isOfficeSocketData(data) {
133 || Object.prototype.hasOwnProperty.call(data, "ok")
134 || Object.prototype.hasOwnProperty.call(data, "session_id")
135 || Object.prototype.hasOwnProperty.call(data, "document")
309 - || Object.prototype.hasOwnProperty.call(data, "tiles")
310 - || Object.prototype.hasOwnProperty.call(data, "native")
136 || Object.prototype.hasOwnProperty.call(data, "desktop")
137 || Object.prototype.hasOwnProperty.call(data, "closed")
138 );
@@ -315,8 +140,6 @@ function isOfficeSocketData(data) {
140
141 const model = {
142 status: null,
318 - recent: [],
319 - openDocuments: [],
143 tabs: [],
144 activeTabId: "",
145 session: null,
@@ -325,22 +148,16 @@ const model = {
148 dirty: false,
149 error: "",
150 message: "",
328 - sourceMode: false,
151 editorText: "",
330 - zoom: 1,
152 _root: null,
153 _mode: "canvas",
154 _saveMessageTimer: null,
155 _inputTimer: null,
156 _history: [],
157 _historyIndex: -1,
337 - _rendering: false,
158 _pendingFocus: false,
159 _pendingFocusEnd: true,
160 _focusAttempts: 0,
341 - _richEditor: null,
342 - _docxEditor: null,
343 - _nativeEventQueue: Promise.resolve(),
161 _floatingCleanup: null,
162 _desktopHeartbeatTimer: null,
163 _desktopHeartbeatSessionId: "",
@@ -404,22 +221,10 @@ const model = {
221 if (this._mode === "modal") this._root = null;
222 },
223
407 - bindEditorElement(element, type) {
408 - if (type === "markdown") this._richEditor = element;
409 - if (type === "docx") this._docxEditor = element;
410 - this.queueRender();
411 - },
412 -
224 async refresh() {
225 try {
415 - const [status, recent, openDocuments] = await Promise.all([
416 - callOffice("status"),
417 - callOffice("recent"),
418 - callOffice("open_documents"),
419 - ]);
226 + const status = await callOffice("status");
227 this.status = status || {};
421 - this.recent = (recent?.documents || []).map(normalizeDocument);
422 - this.openDocuments = (openDocuments?.documents || []).map(normalizeDocument);
228 this.error = "";
229 } catch (error) {
230 this.error = error instanceof Error ? error.message : String(error);
@@ -485,17 +290,20 @@ const model = {
290 });
291 },
292
488 - async openPrompt() {
489 - let defaultPath = "/a0/usr/workdir/";
293 + async openFileBrowser() {
294 + let workdirPath = "/a0/usr/workdir";
295 try {
491 - const home = await callOffice("home");
492 - defaultPath = home?.path || defaultPath;
296 + const response = await callJsonApi("settings_get", null);
297 + workdirPath = response?.settings?.workdir_path || workdirPath;
298 } catch {
494 - // The prompt still works with the static fallback.
299 + try {
300 + const home = await callOffice("home");
301 + workdirPath = home?.path || workdirPath;
302 + } catch {
303 + // The file browser can still open with the static fallback.
304 + }
305 }
496 - const path = globalThis.prompt?.("Path", defaultPath);
497 - if (!path) return;
498 - await this.openPath(path);
306 + await fileBrowserStore.open(workdirPath);
307 },
308
309 async openPath(path) {
@@ -560,7 +368,6 @@ const model = {
368 basename: "Desktop",
369 title: "Desktop",
370 extension: "desktop",
563 - preview: {},
371 },
372 dirty: false,
373 };
@@ -569,7 +376,6 @@ const model = {
376 const desktopTabId = desktopTab.tab_id;
377 this.session = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
378 this.activeTabId = desktopTabId;
572 - this.sourceMode = false;
379 this.editorText = "";
380 this.dirty = false;
381 this.resetHistory("");
@@ -581,7 +387,6 @@ const model = {
387 const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
388 this.session = tab;
389 this.activeTabId = tab?.tab_id || "";
584 - this.sourceMode = false;
390 this.editorText = String(tab?.text || "");
391 this.dirty = Boolean(tab?.dirty);
392 this.resetHistory(this.editorText);
@@ -598,41 +403,6 @@ const model = {
403 return Boolean(tab && tab.tab_id === this.activeTabId);
404 },
405
601 - async closeFile() {
602 - if (!this.session) return;
603 - if (this.isDesktopOfficeDocument(this.session) && !this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) {
604 - await this.closeDesktopDocumentSession(this.session);
605 - return;
606 - }
607 - await this.closeTab(this.session.tab_id);
608 - },
609 -
610 - async closeDesktopDocumentSession(session) {
611 - try {
612 - await callOffice("desktop_save", {
613 - desktop_session_id: session.desktop_session_id || session.session_id,
614 - file_id: session.file_id || "",
615 - }).catch(() => null);
616 - await callOffice("close", {
617 - session_id: session.store_session_id || "",
618 - file_id: session.file_id || "",
619 - });
620 - } catch (error) {
621 - console.warn("Desktop document close skipped", error);
622 - }
623 - this.session = null;
624 - this.activeTabId = "";
625 - this.editorText = "";
626 - this.dirty = false;
627 - const desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
628 - if (desktopTab) {
629 - this.selectTab(desktopTab.tab_id, { focus: false });
630 - } else {
631 - await this.ensureDesktopSession({ select: true });
632 - }
633 - await this.refresh();
634 - },
635 -
406 async closeTab(tabId) {
407 const tab = this.tabs.find((item) => item.tab_id === tabId);
408 if (!tab) return;
@@ -705,14 +475,12 @@ const model = {
475 }
476 return;
477 }
708 - if (this.hasNativeDocxTiles()) await this.awaitNativeEvents();
709 - if (!this.hasNativeDocxTiles()) this.syncEditorText();
478 + this.syncEditorText();
479 this.saving = true;
480 this.error = "";
481 try {
482 let response;
714 - const payload = { session_id: this.session.session_id };
715 - if (!this.hasNativeDocxTiles()) payload.text = this.editorText;
483 + const payload = { session_id: this.session.session_id, text: this.editorText };
484 try {
485 response = await requestOffice("office_save", payload, 10000);
486 } catch (_socketError) {
@@ -727,8 +495,6 @@ const model = {
495 document,
496 path: document.path || this.session.path,
497 file_id: document.file_id || this.session.file_id,
730 - tiles: Array.isArray(response.tiles) ? response.tiles : this.session.tiles,
731 - native: response.native || this.session.native || {},
498 version: document.version || response.version || this.session.version,
499 };
500 this.replaceActiveSession(updated);
@@ -742,26 +508,6 @@ const model = {
508 }
509 },
510
745 - async exportPdf() {
746 - if (!this.session) return;
747 - if (this.isDesktopSession()) return;
748 - this.loading = true;
749 - this.error = "";
750 - try {
751 - const response = await callOffice("export", {
752 - file_id: this.session.file_id,
753 - path: this.session.path,
754 - target_format: "pdf",
755 - });
756 - if (response?.ok === false) throw new Error(response.error || "Export failed.");
757 - this.setMessage(response.path ? `Exported ${response.path}` : "Exported");
758 - } catch (error) {
759 - this.error = error instanceof Error ? error.message : String(error);
760 - } finally {
761 - this.loading = false;
762 - }
763 - },
764 -
511 replaceActiveSession(next) {
512 if (!this.session) return;
513 this.session = next;
@@ -835,32 +581,9 @@ const model = {
581 this.scheduleInputPush();
582 },
583
838 - onRichInput(element) {
839 - if (this._rendering) return;
840 - this.editorText = htmlToMarkdown(element);
841 - this.markDirty();
842 - this.pushHistory(this.editorText);
843 - this.scheduleInputPush();
844 - },
845 -
846 - onDocxInput(element) {
847 - if (this.hasNativeDocxTiles()) return;
848 - if (this._rendering) return;
849 - this.editorText = docxEditorText(element);
850 - this.markDirty();
851 - this.pushHistory(this.editorText);
852 - this.scheduleInputPush();
853 - },
854 -
584 syncEditorText() {
585 if (!this.session) return;
586 if (this.hasOfficialOffice()) return;
858 - if (this.hasNativeDocxTiles()) return;
859 - if (this.isMarkdown() && !this.sourceMode && this._richEditor) {
860 - this.editorText = htmlToMarkdown(this._richEditor);
861 - } else if (this.isDocx() && this._docxEditor) {
862 - this.editorText = docxEditorText(this._docxEditor);
863 - }
587 this.session.text = this.editorText;
588 },
589
@@ -883,98 +606,10 @@ const model = {
606 }, 3000).catch(() => {});
607 },
608
886 - toggleSource() {
887 - if (!this.isMarkdown()) return;
888 - if (!this.sourceMode) this.syncEditorText();
889 - this.sourceMode = !this.sourceMode;
890 - this.queueRender({ force: true, focus: true });
891 - },
892 -
609 format(command) {
610 if (!this.session) return;
895 - if (this.sourceMode) {
896 - this.applySourceFormat(command);
897 - return;
898 - }
899 - const editor = this.isDocx() ? this._docxEditor : this._richEditor;
900 - editor?.focus?.();
901 - const uno = this.unoCommand(command);
902 - if (this.isDocx() && uno) {
903 - void this.dispatchUnoCommand(uno.command, uno.arguments);
904 - if (this.hasNativeDocxTiles()) {
905 - this.markDirty();
906 - return;
907 - }
908 - }
909 - if (command === "bold") document.execCommand?.("bold");
910 - if (command === "italic") document.execCommand?.("italic");
911 - if (command === "underline") document.execCommand?.("underline");
912 - if (command === "list") document.execCommand?.("insertUnorderedList");
913 - if (command === "numbered") document.execCommand?.("insertOrderedList");
914 - if (command === "alignLeft") document.execCommand?.("justifyLeft");
915 - if (command === "alignCenter") document.execCommand?.("justifyCenter");
916 - if (command === "alignRight") document.execCommand?.("justifyRight");
917 - if (command === "table") {
918 - document.execCommand?.(
919 - "insertHTML",
920 - false,
921 - '<table><tbody><tr><th>Column</th><th>Value</th></tr><tr><td></td><td></td></tr></tbody></table>',
922 - );
923 - }
924 - this.syncEditorText();
925 - this.markDirty();
926 - this.pushHistory(this.editorText);
927 - this.scheduleInputPush();
928 - },
929 -
930 - unoCommand(command) {
931 - const commands = {
932 - bold: { command: ".uno:Bold" },
933 - italic: { command: ".uno:Italic" },
934 - underline: { command: ".uno:Underline" },
935 - list: { command: ".uno:DefaultBullet" },
936 - numbered: { command: ".uno:DefaultNumbering" },
937 - alignLeft: { command: ".uno:LeftPara" },
938 - alignCenter: { command: ".uno:CenterPara" },
939 - alignRight: { command: ".uno:RightPara" },
940 - };
941 - return commands[command] || null;
942 - },
943 -
944 - async dispatchUnoCommand(command, argumentsPayload = null) {
945 - if (!this.session?.session_id || !command) return null;
946 - return await this.queueNativeEvent(async () => {
947 - try {
948 - let response;
949 - try {
950 - response = await requestOffice("office_command", {
951 - session_id: this.session.session_id,
952 - command,
953 - arguments: argumentsPayload,
954 - notify: true,
955 - }, 5000);
956 - } catch (_socketError) {
957 - response = await callOffice("command", {
958 - session_id: this.session.session_id,
959 - command,
960 - arguments: argumentsPayload,
961 - notify: true,
962 - });
963 - }
964 - if (response?.ok === false) throw new Error(response.error || `${command} failed.`);
965 - if (response?.metadata && this.session) {
966 - this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
967 - }
968 - if (Array.isArray(response?.tiles) && this.session) {
969 - this.session.tiles = response.tiles;
970 - this.queueRender({ force: true, focus: true });
971 - }
972 - return response;
973 - } catch (error) {
974 - console.warn("LibreOffice command skipped", command, error);
975 - return null;
976 - }
977 - });
611 + if (!this.isMarkdown()) return;
612 + this.applySourceFormat(command);
613 },
614
615 applySourceFormat(command) {
@@ -999,18 +634,6 @@ const model = {
634 });
635 },
636
1002 - zoomIn() {
1003 - this.zoom = Math.min(1.6, Math.round((this.zoom + 0.1) * 10) / 10);
1004 - },
1005 -
1006 - zoomOut() {
1007 - this.zoom = Math.max(0.7, Math.round((this.zoom - 0.1) * 10) / 10);
1008 - },
1009 -
1010 - zoomLabel() {
1011 - return `${Math.round(this.zoom * 100)}%`;
1012 - },
1013 -
637 queueRender(options = {}) {
638 const force = Boolean(options.force);
639 if (options.focus) {
@@ -1019,7 +642,6 @@ const model = {
642 this._focusAttempts = 0;
643 }
644 const render = () => {
1022 - this.renderEditors(force);
645 if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
646 this._pendingFocus = false;
647 this._focusAttempts = 0;
@@ -1035,35 +657,16 @@ const model = {
657 }
658 },
659
1038 - renderEditors(force = false) {
1039 - if (!this.session) return;
1040 - if (this.hasOfficialOffice()) return;
1041 - this._rendering = true;
1042 - try {
1043 - if (this._richEditor && this.isMarkdown() && (!editorContainsFocus(this._richEditor) || force)) {
1044 - this._richEditor.innerHTML = markdownToHtml(this.editorText);
1045 - }
1046 - if (this._docxEditor && this.isDocx() && this.hasNativeDocxTiles() && (!editorContainsFocus(this._docxEditor) || force)) {
1047 - this._docxEditor.innerHTML = nativeTilesToHtml(this.session.tiles || []);
1048 - } else if (this._docxEditor && this.isDocx() && (!editorContainsFocus(this._docxEditor) || force)) {
1049 - this._docxEditor.innerHTML = textToPageHtml(this.editorText);
1050 - }
1051 - } finally {
1052 - this._rendering = false;
1053 - }
1054 - },
1055 -
660 focusEditor(options = {}) {
1057 - if (!this.session || this.isPreviewOnly()) return false;
661 + if (!this.session) return false;
662 if (this.hasOfficialOffice()) {
663 return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
664 }
665 const source = this._root?.querySelector?.("[data-office-source]");
1062 - const editor = this.sourceMode ? source : (this.isDocx() ? this._docxEditor : this._richEditor);
1063 - if (!editor) return false;
1064 - editor.focus?.({ preventScroll: true });
1065 - if (!editorContainsFocus(editor)) return false;
1066 - if (options.end !== false) placeCaretAtEnd(editor);
666 + if (!this.isMarkdown() || !source) return false;
667 + source.focus?.({ preventScroll: true });
668 + if (!editorContainsFocus(source)) return false;
669 + if (options.end !== false) placeCaretAtEnd(source);
670 return true;
671 },
672
@@ -1072,11 +675,6 @@ const model = {
675 return ext === "md";
676 },
677
1075 - isDocx(tab = this.session) {
1076 - const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1077 - return ext === "docx";
1078 - },
1079 -
678 isBinaryOffice(tab = this.session) {
679 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
680 return ext === "docx" || ext === "xlsx" || ext === "pptx";
@@ -1964,123 +1562,6 @@ const model = {
1562 await this.refresh();
1563 },
1564
1967 - hasNativeDocxTiles() {
1968 - return Boolean(
1969 - this.isDocx()
1970 - && this.session?.native?.available
1971 - && Array.isArray(this.session?.tiles)
1972 - && this.session.tiles.some((tile) => tile?.image),
1973 - );
1974 - },
1975 -
1976 - async onNativeDocxClick(event) {
1977 - if (!this.hasNativeDocxTiles()) return;
1978 - const page = event.target?.closest?.(".office-docx-page.is-native-tile");
1979 - const image = page?.querySelector?.("img");
1980 - if (!page || !image) return;
1981 - const twips = this.decodeTileTwips(page);
1982 - const rect = image.getBoundingClientRect();
1983 - const ratioX = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width)));
1984 - const ratioY = Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)));
1985 - const x = Math.round((twips.x || 0) + ratioX * (twips.width || 0));
1986 - const y = Math.round((twips.y || 0) + ratioY * (twips.height || 0));
1987 - this._docxEditor?.focus?.({ preventScroll: true });
1988 - await this.sendNativeMouse({ type: "down", x, y, count: 1, buttons: 1, modifier: 0 });
1989 - await this.sendNativeMouse({ type: "up", x, y, count: 1, buttons: 1, modifier: 0 });
1990 - },
1991 -
1992 - onNativeDocxKeydown(event) {
1993 - if (!this.hasNativeDocxTiles()) return;
1994 - if (event.ctrlKey || event.metaKey || event.altKey) return;
1995 - const key = event.key || "";
1996 - if (key.length === 1) {
1997 - event.preventDefault();
1998 - void this.sendNativeKey({ text: key });
1999 - return;
2000 - }
2001 - const special = {
2002 - Enter: { text: "\n" },
2003 - Tab: { text: "\t" },
2004 - Backspace: { char_code: 0, key_code: 8 },
2005 - Delete: { char_code: 0, key_code: 127 },
2006 - ArrowLeft: { char_code: 0, key_code: 37 },
2007 - ArrowUp: { char_code: 0, key_code: 38 },
2008 - ArrowRight: { char_code: 0, key_code: 39 },
2009 - ArrowDown: { char_code: 0, key_code: 40 },
2010 - }[key];
2011 - if (!special) return;
2012 - event.preventDefault();
2013 - if (special.text != null) {
2014 - void this.sendNativeKey({ text: special.text });
2015 - } else {
2016 - void this.sendNativeKey({ type: "down", ...special }).then(() => this.sendNativeKey({ type: "up", ...special }));
2017 - }
2018 - },
2019 -
2020 - decodeTileTwips(page) {
2021 - try {
2022 - return JSON.parse(decodeURIComponent(page?.dataset?.twips || "{}"));
2023 - } catch {
2024 - return {};
2025 - }
2026 - },
2027 -
2028 - async sendNativeKey(key) {
2029 - if (!this.session?.session_id) return null;
2030 - return await this.queueNativeEvent(async () => {
2031 - const response = await this.sendNativeEvent("office_key", "key", key, "key");
2032 - if (response?.ok) this.markDirty();
2033 - return response;
2034 - });
2035 - },
2036 -
2037 - async sendNativeMouse(mouse) {
2038 - if (!this.session?.session_id) return null;
2039 - return await this.queueNativeEvent(() => this.sendNativeEvent("office_mouse", "mouse", mouse, "mouse"));
2040 - },
2041 -
2042 - async queueNativeEvent(task) {
2043 - const run = this._nativeEventQueue.catch(() => null).then(task);
2044 - this._nativeEventQueue = run.catch(() => null);
2045 - return await run;
2046 - },
2047 -
2048 - async awaitNativeEvents() {
2049 - await this._nativeEventQueue.catch(() => null);
2050 - },
2051 -
2052 - async sendNativeEvent(socketEvent, apiAction, payload, key) {
2053 - try {
2054 - let response;
2055 - try {
2056 - response = await requestOffice(socketEvent, {
2057 - session_id: this.session.session_id,
2058 - [key]: payload,
2059 - }, 7000);
2060 - } catch (_socketError) {
2061 - response = await callOffice(apiAction, {
2062 - session_id: this.session.session_id,
2063 - [key]: payload,
2064 - });
2065 - }
2066 - if (response?.metadata && this.session) {
2067 - this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
2068 - }
2069 - if (Array.isArray(response?.tiles) && this.session) {
2070 - this.session.tiles = response.tiles;
2071 - this.queueRender({ force: true, focus: true });
2072 - }
2073 - return response;
2074 - } catch (error) {
2075 - console.warn("LibreOffice native event skipped", socketEvent, error);
2076 - return null;
2077 - }
2078 - },
2079 -
2080 - isPreviewOnly() {
2081 - return Boolean(this.session && !this.hasOfficialOffice() && !this.isMarkdown() && !this.isDocx());
2082 - },
2083 -
1565 defaultTitle(kind, fmt) {
1566 const date = new Date().toISOString().slice(0, 10);
1567 if (fmt === "md") return `Document ${date}`;
@@ -2109,70 +1590,6 @@ const model = {
1590 return "draft";
1591 },
1592
2112 - documentPath() {
2113 - return this.session?.document?.path || this.session?.path || "";
2114 - },
2115 -
2116 - documentMeta(doc = this.session?.document || this.session || {}) {
2117 - const parts = [String(doc.extension || "").toUpperCase(), formatBytes(doc.size)].filter(Boolean);
2118 - return parts.join(" · ");
2119 - },
2120 -
2121 - openCards() {
2122 - return this.visibleTabs()
2123 - .filter((tab) => !this.isDesktopSession(tab))
2124 - .map((tab) => normalizeDocument({
2125 - ...tab.document,
2126 - ...tab,
2127 - open: true,
2128 - }));
2129 - },
2130 -
2131 - recentCards() {
2132 - const openIds = new Set(this.tabs.map((tab) => tab.file_id).filter(Boolean));
2133 - return this.recent.filter((doc) => !openIds.has(doc.file_id)).slice(0, 8);
2134 - },
2135 -
2136 - previewKind(doc = {}) {
2137 - const ext = String(doc.extension || "").toLowerCase();
2138 - if (ext === "xlsx") return "spreadsheet";
2139 - if (ext === "pptx") return "presentation";
2140 - if (ext === "md") return "markdown";
2141 - return "document";
2142 - },
2143 -
2144 - hasPreview(doc = {}) {
2145 - const preview = doc.preview || {};
2146 - return Boolean(
2147 - (Array.isArray(preview.lines) && preview.lines.length)
2148 - || (Array.isArray(preview.rows) && preview.rows.length)
2149 - || (Array.isArray(preview.slides) && preview.slides.length)
2150 - );
2151 - },
2152 -
2153 - previewLines(doc = {}) {
2154 - const preview = doc.preview || {};
2155 - return (preview.lines || []).slice(0, 8);
2156 - },
2157 -
2158 - previewRows(doc = {}) {
2159 - const preview = doc.preview || {};
2160 - return (preview.rows || []).slice(0, 6);
2161 - },
2162 -
2163 - previewSlides(doc = {}) {
2164 - const preview = doc.preview || {};
2165 - return (preview.slides || []).slice(0, 3);
2166 - },
2167 -
2168 - dashboardTitle(doc = {}) {
2169 - return doc.title || doc.basename || basename(doc.path);
2170 - },
2171 -
2172 - dashboardMeta(doc = {}) {
2173 - return [String(doc.extension || "").toUpperCase(), doc.open ? "Open" : "", formatBytes(doc.size)].filter(Boolean).join(" · ");
2174 - },
2175 -
1593 setupFloatingModal(element = null) {
1594 const root = element || globalThis.document?.querySelector(".office-panel");
1595 const inner = root?.closest?.(".modal-inner");
tests/test_office_canvas_setup.py
+26 -19
@@ -14,8 +14,10 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
14 encoding="utf-8",
15 )
16
17 - assert "office-rich-editor" in panel
18 - assert "office-docx-pages" in panel
17 + assert "office-source-editor" in panel
18 + assert "data-office-source" in panel
19 + assert "office-rich-editor" not in panel
20 + assert "office-docx-pages" not in panel
21 assert "office-desktop-frame" in panel
22 assert "data-office-desktop-frame" in panel
23 assert 'title="LibreOffice desktop"' not in panel
@@ -28,12 +30,14 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
30 assert "office-modal-resizer" in panel
31 assert "resize: both" not in panel
32 assert 'tabindex="0"' in panel
31 - assert "format_underlined" in panel
32 - assert "format_align_center" in panel
33 - assert "is-native-tile" in panel
33 + assert "format_underlined" not in panel
34 + assert "format_align_center" not in panel
35 + assert "is-native-tile" not in panel
36 assert "hasOfficialOffice()" in panel
37 assert "office_save" in store
38 assert "desktop_save" in store
39 + assert "--office-zoom" not in panel
40 + assert "zoom: 1" not in store
41 assert 'callOffice("desktop")' in store
42 assert "ensureDesktopSession" in store
43 assert "handleOfficialOfficeClosed" in store
@@ -82,16 +86,16 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
86 assert "officialOfficeUrl" in store
87 assert "hasOfficialOffice" in store
88 assert "isOfficeSocketData" in store
85 - assert "office_command" in store
86 - assert "office_key" in store
87 - assert "office_mouse" in store
88 - assert ".uno:Bold" in store
89 - assert "nativeTilesToHtml" in store
89 + assert "office_command" not in store
90 + assert "office_key" not in store
91 + assert "office_mouse" not in store
92 + assert ".uno:Bold" not in store
93 + assert "nativeTilesToHtml" not in store
94 assert "editorContainsFocus" in store
95 assert "_focusAttempts" in store
92 - assert "_nativeEventQueue" in store
93 - assert "await this.awaitNativeEvents()" in store
94 - assert "<p><br></p>" in store
96 + assert "_nativeEventQueue" not in store
97 + assert "await this.awaitNativeEvents()" not in store
98 + assert "<p><br></p>" not in store
99 assert "setupTitle()" not in panel
100 assert "Setup in progress" not in store
101 assert "office-log" not in panel
@@ -112,7 +116,7 @@ def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session():
116 assert "getModifierState: { value: getModifierState }" in store
117
118
115 -def test_office_dashboard_uses_cards_and_filters_tabs_to_desktop_and_markdown():
119 +def test_office_surface_filters_tabs_to_desktop_and_markdown_without_dashboard():
120 panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
121 encoding="utf-8",
122 )
@@ -120,14 +124,14 @@ def test_office_dashboard_uses_cards_and_filters_tabs_to_desktop_and_markdown():
124 encoding="utf-8",
125 )
126
123 - assert "office-card-grid" in panel
124 - assert "office-document-card" in panel
127 + assert "office-card-grid" not in panel
128 + assert "office-document-card" not in panel
129 assert "visibleTabs()" in panel
126 - assert "openCards()" in panel
127 - assert "recentCards()" in panel
130 + assert "openCards()" not in panel
131 + assert "recentCards()" not in panel
132 assert "office-editor-head" not in panel
133 assert "office-recent-row" not in panel
130 - assert "open_documents" in store
134 + assert "open_documents" not in store
135 assert "installDesktopDocumentSession" in store
136 assert "isDesktopOfficeDocument" in store
137 assert "isVisibleOfficeTab" in store
@@ -248,6 +252,9 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
252 assert "DESKTOP_FOLDER_LINKS" in desktop
253 assert "HIDDEN_XPRA_DESKTOP_ENTRIES" in desktop
254 assert "libreoffice-gtk3" in install
255 + assert "libreofficekit" not in install
256 + assert "gir1.2-lokdocview" not in install
257 + assert "python3-gi" not in install
258 assert "xpra" in install
259 assert "xpra-x11" in install
260 assert "xpra-html5" in install
tests/test_office_document_store.py
+13 -67
@@ -7,6 +7,7 @@ import os
7 import sys
8 import types
9 import zipfile
10 +import xml.etree.ElementTree as ET
11 from pathlib import Path
12
13 import pytest
@@ -23,9 +24,7 @@ from plugins._office.helpers import (
24 document_store,
25 libreoffice,
26 libreoffice_desktop,
26 - libreofficekit_native,
27 - libreofficekit_sessions,
28 - libreofficekit_worker,
27 + markdown_sessions,
28 )
29
30
@@ -87,9 +86,10 @@ def test_blank_docx_includes_editable_body_paragraph(office_state):
86 doc = document_store.create_document("document", "Blank Memo", "docx", "")
87 with zipfile.ZipFile(doc["path"]) as archive:
88 xml = archive.read("word/document.xml").decode("utf-8")
90 - root = document_store.ET.fromstring(xml)
89 + root = ET.fromstring(xml)
90
92 - assert len(list(root.iter(document_store._qn(document_store.W_NS, "p")))) >= 2
91 + word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
92 + assert len(list(root.iter(f"{{{word_ns}}}p"))) >= 2
93 assert 'xml:space="preserve">&#160;</w:t>' in xml
94
95
@@ -239,16 +239,14 @@ def test_non_project_creation_uses_configured_workdir(office_state):
239 assert Path(spreadsheet["path"]).parent == office_state.documents
240
241
242 -def test_sessions_recent_preview_and_canvas_context_are_neutral(office_state):
242 +def test_sessions_and_canvas_context_are_neutral(office_state):
243 doc = document_store.create_document("document", "Canvas Context", "md", "Private body text.")
244 session = document_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
245
246 open_docs = document_store.get_open_documents()
247 - recent = document_store.get_recent_documents()
247 context = canvas_context.build_context()
248
249 assert open_docs[0]["file_id"] == doc["file_id"]
251 - assert recent[0]["preview"]["lines"]
250 assert "document artifacts" in context
251 assert "Private body text" not in context
252 assert document_store.close_session(session_id=session["session_id"]) == 1
@@ -266,8 +264,8 @@ def test_markdown_save_tracks_version_history(office_state):
264
265
266 def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
269 - manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
270 - monkeypatch.setattr(libreofficekit_sessions, "_manager", manager, raising=False)
267 + manager = markdown_sessions.MarkdownSessionManager()
268 + monkeypatch.setattr(markdown_sessions, "_manager", manager, raising=False)
269 doc = document_store.create_document("document", "Receiver", "md", "First")
270 session = manager.open(doc)
271
@@ -276,64 +274,12 @@ def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeyp
274 assert manager._sessions[session["session_id"]].text == "# Receiver\n\nSecond"
275
276
279 -def test_docx_session_dispatches_native_uno_commands(office_state, monkeypatch):
280 - calls = []
281 -
282 - class FakeNativeDocument:
283 - def metadata(self):
284 - return {"available": True, "doctype": 0, "parts": 1, "width_twips": 100, "height_twips": 200}
285 -
286 - def post_uno_command(self, command, arguments=None, notify=True):
287 - calls.append((command, arguments, notify))
288 - return {"ok": True, "native": True, "command": command}
289 -
290 - def command_values(self, command):
291 - return {"ok": True, "native": True, "command": command, "values": {"commandName": command}}
277 +def test_markdown_session_rejects_office_binaries(office_state):
278 + manager = markdown_sessions.MarkdownSessionManager()
279 + doc = document_store.create_document("document", "Desktop Only", "docx", "Native text")
280
293 - def close(self):
294 - calls.append(("close", None, None))
295 -
296 - monkeypatch.setattr(libreofficekit_native, "open_document", lambda path: FakeNativeDocument())
297 -
298 - manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
299 - doc = document_store.create_document("document", "Native", "docx", "Native text")
300 - session = manager.open(doc)
301 - result = manager.command(session["session_id"], ".uno:Bold", notify=True)
302 - values = manager.command_values(session["session_id"], ".uno:StyleApply")
303 - manager.close(session["session_id"])
304 -
305 - assert session["native"]["available"] is True
306 - assert result["ok"] is True
307 - assert result["native"] is True
308 - assert values["values"]["commandName"] == ".uno:StyleApply"
309 - assert calls[0] == (".uno:Bold", None, True)
310 - assert calls[-1] == ("close", None, None)
311 -
312 -
313 -def test_lok_worker_serializes_concurrent_rpc_calls():
314 - import concurrent.futures
315 - import threading
316 - import time
317 -
318 - document = object.__new__(libreofficekit_worker.WorkerLokDocument)
319 - document._lock = threading.RLock()
320 - active = 0
321 - max_active = 0
322 -
323 - def fake_request_unlocked(action, payload=None, timeout=18):
324 - nonlocal active, max_active
325 - active += 1
326 - max_active = max(max_active, active)
327 - time.sleep(0.01)
328 - active -= 1
329 - return {"ok": True, "action": action, "payload": payload}
330 -
331 - document._request_unlocked = fake_request_unlocked
332 - with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
333 - results = list(pool.map(lambda index: document._request("key", {"index": index}), range(12)))
334 -
335 - assert all(result["ok"] is True for result in results)
336 - assert max_active == 1
281 + with pytest.raises(ValueError, match="Open .docx files in the Desktop"):
282 + manager.open(doc)
283
284
285 def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypatch):