mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 16:31:30 +00:00
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.
This commit is contained in:
parent
9ec070793d
commit
df9523433d
7 changed files with 882 additions and 128 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@
|
|||
<span
|
||||
class="office-health-pill"
|
||||
:class="`is-${$store.office.status?.state || 'unknown'}`"
|
||||
:title="$store.office.status?.message || 'Office status'"
|
||||
:aria-label="$store.office.status?.message || 'Office status'"
|
||||
:title="$store.office.healthTitle()"
|
||||
:aria-label="$store.office.healthTitle()"
|
||||
>
|
||||
<span class="office-health-dot"></span>
|
||||
<span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.status?.state || 'status'"></span>
|
||||
<span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.healthText()"></span>
|
||||
</span>
|
||||
<button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
|
|
@ -87,66 +87,143 @@
|
|||
</div>
|
||||
|
||||
<div class="office-body">
|
||||
<div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)">
|
||||
<div class="office-bootstrap-header">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<div>
|
||||
<strong x-text="$store.office.status?.state || 'Preparing Office'"></strong>
|
||||
<span x-text="$store.office.status?.message || 'Collabora Online is being prepared in the background.'"></span>
|
||||
</div>
|
||||
<div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)" style="display: none;">
|
||||
<div class="office-setup-mark" :class="{ 'is-busy': $store.office.isSetupBusy(), 'is-alert': $store.office.isSetupBlocked() }">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.office.isSetupBusy() }" x-text="$store.office.setupIcon()"></span>
|
||||
</div>
|
||||
<div class="office-bootstrap-actions">
|
||||
<button type="button" class="office-button" @click="$store.office.retry()">
|
||||
<div class="office-setup-copy">
|
||||
<span>Agent Zero Office</span>
|
||||
<strong x-text="$store.office.setupTitle()"></strong>
|
||||
<p x-text="$store.office.setupMessage()"></p>
|
||||
</div>
|
||||
<div class="office-setup-progress" :class="{ 'is-paused': !$store.office.isSetupBusy() }" aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="office-bootstrap-actions" x-show="$store.office.showSetupActions()" style="display: none;">
|
||||
<button type="button" class="office-button" @click="$store.office.retry()" x-show="$store.office.isSetupBlocked()" style="display: none;">
|
||||
<span class="material-symbols-outlined">restart_alt</span>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
<button type="button" class="office-button" @click="$store.office.refresh()">
|
||||
<span class="material-symbols-outlined">sync</span>
|
||||
<span>Status</span>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="office-log" x-text="$store.office.logs?.bootstrap || $store.office.logs?.wrapper || ''"></pre>
|
||||
</div>
|
||||
|
||||
<div class="office-start" x-show="!$store.office.session && $store.office.status?.healthy" style="display: none;">
|
||||
<div class="office-start-actions">
|
||||
<button type="button" class="office-create-tile" @click="$store.office.create('document')">
|
||||
<span class="material-symbols-outlined">article</span>
|
||||
<span>Document</span>
|
||||
</button>
|
||||
<button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
|
||||
<span class="material-symbols-outlined">table_chart</span>
|
||||
<span>Spreadsheet</span>
|
||||
</button>
|
||||
<button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
|
||||
<span class="material-symbols-outlined">co_present</span>
|
||||
<span>Presentation</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="office-recent" x-show="$store.office.openDocuments.length">
|
||||
<div class="office-list-label">Open files</div>
|
||||
<template x-for="doc in $store.office.openDocuments" :key="doc.file_id">
|
||||
<button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
|
||||
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
|
||||
<span class="office-recent-text">
|
||||
<span x-text="$store.office.openDocumentLabel(doc)"></span>
|
||||
<small x-text="$store.office.openDocumentMeta(doc)"></small>
|
||||
</span>
|
||||
<section class="office-dashboard-section" aria-label="Create Office file">
|
||||
<div class="office-dashboard-heading">Create</div>
|
||||
<div class="office-template-grid">
|
||||
<button type="button" class="office-create-tile" @click="$store.office.create('document')">
|
||||
<span class="material-symbols-outlined">article</span>
|
||||
<span>Document</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="office-recent" x-show="$store.office.recent.length">
|
||||
<div class="office-list-label">Recent files</div>
|
||||
<template x-for="doc in $store.office.recent" :key="doc.file_id">
|
||||
<button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
|
||||
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
|
||||
<span class="office-recent-text">
|
||||
<span x-text="doc.basename"></span>
|
||||
<small x-text="String(doc.extension || '').toUpperCase()"></small>
|
||||
</span>
|
||||
<button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
|
||||
<span class="material-symbols-outlined">table_chart</span>
|
||||
<span>Spreadsheet</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
|
||||
<span class="material-symbols-outlined">co_present</span>
|
||||
<span>Presentation</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open Office files" style="display: none;">
|
||||
<div class="office-dashboard-heading">Open files</div>
|
||||
<div class="office-card-grid">
|
||||
<template x-for="doc in $store.office.openCards()" :key="doc.tab_id">
|
||||
<button type="button" class="office-document-card is-open" :title="doc.path" @click="$store.office.selectTab(doc.tab_id)">
|
||||
<span class="office-card-badge">Open</span>
|
||||
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
|
||||
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
|
||||
<div class="office-sheet-preview">
|
||||
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
|
||||
<div class="office-sheet-row">
|
||||
<template x-for="(cell, cellIndex) in row" :key="cellIndex">
|
||||
<span x-text="cell"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
|
||||
<div class="office-slide-preview">
|
||||
<template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
|
||||
<div class="office-slide-line">
|
||||
<strong x-text="slide.title"></strong>
|
||||
<span x-text="(slide.lines || []).join(' / ')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
|
||||
<div class="office-page-preview">
|
||||
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
|
||||
<span x-text="line"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.office.hasPreview(doc)">
|
||||
<div class="office-preview-fallback">
|
||||
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
|
||||
<small x-text="$store.office.dashboardMeta(doc)"></small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent Office files" style="display: none;">
|
||||
<div class="office-dashboard-heading">Recent files</div>
|
||||
<div class="office-card-grid">
|
||||
<template x-for="doc in $store.office.recentCards()" :key="doc.file_id">
|
||||
<button type="button" class="office-document-card" :title="doc.path" @click="$store.office.openPath(doc.path)">
|
||||
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
|
||||
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
|
||||
<div class="office-sheet-preview">
|
||||
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
|
||||
<div class="office-sheet-row">
|
||||
<template x-for="(cell, cellIndex) in row" :key="cellIndex">
|
||||
<span x-text="cell"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
|
||||
<div class="office-slide-preview">
|
||||
<template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
|
||||
<div class="office-slide-line">
|
||||
<strong x-text="slide.title"></strong>
|
||||
<span x-text="(slide.lines || []).join(' / ')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
|
||||
<div class="office-page-preview">
|
||||
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
|
||||
<span x-text="line"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.office.hasPreview(doc)">
|
||||
<div class="office-preview-fallback">
|
||||
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
|
||||
<small x-text="$store.office.dashboardMeta(doc)"></small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="office-frame-wrap" x-show="$store.office.session" style="display: none;">
|
||||
|
|
@ -401,7 +478,7 @@
|
|||
.office-icon-button,
|
||||
.office-health-pill,
|
||||
.office-create-tile,
|
||||
.office-recent-row {
|
||||
.office-document-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -469,7 +546,7 @@
|
|||
.office-button:hover:not(:disabled),
|
||||
.office-icon-button:hover:not(:disabled),
|
||||
.office-create-tile:hover,
|
||||
.office-recent-row:hover {
|
||||
.office-document-card:hover {
|
||||
background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
||||
}
|
||||
|
|
@ -505,64 +582,144 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.office-bootstrap,
|
||||
.office-start {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 22px;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.office-bootstrap-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.office-bootstrap-header > .material-symbols-outlined {
|
||||
font-size: 24px;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
|
||||
}
|
||||
|
||||
.office-bootstrap-header div {
|
||||
.office-bootstrap {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: clamp(24px, 7cqi, 56px);
|
||||
overflow: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.office-setup-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
||||
border-radius: 7px;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
|
||||
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
|
||||
}
|
||||
|
||||
.office-setup-mark.is-busy {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
|
||||
}
|
||||
|
||||
.office-setup-mark.is-alert {
|
||||
border-color: color-mix(in srgb, #f05252 48%, var(--color-border));
|
||||
color: #f05252;
|
||||
}
|
||||
|
||||
.office-setup-mark .material-symbols-outlined {
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.office-setup-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.9rem;
|
||||
gap: 6px;
|
||||
max-width: 460px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.office-bootstrap-header span {
|
||||
.office-setup-copy > span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-setup-copy > strong {
|
||||
color: var(--color-text);
|
||||
font-size: clamp(1.05rem, 4cqi, 1.35rem);
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.office-setup-copy > p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.office-setup-progress {
|
||||
position: relative;
|
||||
width: min(260px, 72cqi);
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-border) 45%, transparent);
|
||||
}
|
||||
|
||||
.office-setup-progress > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 42%;
|
||||
border-radius: inherit;
|
||||
background: color-mix(in srgb, var(--color-primary) 72%, var(--color-text) 28%);
|
||||
animation: office-setup-progress 1.45s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.office-setup-progress.is-paused > span {
|
||||
width: 100%;
|
||||
opacity: 0.42;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.office-bootstrap-actions,
|
||||
.office-start-actions {
|
||||
.office-template-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.office-log {
|
||||
min-height: 140px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
|
||||
.office-dashboard-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.office-dashboard-heading {
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.72rem;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-template-grid {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.office-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.office-create-tile {
|
||||
|
|
@ -577,45 +734,182 @@
|
|||
font-size: 28px;
|
||||
}
|
||||
|
||||
.office-recent {
|
||||
.office-document-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
align-content: start;
|
||||
justify-content: stretch;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 196px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.office-document-card.is-open {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 36%, var(--color-border));
|
||||
}
|
||||
|
||||
.office-card-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
max-width: calc(100% - 16px);
|
||||
overflow: hidden;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 42%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-background) 82%, transparent);
|
||||
color: var(--color-text);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-card-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
min-height: 112px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-background) 76%, #fff 4%);
|
||||
}
|
||||
|
||||
.office-page-preview,
|
||||
.office-sheet-preview,
|
||||
.office-slide-preview,
|
||||
.office-preview-fallback {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.office-page-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-width: 720px;
|
||||
gap: 5px;
|
||||
padding: 12px 13px;
|
||||
background:
|
||||
linear-gradient(to bottom, transparent 0, transparent 21px, color-mix(in srgb, var(--color-border) 30%, transparent) 22px),
|
||||
color-mix(in srgb, var(--color-panel) 72%, transparent);
|
||||
background-size: 100% 22px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.office-list-label {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-recent-row {
|
||||
justify-content: flex-start;
|
||||
min-height: 36px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.office-recent-text {
|
||||
display: grid;
|
||||
.office-page-preview span,
|
||||
.office-slide-line span,
|
||||
.office-slide-line strong,
|
||||
.office-sheet-row span {
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.office-recent-text > span,
|
||||
.office-recent-text > small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-recent-text > small {
|
||||
.office-page-preview span {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.office-sheet-preview {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
background: color-mix(in srgb, var(--color-panel) 74%, transparent);
|
||||
}
|
||||
|
||||
.office-sheet-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.office-sheet-row + .office-sheet-row {
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
|
||||
}
|
||||
|
||||
.office-sheet-row span {
|
||||
padding: 4px 5px;
|
||||
border-right: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.office-sheet-row span:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.office-slide-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--color-panel) 80%, transparent), color-mix(in srgb, var(--color-background) 84%, var(--color-primary) 10%));
|
||||
}
|
||||
|
||||
.office-slide-line {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.office-slide-line strong {
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.office-slide-line span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.office-preview-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--color-primary) 64%, var(--color-text));
|
||||
background: color-mix(in srgb, var(--color-panel) 76%, transparent);
|
||||
}
|
||||
|
||||
.office-preview-fallback .material-symbols-outlined {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.office-card-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 720;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.office-document-card small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-frame-wrap {
|
||||
|
|
@ -649,6 +943,19 @@
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes office-setup-progress {
|
||||
0% { transform: translateX(-110%); }
|
||||
55% { transform: translateX(85%); }
|
||||
100% { transform: translateX(250%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.office-panel .spinning,
|
||||
.office-setup-progress > span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.office-button span:last-child {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const FRAME_NAME_PREFIX = "a0-office-frame";
|
|||
const COLLABORA_STATE_VERSION = "2026-04-26.1";
|
||||
const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
|
||||
const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
|
||||
const SETUP_POLL_INTERVAL_MS = 4000;
|
||||
|
||||
function makeFrameName() {
|
||||
const id = globalThis.crypto?.randomUUID?.()
|
||||
|
|
@ -49,9 +50,22 @@ function sameDocument(left = {}, right = {}) {
|
|||
return Boolean(leftPath && rightPath && leftPath === rightPath);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (!Number.isFinite(size) || size <= 0) return "";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let amount = size;
|
||||
let index = 0;
|
||||
while (amount >= 1024 && index < units.length - 1) {
|
||||
amount /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
const digits = amount >= 10 || index === 0 ? 0 : 1;
|
||||
return `${amount.toFixed(digits)} ${units[index]}`;
|
||||
}
|
||||
|
||||
const model = {
|
||||
status: null,
|
||||
logs: null,
|
||||
recent: [],
|
||||
openDocuments: [],
|
||||
tabs: [],
|
||||
|
|
@ -72,6 +86,7 @@ const model = {
|
|||
_mode: "canvas",
|
||||
_floatingCleanup: null,
|
||||
_saveWaiters: [],
|
||||
_statusPollTimer: null,
|
||||
|
||||
async init(element = null) {
|
||||
return await this.onMount(element, { mode: "canvas" });
|
||||
|
|
@ -115,6 +130,7 @@ const model = {
|
|||
cleanup() {
|
||||
this._floatingCleanup?.();
|
||||
this._floatingCleanup = null;
|
||||
this.clearStatusPoll();
|
||||
if (this._mode === "modal") {
|
||||
this._root = null;
|
||||
}
|
||||
|
|
@ -125,20 +141,103 @@ const model = {
|
|||
this.status = await callJsonApi("/plugins/_office/office_session", { action: "status" });
|
||||
const recent = await callJsonApi("/plugins/_office/office_session", { action: "recent" });
|
||||
this.recent = recent?.documents || [];
|
||||
const openDocuments = await callJsonApi("/plugins/_office/office_session", { action: "open_documents" });
|
||||
this.openDocuments = openDocuments?.documents || [];
|
||||
if (!this.status?.healthy) {
|
||||
const logs = await callJsonApi("/plugins/_office/collabora_logs", {});
|
||||
this.logs = logs;
|
||||
if (this.status?.healthy) {
|
||||
await this.syncOpenSessions();
|
||||
} else {
|
||||
this.openDocuments = [];
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.scheduleStatusPoll();
|
||||
}
|
||||
},
|
||||
|
||||
async syncOpenSessions() {
|
||||
const sessionIds = this.tabs
|
||||
.map((tab) => normalizeTabId(tab?.session_id))
|
||||
.filter(Boolean);
|
||||
const response = await callJsonApi("/plugins/_office/office_session", {
|
||||
action: "sync_open_sessions",
|
||||
session_ids: sessionIds,
|
||||
});
|
||||
this.openDocuments = response?.documents || [];
|
||||
return response;
|
||||
},
|
||||
|
||||
async retry() {
|
||||
this.message = "Retrying Collabora setup...";
|
||||
this.message = "Retrying Office setup...";
|
||||
this.status = await callJsonApi("/plugins/_office/office_session", { action: "retry" });
|
||||
this.scheduleStatusPoll();
|
||||
},
|
||||
|
||||
clearStatusPoll() {
|
||||
if (!this._statusPollTimer) return;
|
||||
globalThis.clearTimeout(this._statusPollTimer);
|
||||
this._statusPollTimer = null;
|
||||
},
|
||||
|
||||
scheduleStatusPoll() {
|
||||
this.clearStatusPoll();
|
||||
if (!this.shouldPollSetup()) return;
|
||||
this._statusPollTimer = globalThis.setTimeout(() => {
|
||||
this._statusPollTimer = null;
|
||||
void this.refresh();
|
||||
}, SETUP_POLL_INTERVAL_MS);
|
||||
},
|
||||
|
||||
shouldPollSetup() {
|
||||
if (this.session || this.status?.healthy) return false;
|
||||
if (!this.status) return true;
|
||||
const state = String(this.status.state || "").toLowerCase();
|
||||
return Boolean(this.status.installing || state === "installing" || state === "idle");
|
||||
},
|
||||
|
||||
setupState() {
|
||||
return String(this.status?.state || "installing").toLowerCase();
|
||||
},
|
||||
|
||||
isSetupBusy() {
|
||||
const state = this.setupState();
|
||||
return !this.status || Boolean(this.status.installing) || state === "installing" || state === "idle";
|
||||
},
|
||||
|
||||
isSetupBlocked() {
|
||||
const state = this.setupState();
|
||||
return state === "failed" || state === "degraded";
|
||||
},
|
||||
|
||||
showSetupActions() {
|
||||
return this.isSetupBlocked() || (!this.isSetupBusy() && !this.status?.healthy);
|
||||
},
|
||||
|
||||
setupIcon() {
|
||||
return this.isSetupBlocked() ? "error" : "progress_activity";
|
||||
},
|
||||
|
||||
setupTitle() {
|
||||
if (this.isSetupBlocked()) return "Setup needs attention";
|
||||
return "Setup in progress";
|
||||
},
|
||||
|
||||
setupMessage() {
|
||||
if (this.isSetupBlocked()) {
|
||||
return "Office could not finish setup. Retry when you are ready.";
|
||||
}
|
||||
return "Please wait while Office is prepared. This can take a few minutes the first time.";
|
||||
},
|
||||
|
||||
healthTitle() {
|
||||
if (this.status?.healthy) return "Office is ready";
|
||||
if (this.isSetupBlocked()) return "Office setup needs attention";
|
||||
if (this.isSetupBusy()) return "Office setup is in progress";
|
||||
return "Office status";
|
||||
},
|
||||
|
||||
healthText() {
|
||||
if (this.isSetupBlocked()) return "attention";
|
||||
if (this.isSetupBusy()) return "setup";
|
||||
return String(this.status?.state || "status");
|
||||
},
|
||||
|
||||
async create(kind = "document") {
|
||||
|
|
@ -491,13 +590,71 @@ const model = {
|
|||
return basename || path.split("/").filter(Boolean).pop() || "Office file";
|
||||
},
|
||||
|
||||
openDocumentMeta(doc) {
|
||||
const sessions = Number(doc?.open_sessions || 0);
|
||||
openCards() {
|
||||
return this.tabs.map((tab) => ({ ...tab, dashboard_open: true }));
|
||||
},
|
||||
|
||||
recentCards() {
|
||||
const openFileIds = new Set(this.tabs.map((tab) => normalizeTabId(tab?.file_id)).filter(Boolean));
|
||||
return (this.recent || []).filter((doc) => !openFileIds.has(normalizeTabId(doc?.file_id)));
|
||||
},
|
||||
|
||||
dashboardTitle(doc) {
|
||||
return this.openDocumentLabel(doc);
|
||||
},
|
||||
|
||||
dashboardMeta(doc) {
|
||||
const extension = String(doc?.extension || "").trim().toUpperCase();
|
||||
return [
|
||||
extension,
|
||||
sessions ? `${sessions} session${sessions === 1 ? "" : "s"}` : "",
|
||||
].filter(Boolean).join(" / ");
|
||||
const size = formatBytes(doc?.size);
|
||||
return [extension, size].filter(Boolean).join(" / ");
|
||||
},
|
||||
|
||||
previewKind(doc) {
|
||||
const kind = String(doc?.preview?.kind || "").trim();
|
||||
if (kind === "spreadsheet" && !doc?.preview?.rows?.length && doc?.preview?.lines?.length) return "document";
|
||||
if (kind === "presentation" && !doc?.preview?.slides?.length && doc?.preview?.lines?.length) return "document";
|
||||
if (kind) return kind;
|
||||
const extension = String(doc?.extension || "").toLowerCase();
|
||||
if (["xlsx", "ods"].includes(extension)) return "spreadsheet";
|
||||
if (["pptx", "odp"].includes(extension)) return "presentation";
|
||||
if (["docx", "odt"].includes(extension)) return "document";
|
||||
return "file";
|
||||
},
|
||||
|
||||
hasPreview(doc) {
|
||||
const preview = doc?.preview || {};
|
||||
return Boolean(
|
||||
preview.available
|
||||
&& (
|
||||
preview.lines?.length
|
||||
|| preview.rows?.length
|
||||
|| preview.slides?.length
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
previewLines(doc) {
|
||||
const lines = doc?.preview?.lines || [];
|
||||
if (lines.length) return lines.slice(0, 5).map((line) => String(line || ""));
|
||||
const slides = doc?.preview?.slides || [];
|
||||
if (slides.length) {
|
||||
return [slides[0]?.title, ...(slides[0]?.lines || [])].filter(Boolean).slice(0, 5);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
previewRows(doc) {
|
||||
return (doc?.preview?.rows || [])
|
||||
.slice(0, 5)
|
||||
.map((row) => {
|
||||
const cells = (Array.isArray(row) ? row : []).slice(0, 4).map((cell) => String(cell ?? ""));
|
||||
while (cells.length < 4) cells.push("");
|
||||
return cells;
|
||||
});
|
||||
},
|
||||
|
||||
previewSlides(doc) {
|
||||
return (doc?.preview?.slides || []).slice(0, 2);
|
||||
},
|
||||
|
||||
onPostMessage(event) {
|
||||
|
|
|
|||
53
tests/test_office_canvas_setup.py
Normal file
53
tests/test_office_canvas_setup.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_office_setup_canvas_uses_simple_progress_instead_of_install_logs():
|
||||
panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert "Agent Zero Office" in panel
|
||||
assert "setupTitle()" in panel
|
||||
assert "Setup in progress" in store
|
||||
assert "office-log" not in panel
|
||||
assert "collabora_logs" not in store
|
||||
|
||||
|
||||
def test_office_dashboard_uses_cards_and_visible_tabs_for_open_files():
|
||||
panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert "office-card-grid" in panel
|
||||
assert "office-document-card" in panel
|
||||
assert "openCards()" in panel
|
||||
assert "recentCards()" in panel
|
||||
assert "office-recent-row" not in panel
|
||||
assert "sync_open_sessions" in store
|
||||
|
||||
|
||||
def test_right_canvas_keeps_restored_office_surface_until_registration_finishes():
|
||||
canvas_store = (
|
||||
PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
init_registration = canvas_store.index('await callJsExtensions("right_canvas_register_surfaces", this);')
|
||||
init_ensure = canvas_store.index("this.ensureActiveSurface();", init_registration)
|
||||
register_surface = canvas_store.index("registerSurface(surface)")
|
||||
register_guard = canvas_store.index("if (!this._registering)", register_surface)
|
||||
guarded_ensure = canvas_store.index("this.ensureActiveSurface();", register_guard)
|
||||
open_surface = canvas_store.index("async open", register_surface)
|
||||
|
||||
assert init_registration < init_ensure
|
||||
assert register_surface < register_guard < guarded_ensure < open_surface
|
||||
|
|
@ -106,6 +106,39 @@ def test_close_session_revokes_token_lock_and_open_document_metadata(office_stat
|
|||
assert wopi_store.close_session(session_id=session["session_id"]) == 0
|
||||
|
||||
|
||||
def test_sync_open_sessions_closes_sessions_without_visible_tabs(office_state):
|
||||
first = wopi_store.create_document("document", "Visible", "docx", "shown")
|
||||
second = wopi_store.create_document("document", "Orphan", "docx", "hidden")
|
||||
visible = wopi_store.create_session(first["file_id"], "user-a", "write", "http://localhost:32080")
|
||||
orphan = wopi_store.create_session(second["file_id"], "user-a", "write", "http://localhost:32080")
|
||||
ok, _ = wopi_store.lock(second["file_id"], "orphan-lock", orphan["session_id"], 120)
|
||||
assert ok is True
|
||||
|
||||
assert wopi_store.sync_open_sessions([visible["session_id"]]) == 1
|
||||
|
||||
open_docs = wopi_store.get_open_documents()
|
||||
assert len(open_docs) == 1
|
||||
assert open_docs[0]["file_id"] == first["file_id"]
|
||||
assert wopi_store.get_lock(second["file_id"]) == ""
|
||||
with pytest.raises(PermissionError):
|
||||
wopi_store.validate_token(orphan["access_token"], second["file_id"])
|
||||
|
||||
|
||||
def test_recent_documents_include_lightweight_previews(office_state):
|
||||
doc = wopi_store.create_document("document", "Preview Memo", "docx", "A calm dashboard.")
|
||||
sheet = wopi_store.create_document("spreadsheet", "Preview Sheet", "xlsx", "Name,Value\nOffice,1")
|
||||
deck = wopi_store.create_document("presentation", "Preview Deck", "pptx", "First slide")
|
||||
|
||||
previews = {
|
||||
item["file_id"]: item["preview"]
|
||||
for item in wopi_store.get_recent_documents(limit=3)
|
||||
}
|
||||
|
||||
assert previews[doc["file_id"]]["lines"][0] == "Preview Memo"
|
||||
assert previews[sheet["file_id"]]["rows"][0] == ["Name", "Value"]
|
||||
assert previews[deck["file_id"]]["slides"][0]["title"] == "Preview Deck"
|
||||
|
||||
|
||||
def test_put_file_requires_lock_and_updates_version_history(office_state):
|
||||
doc = wopi_store.create_document("document", "Save Test", "docx", "before")
|
||||
session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ const model = {
|
|||
this.surfaces.push(normalized);
|
||||
}
|
||||
this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
this.ensureActiveSurface();
|
||||
if (!this._registering) {
|
||||
this.ensureActiveSurface();
|
||||
}
|
||||
},
|
||||
|
||||
ensureActiveSurface() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue