From df9523433dd8074e80523fecc54d4b466ba93ae8 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:17:04 +0200 Subject: [PATCH] Improve Office canvas setup and dashboard UX Replace the raw Collabora setup log with a simple Office setup progress state, redesign the Office dashboard around document cards with lightweight previews, and keep backend WOPI sessions aligned with visible Office tabs. Also preserve the restored Office canvas surface across window refreshes and add regression coverage for the new behavior. --- plugins/_office/api/office_session.py | 7 + plugins/_office/helpers/wopi_store.py | 199 ++++++- plugins/_office/webui/office-panel.html | 531 ++++++++++++++---- plugins/_office/webui/office-store.js | 183 +++++- tests/test_office_canvas_setup.py | 53 ++ tests/test_office_wopi_store.py | 33 ++ webui/components/canvas/right-canvas-store.js | 4 +- 7 files changed, 882 insertions(+), 128 deletions(-) create mode 100644 tests/test_office_canvas_setup.py diff --git a/plugins/_office/api/office_session.py b/plugins/_office/api/office_session.py index b7f96b823..1beaeb46d 100644 --- a/plugins/_office/api/office_session.py +++ b/plugins/_office/api/office_session.py @@ -26,6 +26,12 @@ class OfficeSession(ApiHandler): return {"ok": True, "documents": wopi_store.get_recent_documents()} if action == "open_documents": return {"ok": True, "documents": wopi_store.get_open_documents(limit=24)} + if action == "sync_open_sessions": + session_ids = input.get("session_ids") + if not isinstance(session_ids, list): + session_ids = [] + closed = wopi_store.sync_open_sessions(session_ids) + return {"ok": True, "closed": closed, "documents": wopi_store.get_open_documents(limit=24)} if action == "close": closed = wopi_store.close_session( session_id=str(input.get("session_id") or ""), @@ -96,6 +102,7 @@ class OfficeSession(ApiHandler): "extension": doc["extension"], "path": doc["path"], "version": wopi_store.item_version(doc), + "preview": wopi_store.build_preview(doc), } def _origin(self, request: Request) -> str: diff --git a/plugins/_office/helpers/wopi_store.py b/plugins/_office/helpers/wopi_store.py index 96aacce7e..471fd9aa4 100644 --- a/plugins/_office/helpers/wopi_store.py +++ b/plugins/_office/helpers/wopi_store.py @@ -12,6 +12,7 @@ import sqlite3 import time import uuid import zipfile +import xml.etree.ElementTree as ET from contextlib import contextmanager from pathlib import Path from typing import Any @@ -27,6 +28,14 @@ DEFAULT_LOCK_SECONDS = 30 * 60 MAX_LOCK_SECONDS = 3600 MIN_LOCK_SECONDS = 60 MAX_SAVE_BYTES = 512 * 1024 * 1024 +PREVIEW_LINE_LIMIT = 5 +PREVIEW_ROW_LIMIT = 5 +PREVIEW_COLUMN_LIMIT = 4 +PREVIEW_SLIDE_LIMIT = 2 + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" +X_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "collabora")) DB_PATH = STATE_DIR / "documents.sqlite3" @@ -210,13 +219,16 @@ def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[s return _fetch(active) -def get_recent_documents(limit: int = 12) -> list[dict[str, Any]]: +def get_recent_documents(limit: int = 12, include_preview: bool = True) -> list[dict[str, Any]]: with connect() as conn: rows = conn.execute( "SELECT * FROM documents ORDER BY updated_at DESC LIMIT ?", (limit,), ).fetchall() - return [dict(row) for row in rows] + documents = [dict(row) for row in rows] + if include_preview: + return [with_preview(document) for document in documents] + return documents def get_open_documents(limit: int = 6) -> list[dict[str, Any]]: @@ -273,6 +285,38 @@ def close_session(session_id: str = "", file_id: str = "") -> int: return len(rows) +def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str]) -> int: + active_ids = {str(session_id).strip() for session_id in active_session_ids if str(session_id).strip()} + with connect() as conn: + _clear_expired_sessions(conn) + if active_ids: + placeholders = ",".join("?" for _ in active_ids) + rows = conn.execute( + f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders})", + tuple(active_ids), + ).fetchall() + conn.execute(f"DELETE FROM tokens WHERE session_id NOT IN ({placeholders})", tuple(active_ids)) + conn.execute(f"DELETE FROM sessions WHERE session_id NOT IN ({placeholders})", tuple(active_ids)) + conn.execute(f"DELETE FROM locks WHERE session_id NOT IN ({placeholders})", tuple(active_ids)) + else: + rows = conn.execute("SELECT session_id, file_id FROM sessions").fetchall() + conn.execute("DELETE FROM tokens") + conn.execute("DELETE FROM sessions") + conn.execute("DELETE FROM locks") + + for row in rows: + conn.execute( + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)", + ( + row["file_id"], + "close_orphan_session", + json.dumps({"session_id": row["session_id"]}), + now(), + ), + ) + return len(rows) + + def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]: permission = "write" if permission == "write" else "read" token = secrets.token_urlsafe(32) @@ -304,6 +348,157 @@ def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl } +def with_preview(document: dict[str, Any]) -> dict[str, Any]: + return {**document, "preview": build_preview(document)} + + +def build_preview(document: dict[str, Any]) -> dict[str, Any]: + ext = str(document.get("extension") or "").lower() + path = Path(str(document.get("path") or "")) + preview = { + "available": False, + "kind": _preview_kind(ext), + "lines": [], + "rows": [], + "slides": [], + } + if not path.exists(): + return preview + try: + if ext == "docx": + lines = _preview_docx(path) + return {**preview, "available": bool(lines), "lines": lines} + if ext == "xlsx": + rows = _preview_xlsx(path) + return {**preview, "available": bool(rows), "rows": rows} + if ext == "pptx": + slides = _preview_pptx(path) + return {**preview, "available": bool(slides), "slides": slides} + if ext in {"odt", "ods", "odp"}: + lines = _preview_odf(path) + return {**preview, "available": bool(lines), "lines": lines} + except Exception: + return preview + return preview + + +def _preview_kind(ext: str) -> str: + if ext in {"xlsx", "ods"}: + return "spreadsheet" + if ext in {"pptx", "odp"}: + return "presentation" + if ext in {"docx", "odt"}: + return "document" + return "file" + + +def _qn(namespace: str, tag: str) -> str: + return f"{{{namespace}}}{tag}" + + +def _clean_preview_text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def _preview_docx(path: Path) -> list[str]: + with zipfile.ZipFile(path) as archive: + root = ET.fromstring(archive.read("word/document.xml")) + lines = [] + for paragraph in root.iter(_qn(W_NS, "p")): + text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(W_NS, "t")))) + if text: + lines.append(text) + if len(lines) >= PREVIEW_LINE_LIMIT: + break + return lines + + +def _preview_xlsx(path: Path) -> list[list[str]]: + with zipfile.ZipFile(path) as archive: + shared_strings = _xlsx_shared_strings(archive) + sheet_names = sorted( + (name for name in archive.namelist() if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)), + key=_natural_name_key, + ) + if not sheet_names: + return [] + root = ET.fromstring(archive.read(sheet_names[0])) + + rows = [] + for row in root.iter(_qn(X_NS, "row")): + cells = [] + for cell in list(row)[:PREVIEW_COLUMN_LIMIT]: + cells.append(_xlsx_cell_preview(cell, shared_strings)) + if any(cells): + rows.append(cells) + if len(rows) >= PREVIEW_ROW_LIMIT: + break + return rows + + +def _xlsx_shared_strings(archive: zipfile.ZipFile) -> list[str]: + try: + root = ET.fromstring(archive.read("xl/sharedStrings.xml")) + except KeyError: + return [] + strings = [] + for item in root.iter(_qn(X_NS, "si")): + strings.append(_clean_preview_text("".join(node.text or "" for node in item.iter(_qn(X_NS, "t"))))) + return strings + + +def _xlsx_cell_preview(cell: ET.Element, shared_strings: list[str]) -> str: + cell_type = cell.attrib.get("t", "") + if cell_type == "inlineStr": + return _clean_preview_text("".join(node.text or "" for node in cell.iter(_qn(X_NS, "t")))) + value_node = cell.find(_qn(X_NS, "v")) + value = _clean_preview_text(value_node.text if value_node is not None else "") + if cell_type == "s": + try: + return shared_strings[int(value)] + except (ValueError, IndexError): + return value + if cell_type == "b": + return "TRUE" if value == "1" else "FALSE" + return value + + +def _preview_pptx(path: Path) -> list[dict[str, Any]]: + with zipfile.ZipFile(path) as archive: + names = sorted( + (name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)), + key=_natural_name_key, + ) + slides = [] + for name in names[:PREVIEW_SLIDE_LIMIT]: + root = ET.fromstring(archive.read(name)) + lines = [] + for paragraph in root.iter(_qn(A_NS, "p")): + text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(A_NS, "t")))) + if text: + lines.append(text) + if lines: + slides.append({"title": lines[0], "lines": lines[1:PREVIEW_LINE_LIMIT]}) + return slides + + +def _preview_odf(path: Path) -> list[str]: + with zipfile.ZipFile(path) as archive: + root = ET.fromstring(archive.read("content.xml")) + lines = [] + for node in root.iter(): + text = _clean_preview_text(node.text) + if text: + lines.append(text) + if len(lines) >= PREVIEW_LINE_LIMIT: + break + return lines + + +def _natural_name_key(value: str) -> list[int | str]: + return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)] + + def replace_document_bytes( file_id: str, data: bytes, diff --git a/plugins/_office/webui/office-panel.html b/plugins/_office/webui/office-panel.html index 5a3e69766..5d98a9e17 100644 --- a/plugins/_office/webui/office-panel.html +++ b/plugins/_office/webui/office-panel.html @@ -29,11 +29,11 @@ - + -

           
 
           
+            
+
+            
+
+