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