Files
mnote/rust/crates/mnote-web/src/routes/onlyoffice_bridge.rs
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
2026-06-01 09:29:12 +08:00

2227 lines
89 KiB
Rust

use axum::extract::{Path, Query};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{sleep, Duration, Instant};
use uuid::Uuid;
pub const MNOTE_ONLYOFFICE_BRIDGE_GUID: &str = "asc.{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}";
const MAX_LONG_POLL_MS: u64 = 25_000;
const LONG_POLL_INTERVAL_MS: u64 = 250;
static BRIDGE_COMMAND_COUNTER: AtomicU64 = AtomicU64::new(1);
static BRIDGE_STATE: OnceLock<Mutex<HashMap<String, BridgeSessionState>>> = OnceLock::new();
#[derive(Debug, Default)]
struct BridgeSessionState {
queue: VecDeque<BridgeCommandWire>,
results: HashMap<String, BridgeResultWire>,
token: Option<String>,
editor_type: Option<String>,
document_id: Option<String>,
asset_id: Option<String>,
file_type: Option<String>,
doc_key: Option<String>,
page_origin: Option<String>,
last_seen_millis: u128,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeCommandWire {
pub id: String,
pub action: String,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeResultWire {
pub id: String,
pub ok: bool,
#[serde(default)]
pub result: Value,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeSessionInfo {
pub session_id: String,
pub editor_type: Option<String>,
pub document_id: Option<String>,
pub asset_id: Option<String>,
pub file_type: Option<String>,
pub doc_key: Option<String>,
pub page_origin: Option<String>,
pub last_seen_millis: u128,
pub pending_commands: usize,
pub pending_results: usize,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgePluginConfigQuery {
session_id: Option<String>,
api_base: Option<String>,
token: Option<String>,
document_id: Option<String>,
asset_id: Option<String>,
file_type: Option<String>,
doc_key: Option<String>,
page_origin: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgePluginIndexQuery {
session_id: Option<String>,
api_base: Option<String>,
token: Option<String>,
document_id: Option<String>,
asset_id: Option<String>,
file_type: Option<String>,
doc_key: Option<String>,
page_origin: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BridgePluginIndexState {
session_id: String,
api_base: String,
token: String,
#[serde(default)]
document_id: Option<String>,
#[serde(default)]
asset_id: Option<String>,
#[serde(default)]
file_type: Option<String>,
#[serde(default)]
doc_key: Option<String>,
#[serde(default)]
page_origin: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeSessionPayload {
pub session_id: String,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub editor_type: Option<String>,
#[serde(default)]
pub document_id: Option<String>,
#[serde(default)]
pub asset_id: Option<String>,
#[serde(default)]
pub file_type: Option<String>,
#[serde(default)]
pub doc_key: Option<String>,
#[serde(default)]
pub page_origin: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeCloseSessionPayload {
pub session_id: String,
#[serde(default)]
pub token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeEnqueuePayload {
session_id: String,
#[serde(default)]
token: Option<String>,
action: String,
#[serde(default)]
payload: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeNextQuery {
session_id: String,
token: Option<String>,
timeout_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeResultQuery {
session_id: String,
token: Option<String>,
command_id: String,
timeout_ms: Option<u64>,
}
pub async fn plugin_config(Query(query): Query<BridgePluginConfigQuery>) -> Response {
let session_id = non_empty(query.session_id).unwrap_or_else(|| "mnote-onlyoffice".into());
let api_base = normalize_api_base(query.api_base.as_deref());
let token = non_empty(query.token).unwrap_or_else(new_bridge_token);
let index_state = encode_index_state(
&session_id,
&api_base,
&token,
query.document_id.as_deref(),
query.asset_id.as_deref(),
query.file_type.as_deref(),
query.doc_key.as_deref(),
query.page_origin.as_deref(),
);
let index_url = format!("{api_base}/api/onlyoffice/bridge/plugin/index/{index_state}");
let payload = json!({
"name": "MNote Bridge",
"guid": MNOTE_ONLYOFFICE_BRIDGE_GUID,
"mnoteBridgeToken": token,
"version": "0.1.0",
"variations": [{
"description": "MNote agent bridge for ONLYOFFICE documents, spreadsheets, and presentations.",
"url": index_url,
"EditorsSupport": ["word", "cell", "slide"],
"isViewer": false,
"isVisual": false,
"isModal": false,
"isInsideMode": false,
"initDataType": "none",
"initData": "",
"buttons": []
}]
});
json_response(payload)
}
pub async fn plugin_index(Query(query): Query<BridgePluginIndexQuery>) -> impl IntoResponse {
let session_id = non_empty(query.session_id).unwrap_or_else(|| "mnote-onlyoffice".into());
let api_base = normalize_api_base(query.api_base.as_deref());
let token = non_empty(query.token).unwrap_or_else(new_bridge_token);
render_plugin_index(
&session_id,
&api_base,
&token,
query.document_id.as_deref(),
query.asset_id.as_deref(),
query.file_type.as_deref(),
query.doc_key.as_deref(),
query.page_origin.as_deref(),
)
}
pub async fn plugin_index_with_state(
Path(encoded_state): Path<String>,
Query(query): Query<BridgePluginIndexQuery>,
) -> impl IntoResponse {
let state = decode_index_state(&encoded_state);
let session_id = non_empty(query.session_id)
.or_else(|| state.as_ref().map(|value| value.session_id.clone()))
.unwrap_or_else(|| "mnote-onlyoffice".into());
let api_base = normalize_api_base(query.api_base.as_deref().or_else(|| {
state
.as_ref()
.map(|value| value.api_base.as_str())
.filter(|value| !value.is_empty())
}));
let document_id = query.document_id.as_deref().or_else(|| {
state
.as_ref()
.and_then(|value| value.document_id.as_deref())
});
let asset_id = query
.asset_id
.as_deref()
.or_else(|| state.as_ref().and_then(|value| value.asset_id.as_deref()));
let file_type = query
.file_type
.as_deref()
.or_else(|| state.as_ref().and_then(|value| value.file_type.as_deref()));
let doc_key = query
.doc_key
.as_deref()
.or_else(|| state.as_ref().and_then(|value| value.doc_key.as_deref()));
let page_origin = query.page_origin.as_deref().or_else(|| {
state
.as_ref()
.and_then(|value| value.page_origin.as_deref())
});
let token = non_empty(query.token)
.or_else(|| {
state
.as_ref()
.map(|value| value.token.as_str())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(new_bridge_token);
render_plugin_index(
&session_id,
&api_base,
&token,
document_id,
asset_id,
file_type,
doc_key,
page_origin,
)
}
fn render_plugin_index(
session_id: &str,
api_base: &str,
token: &str,
document_id: Option<&str>,
asset_id: Option<&str>,
file_type: Option<&str>,
doc_key: Option<&str>,
page_origin: Option<&str>,
) -> Html<String> {
let html = format!(
r#"<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<script src="/onlyoffice-server/sdkjs-plugins/v1/plugins.js"></script>
</head>
<body>
<script>
(function () {{
"use strict";
var sessionId = {session_id};
var apiBase = {api_base};
var bridgeToken = {token};
var sessionMeta = {{
documentId: {document_id},
assetId: {asset_id},
fileType: {file_type},
docKey: {doc_key},
pageOrigin: {page_origin}
}};
var stopped = false;
function postJson(path, body) {{
return fetch(apiBase + path, {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify(body || {{}})
}}).then(function (response) {{
return response.json().catch(function () {{ return {{ ok: response.ok }}; }});
}});
}}
function callMethod(name, args) {{
return new Promise(function (resolve) {{
window.Asc.plugin.executeMethod(name, args || [], function (value) {{
resolve(value);
}});
}});
}}
function callCommand(scope, func) {{
return new Promise(function (resolve) {{
window.Asc.scope.mnoteBridge = scope || {{}};
window.Asc.plugin.callCommand(func, false, true, function (value) {{
resolve(value);
}});
}});
}}
async function callCommandJson(scope, func) {{
var value = await callCommand(scope, func);
if (typeof value === "string") return JSON.parse(value);
return value;
}}
function numberOrDefault(value, fallback) {{
var parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
}}
function columnIndexFromLetters(value) {{
var letters = String(value || "").toUpperCase();
var total = 0;
for (var index = 0; index < letters.length; index += 1) {{
total = total * 26 + (letters.charCodeAt(index) - 64);
}}
return Math.max(0, total - 1);
}}
function parseA1Range(address) {{
var match = String(address || "").trim().match(/^([A-Za-z]+)([0-9]+)(?::([A-Za-z]+)([0-9]+))?$/);
if (!match) throw new Error("invalid_a1_range:" + address);
var startCol = columnIndexFromLetters(match[1]);
var startRow = Math.max(0, Number(match[2]) - 1);
var endCol = match[3] ? columnIndexFromLetters(match[3]) : startCol;
var endRow = match[4] ? Math.max(0, Number(match[4]) - 1) : startRow;
var minRow = Math.min(startRow, endRow);
var maxRow = Math.max(startRow, endRow);
var minCol = Math.min(startCol, endCol);
var maxCol = Math.max(startCol, endCol);
return {{
address: String(address || "").trim(),
startRow: minRow,
startCol: minCol,
rowCount: maxRow - minRow + 1,
colCount: maxCol - minCol + 1
}};
}}
function colorFromHex(value) {{
var raw = String(value || "").trim();
if (!raw) return null;
var match = raw.match(/^#?([0-9a-fA-F]{{6}})$/);
if (!match) throw new Error("invalid_hex_color:" + raw);
var hex = match[1];
return Api.CreateColorFromRGB(
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16)
);
}}
function finiteNumberOrNull(value) {{
var parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}}
function expectedEditorTypeForAction(action) {{
var name = String(action || "");
if (name.indexOf("document.") === 0) return "word";
if (name.indexOf("sheet.") === 0) return "cell";
if (name.indexOf("presentation.") === 0) return "slide";
return "";
}}
function assertEditorTypeForAction(action) {{
var expected = expectedEditorTypeForAction(action);
if (!expected) return;
var actual = String(window.Asc.plugin.info && window.Asc.plugin.info.editorType || "");
if (actual !== expected) {{
throw new Error("onlyoffice_editor_type_mismatch:" + action + ":expected_" + expected + ":actual_" + (actual || "unknown"));
}}
}}
async function executeCommand(command) {{
var payload = command.payload || {{}};
assertEditorTypeForAction(command.action);
switch (command.action) {{
case "selection.get":
return {{
editorType: window.Asc.plugin.info && window.Asc.plugin.info.editorType,
text: await callMethod("GetSelectedText", [payload.options || {{
Numbering: false,
Math: false,
TableCellSeparator: "\n",
ParaSeparator: "\n",
TabSymbol: "\t"
}}])
}};
case "document.insert_text":
case "document.replace_selection":
return {{ value: await callMethod("PasteText", [String(payload.text || "")]) }};
case "document.insert_html":
return {{ value: await callMethod("PasteHtml", [String(payload.html || "")]) }};
case "document.export":
return {{
format: payload.format || "markdown",
content: await callMethod("ConvertDocument", [payload.format || "markdown", false, false, true, false])
}};
case "document.search_replace":
return await callCommandJson({{
search: String(payload.search || ""),
replace: String(payload.replace || ""),
matchCase: !!payload.matchCase
}}, function () {{
var search = String(Asc.scope.mnoteBridge.search || "");
var replace = String(Asc.scope.mnoteBridge.replace || "");
if (!search) {{
return JSON.stringify({{ search: search, replace: replace, matchCase: !!Asc.scope.mnoteBridge.matchCase, skipped: true }});
}}
var document = Api.GetDocument();
document.SearchAndReplace({{
searchString: search,
replaceString: replace,
matchCase: !!Asc.scope.mnoteBridge.matchCase
}});
return JSON.stringify({{ search: search, replace: replace, matchCase: !!Asc.scope.mnoteBridge.matchCase }});
}});
case "document.insert_table":
return await callCommandJson({{
rows: Math.max(1, numberOrDefault(payload.rows, 1)),
cols: Math.max(1, numberOrDefault(payload.cols || payload.columns, 1)),
data: Array.isArray(payload.data) ? payload.data : []
}}, function () {{
function cellText(value) {{
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
return String(value);
}}
var doc = Api.GetDocument();
var rows = Number(Asc.scope.mnoteBridge.rows);
var cols = Number(Asc.scope.mnoteBridge.cols);
var table = Api.CreateTable(cols, rows);
table.SetWidth("percent", 100);
var data = Asc.scope.mnoteBridge.data || [];
for (var rowIndex = 0; rowIndex < rows; rowIndex += 1) {{
var row = table.GetRow(rowIndex);
var rowValues = Array.isArray(data[rowIndex]) ? data[rowIndex] : [];
for (var colIndex = 0; colIndex < cols; colIndex += 1) {{
var cell = row.GetCell(colIndex);
if (!cell || !cell.GetContent) continue;
var content = cell.GetContent();
if (content && content.RemoveAllElements) content.RemoveAllElements();
var paragraph = Api.CreateParagraph();
paragraph.AddText(cellText(rowValues[colIndex]));
content.Push(paragraph);
}}
}}
doc.Push(table);
return JSON.stringify({{ rows: rows, cols: cols }});
}});
case "document.get_comments":
return await callCommandJson({{}}, function () {{
var doc = Api.GetDocument();
var comments = doc.GetAllComments ? doc.GetAllComments() : [];
var result = [];
for (var index = 0; index < comments.length; index += 1) {{
var comment = comments[index];
result.push({{
index: index,
id: comment && comment.GetId ? String(comment.GetId()) : "",
text: comment && comment.GetText ? String(comment.GetText() || "") : "",
author: comment && comment.GetAuthorName ? String(comment.GetAuthorName() || "") : "",
quoteText: comment && comment.GetQuoteText ? String(comment.GetQuoteText() || "") : ""
}});
}}
return JSON.stringify({{ comments: result }});
}});
case "document.add_comment":
return await callCommandJson({{
text: String(payload.text || ""),
author: String(payload.author || "MNote AI"),
target: String(payload.target || "selection")
}}, function () {{
var doc = Api.GetDocument();
var text = String(Asc.scope.mnoteBridge.text || "");
var author = String(Asc.scope.mnoteBridge.author || "MNote AI");
var target = String(Asc.scope.mnoteBridge.target || "selection");
var comment = null;
if (target === "document_start") {{
var paragraph = doc.GetElement ? doc.GetElement(0) : null;
if (!paragraph) throw new Error("document_start_not_found");
if (Api.AddComment) {{
comment = Api.AddComment(paragraph, text, author);
}} else if (paragraph.AddComment) {{
comment = paragraph.AddComment(text, author);
}}
}} else {{
var range = doc.GetRangeBySelect ? doc.GetRangeBySelect() : null;
if (!range) throw new Error("selection_range_not_found");
comment = range.AddComment(text, author, "uid" + author);
}}
if (!comment) throw new Error("comment_not_created");
if (doc.ShowComment && comment.GetId) doc.ShowComment([comment.GetId()]);
return JSON.stringify({{
id: comment.GetId ? String(comment.GetId()) : "",
text: comment.GetText ? String(comment.GetText() || text) : text,
author: comment.GetAuthorName ? String(comment.GetAuthorName() || author) : author,
target: target
}});
}});
case "sheet.get_sheets":
return await callCommandJson({{}}, function () {{
var sheets = Api.GetSheets();
var result = [];
for (var index = 0; index < sheets.length; index += 1) {{
var sheet = sheets[index];
result.push({{
index: index,
name: sheet && sheet.GetName ? sheet.GetName() : ""
}});
}}
return JSON.stringify({{ sheets: result }});
}});
case "sheet.add_sheet":
return await callCommandJson({{ name: String(payload.name || "") }}, function () {{
var name = String(Asc.scope.mnoteBridge.name || "").trim();
if (!name) throw new Error("sheet_name_required");
Api.AddSheet(name);
var sheet = Api.GetActiveSheet();
return JSON.stringify({{ name: sheet && sheet.GetName ? sheet.GetName() : name }});
}});
case "sheet.rename_sheet":
return await callCommandJson({{
name: String(payload.name || ""),
sheetIndex: payload.sheetIndex === undefined || payload.sheetIndex === null ? -1 : numberOrDefault(payload.sheetIndex, -1),
sheetName: String(payload.sheetName || "")
}}, function () {{
var name = String(Asc.scope.mnoteBridge.name || "").trim();
if (!name) throw new Error("sheet_name_required");
var sheets = Api.GetSheets();
var wantedIndex = Number(Asc.scope.mnoteBridge.sheetIndex);
var wantedName = String(Asc.scope.mnoteBridge.sheetName || "");
var sheet = null;
if (Number.isFinite(wantedIndex) && wantedIndex >= 0 && wantedIndex < sheets.length) {{
sheet = sheets[wantedIndex];
}} else if (wantedName) {{
for (var index = 0; index < sheets.length; index += 1) {{
if (sheets[index] && sheets[index].GetName && sheets[index].GetName() === wantedName) {{
sheet = sheets[index];
break;
}}
}}
}} else {{
sheet = Api.GetActiveSheet();
}}
if (!sheet) throw new Error("sheet_not_found");
sheet.SetName(name);
return JSON.stringify({{ name: sheet.GetName ? sheet.GetName() : name }});
}});
case "sheet.get_range_values":
return await callCommandJson({{ range: parseA1Range(payload.address || payload.range || "A1") }}, function () {{
var worksheet = Api.GetActiveSheet();
var range = Asc.scope.mnoteBridge.range;
var values = [];
var value2 = [];
var formulas = [];
for (var row = 0; row < range.rowCount; row += 1) {{
var valueRow = [];
var value2Row = [];
var formulaRow = [];
for (var col = 0; col < range.colCount; col += 1) {{
var cell = worksheet.GetRangeByNumber(range.startRow + row, range.startCol + col);
valueRow.push(cell.GetValue());
value2Row.push(cell.GetValue2 ? cell.GetValue2() : cell.GetValue());
formulaRow.push(cell.GetFormula ? cell.GetFormula() : "");
}}
values.push(valueRow);
value2.push(value2Row);
formulas.push(formulaRow);
}}
return JSON.stringify({{
address: range.address,
startRow: range.startRow,
startCol: range.startCol,
rowCount: range.rowCount,
colCount: range.colCount,
values: values,
value2: value2,
formulas: formulas
}});
}});
case "sheet.get_range":
return await callCommandJson({{ address: payload.address || "" }}, function () {{
var worksheet = Api.GetActiveSheet();
var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
return JSON.stringify({{
value: range.GetValue(),
value2: range.GetValue2 ? range.GetValue2() : range.GetValue(),
formula: range.GetFormula ? range.GetFormula() : ""
}});
}});
case "sheet.get_values":
return await callCommandJson({{
startRow: numberOrDefault(payload.startRow, 0),
startCol: numberOrDefault(payload.startCol, 0),
rowCount: Math.max(1, numberOrDefault(payload.rowCount, 1)),
colCount: Math.max(1, numberOrDefault(payload.colCount, 1))
}}, function () {{
var worksheet = Api.GetActiveSheet();
var values = [];
var value2 = [];
var formulas = [];
for (var row = 0; row < Asc.scope.mnoteBridge.rowCount; row += 1) {{
var valueRow = [];
var value2Row = [];
var formulaRow = [];
for (var col = 0; col < Asc.scope.mnoteBridge.colCount; col += 1) {{
var cell = worksheet.GetRangeByNumber(
Asc.scope.mnoteBridge.startRow + row,
Asc.scope.mnoteBridge.startCol + col
);
valueRow.push(cell.GetValue());
value2Row.push(cell.GetValue2 ? cell.GetValue2() : cell.GetValue());
formulaRow.push(cell.GetFormula ? cell.GetFormula() : "");
}}
values.push(valueRow);
value2.push(value2Row);
formulas.push(formulaRow);
}}
return JSON.stringify({{
startRow: Asc.scope.mnoteBridge.startRow,
startCol: Asc.scope.mnoteBridge.startCol,
rowCount: Asc.scope.mnoteBridge.rowCount,
colCount: Asc.scope.mnoteBridge.colCount,
values: values,
value2: value2,
formulas: formulas
}});
}});
case "sheet.set_value":
return await callCommandJson({{ address: payload.address || "", value: payload.value }}, function () {{
var worksheet = Api.GetActiveSheet();
var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
range.SetValue(Asc.scope.mnoteBridge.value);
return JSON.stringify({{ value: range.GetValue() }});
}});
case "sheet.set_formula":
return await callCommandJson({{ address: payload.address || "", formula: payload.formula || "" }}, function () {{
var worksheet = Api.GetActiveSheet();
var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
range.SetValue(Asc.scope.mnoteBridge.formula);
return JSON.stringify({{ formula: range.GetFormula ? range.GetFormula() : Asc.scope.mnoteBridge.formula }});
}});
case "sheet.batch_set_values":
return await callCommandJson({{
startRow: numberOrDefault(payload.startRow, 0),
startCol: numberOrDefault(payload.startCol, 0),
values: Array.isArray(payload.values) ? payload.values : []
}}, function () {{
var worksheet = Api.GetActiveSheet();
var rows = Asc.scope.mnoteBridge.values || [];
var colCount = 0;
for (var row = 0; row < rows.length; row += 1) {{
var cells = Array.isArray(rows[row]) ? rows[row] : [rows[row]];
if (cells.length > colCount) colCount = cells.length;
for (var col = 0; col < cells.length; col += 1) {{
worksheet.GetRangeByNumber(
Asc.scope.mnoteBridge.startRow + row,
Asc.scope.mnoteBridge.startCol + col
).SetValue(cells[col]);
}}
}}
return JSON.stringify({{
startRow: Asc.scope.mnoteBridge.startRow,
startCol: Asc.scope.mnoteBridge.startCol,
rowCount: rows.length,
colCount: colCount
}});
}});
case "sheet.set_range_values":
return await callCommandJson({{
range: parseA1Range(payload.address || payload.range || "A1"),
values: Array.isArray(payload.values) ? payload.values : []
}}, function () {{
var worksheet = Api.GetActiveSheet();
var range = Asc.scope.mnoteBridge.range;
var rows = Asc.scope.mnoteBridge.values || [];
var writtenRows = Math.min(range.rowCount, rows.length);
var writtenCols = 0;
for (var row = 0; row < writtenRows; row += 1) {{
var cells = Array.isArray(rows[row]) ? rows[row] : [rows[row]];
var colLimit = Math.min(range.colCount, cells.length);
if (colLimit > writtenCols) writtenCols = colLimit;
for (var col = 0; col < colLimit; col += 1) {{
worksheet.GetRangeByNumber(range.startRow + row, range.startCol + col).SetValue(cells[col]);
}}
}}
return JSON.stringify({{
address: range.address,
startRow: range.startRow,
startCol: range.startCol,
rowCount: writtenRows,
colCount: writtenCols
}});
}});
case "sheet.format_range":
return await callCommandJson({{
address: String(payload.address || payload.range || ""),
bold: payload.bold === undefined || payload.bold === null ? null : !!payload.bold,
italic: payload.italic === undefined || payload.italic === null ? null : !!payload.italic,
underline: payload.underline === undefined || payload.underline === null ? null : !!payload.underline,
fillColor: String(payload.fillColor || ""),
fontColor: String(payload.fontColor || ""),
fontSize: finiteNumberOrNull(payload.fontSize),
fontName: String(payload.fontName || ""),
horizontalAlign: String(payload.horizontalAlign || ""),
verticalAlign: String(payload.verticalAlign || ""),
numberFormat: String(payload.numberFormat || "")
}}, function () {{
function colorFromHex(value) {{
var raw = String(value || "").trim();
if (!raw) return null;
var match = raw.match(/^#?([0-9a-fA-F]{{6}})$/);
if (!match) throw new Error("invalid_hex_color:" + raw);
var hex = match[1];
return Api.CreateColorFromRGB(
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16)
);
}}
var worksheet = Api.GetActiveSheet();
var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
var applied = [];
if (Asc.scope.mnoteBridge.bold !== null && range.SetBold) {{
range.SetBold(!!Asc.scope.mnoteBridge.bold);
applied.push("bold");
}}
if (Asc.scope.mnoteBridge.italic !== null && range.SetItalic) {{
range.SetItalic(!!Asc.scope.mnoteBridge.italic);
applied.push("italic");
}}
if (Asc.scope.mnoteBridge.underline !== null && range.SetUnderline) {{
range.SetUnderline(!!Asc.scope.mnoteBridge.underline);
applied.push("underline");
}}
if (Asc.scope.mnoteBridge.fillColor && range.SetFillColor) {{
range.SetFillColor(colorFromHex(Asc.scope.mnoteBridge.fillColor));
applied.push("fillColor");
}}
if (Asc.scope.mnoteBridge.fontColor && range.SetFontColor) {{
range.SetFontColor(colorFromHex(Asc.scope.mnoteBridge.fontColor));
applied.push("fontColor");
}}
if (Asc.scope.mnoteBridge.fontSize !== null && range.SetFontSize) {{
range.SetFontSize(Asc.scope.mnoteBridge.fontSize);
applied.push("fontSize");
}}
if (Asc.scope.mnoteBridge.fontName && range.SetFontName) {{
range.SetFontName(Asc.scope.mnoteBridge.fontName);
applied.push("fontName");
}}
if (Asc.scope.mnoteBridge.horizontalAlign && range.SetAlignHorizontal) {{
range.SetAlignHorizontal(Asc.scope.mnoteBridge.horizontalAlign);
applied.push("horizontalAlign");
}}
if (Asc.scope.mnoteBridge.verticalAlign && range.SetAlignVertical) {{
range.SetAlignVertical(Asc.scope.mnoteBridge.verticalAlign);
applied.push("verticalAlign");
}}
if (Asc.scope.mnoteBridge.numberFormat && range.SetNumberFormat) {{
range.SetNumberFormat(Asc.scope.mnoteBridge.numberFormat);
applied.push("numberFormat");
}}
return JSON.stringify({{
address: Asc.scope.mnoteBridge.address,
applied: applied
}});
}});
case "sheet.set_dimensions":
return await callCommandJson({{
columnIndex: payload.columnIndex === undefined || payload.columnIndex === null ? -1 : numberOrDefault(payload.columnIndex, -1),
columnWidth: finiteNumberOrNull(payload.columnWidth),
rowIndex: payload.rowIndex === undefined || payload.rowIndex === null ? -1 : numberOrDefault(payload.rowIndex, -1),
rowHeight: finiteNumberOrNull(payload.rowHeight)
}}, function () {{
var worksheet = Api.GetActiveSheet();
var changed = [];
if (Asc.scope.mnoteBridge.columnIndex >= 0 && Asc.scope.mnoteBridge.columnWidth !== null) {{
worksheet.SetColumnWidth(Asc.scope.mnoteBridge.columnIndex, Asc.scope.mnoteBridge.columnWidth);
changed.push("columnWidth");
}}
if (Asc.scope.mnoteBridge.rowIndex >= 0 && Asc.scope.mnoteBridge.rowHeight !== null) {{
worksheet.SetRowHeight(Asc.scope.mnoteBridge.rowIndex, Asc.scope.mnoteBridge.rowHeight);
changed.push("rowHeight");
}}
return JSON.stringify({{
columnIndex: Asc.scope.mnoteBridge.columnIndex,
columnWidth: Asc.scope.mnoteBridge.columnWidth,
rowIndex: Asc.scope.mnoteBridge.rowIndex,
rowHeight: Asc.scope.mnoteBridge.rowHeight,
changed: changed
}});
}});
case "sheet.sort_range":
return await callCommandJson({{
range: parseA1Range(payload.address || payload.range || "A1"),
keyColumn: Math.max(1, numberOrDefault(payload.keyColumn || payload.key_column, 1)),
order: String(payload.order || "ascending"),
header: payload.header === undefined ? true : !!payload.header
}}, function () {{
var worksheet = Api.GetActiveSheet();
var rangeInfo = Asc.scope.mnoteBridge.range;
var range = worksheet.GetRange(rangeInfo.address);
if (!range || !range.SetSort) throw new Error("range_sort_not_supported");
var keyColumn = Math.min(Math.max(1, Number(Asc.scope.mnoteBridge.keyColumn)), rangeInfo.colCount);
var keyCell = worksheet.GetCells(
rangeInfo.startRow + (Asc.scope.mnoteBridge.header ? 2 : 1),
rangeInfo.startCol + keyColumn
);
var rawOrder = String(Asc.scope.mnoteBridge.order || "ascending").toLowerCase();
var order = rawOrder === "desc" || rawOrder === "descending" || rawOrder === "xldescending"
? "xlDescending"
: "xlAscending";
var header = Asc.scope.mnoteBridge.header ? "xlYes" : "xlNo";
range.SetSort(keyCell, order, null, null, null, null, header);
return JSON.stringify({{
address: rangeInfo.address,
keyColumn: keyColumn,
order: order,
header: header
}});
}});
case "sheet.add_chart":
return await callCommandJson({{
address: String(payload.address || payload.range || ""),
chartType: String(payload.chartType || payload.chart_type || "bar"),
inColumns: payload.inColumns === undefined && payload.in_columns === undefined ? true : !!(payload.inColumns === undefined ? payload.in_columns : payload.inColumns),
style: Math.max(1, numberOrDefault(payload.style, 2)),
widthMm: finiteNumberOrNull(payload.widthMm || payload.width_mm) || 120,
heightMm: finiteNumberOrNull(payload.heightMm || payload.height_mm) || 80,
fromCol: numberOrDefault(payload.fromCol || payload.from_col, 4),
xOffsetMm: finiteNumberOrNull(payload.xOffsetMm || payload.x_offset_mm) || 0,
fromRow: numberOrDefault(payload.fromRow || payload.from_row, 1),
yOffsetMm: finiteNumberOrNull(payload.yOffsetMm || payload.y_offset_mm) || 0
}}, function () {{
var worksheet = Api.GetActiveSheet();
var address = String(Asc.scope.mnoteBridge.address || "").trim();
if (!address) throw new Error("chart_range_required");
if (!worksheet.AddChart) throw new Error("chart_not_supported");
worksheet.AddChart(
address,
!!Asc.scope.mnoteBridge.inColumns,
String(Asc.scope.mnoteBridge.chartType || "bar"),
Number(Asc.scope.mnoteBridge.style),
Number(Asc.scope.mnoteBridge.widthMm) * 36000,
Number(Asc.scope.mnoteBridge.heightMm) * 36000,
Number(Asc.scope.mnoteBridge.fromCol),
Number(Asc.scope.mnoteBridge.xOffsetMm) * 36000,
Number(Asc.scope.mnoteBridge.fromRow),
Number(Asc.scope.mnoteBridge.yOffsetMm) * 36000
);
return JSON.stringify({{
address: address,
chartType: String(Asc.scope.mnoteBridge.chartType || "bar"),
inColumns: !!Asc.scope.mnoteBridge.inColumns,
style: Number(Asc.scope.mnoteBridge.style)
}});
}});
case "presentation.get_slides":
return await callCommand({{}}, function () {{
var presentation = Api.GetPresentation();
return {{ slidesCount: presentation.GetSlidesCount() }};
}});
case "presentation.get_slide_texts":
return await callCommandJson({{
slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1)
}}, function () {{
function readContentText(content) {{
if (!content || !content.GetElementsCount) return "";
var parts = [];
for (var index = 0; index < content.GetElementsCount(); index += 1) {{
var element = content.GetElement(index);
if (element && element.GetText) {{
var text = String(element.GetText() || "").replace(/[\r\n]+$/g, "");
if (text) parts.push(text);
}}
}}
return parts.join("\n");
}}
function slideShapes(slide) {{
if (!slide) return [];
if (slide.GetAllShapes) return slide.GetAllShapes();
if (slide.GetAllDrawings) return slide.GetAllDrawings();
return [];
}}
function readSlide(presentation, slideIndex) {{
var slide = presentation.GetSlideByIndex(slideIndex);
var shapes = slideShapes(slide);
var shapeTexts = [];
var textParts = [];
for (var shapeIndex = 0; shapeIndex < shapes.length; shapeIndex += 1) {{
var shape = shapes[shapeIndex];
if (!shape || !shape.GetDocContent) continue;
var text = readContentText(shape.GetDocContent());
if (!text) continue;
shapeTexts.push({{ shapeIndex: shapeIndex, text: text }});
textParts.push(text);
}}
return {{ slideIndex: slideIndex, text: textParts.join("\n"), shapes: shapeTexts }};
}}
var presentation = Api.GetPresentation();
var slidesCount = presentation.GetSlidesCount();
var wanted = Number(Asc.scope.mnoteBridge.slideIndex);
var slides = [];
if (Number.isFinite(wanted) && wanted >= 0) {{
if (wanted < slidesCount) slides.push(readSlide(presentation, wanted));
}} else {{
for (var index = 0; index < slidesCount; index += 1) {{
slides.push(readSlide(presentation, index));
}}
}}
return JSON.stringify({{ slidesCount: slidesCount, slides: slides }});
}});
case "presentation.get_shapes":
return await callCommandJson({{
slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1)
}}, function () {{
function readContentText(content) {{
if (!content || !content.GetElementsCount) return "";
var parts = [];
for (var index = 0; index < content.GetElementsCount(); index += 1) {{
var element = content.GetElement(index);
if (element && element.GetText) {{
var text = String(element.GetText() || "").replace(/[\r\n]+$/g, "");
if (text) parts.push(text);
}}
}}
return parts.join("\n");
}}
function slideShapes(slide) {{
if (!slide) return [];
if (slide.GetAllShapes) return slide.GetAllShapes();
if (slide.GetAllDrawings) return slide.GetAllDrawings();
return [];
}}
function readShape(shape, slideIndex, shapeIndex) {{
var content = shape && shape.GetDocContent ? shape.GetDocContent() : null;
return {{
slideIndex: slideIndex,
shapeIndex: shapeIndex,
shapeId: shape && shape.GetInternalId ? String(shape.GetInternalId()) : "",
hasText: !!content,
text: readContentText(content)
}};
}}
var presentation = Api.GetPresentation();
var slidesCount = presentation.GetSlidesCount();
var wanted = Number(Asc.scope.mnoteBridge.slideIndex);
var shapes = [];
for (var slideIndex = 0; slideIndex < slidesCount; slideIndex += 1) {{
if (Number.isFinite(wanted) && wanted >= 0 && slideIndex !== wanted) continue;
var slide = presentation.GetSlideByIndex(slideIndex);
var slideShapesList = slideShapes(slide);
for (var shapeIndex = 0; shapeIndex < slideShapesList.length; shapeIndex += 1) {{
shapes.push(readShape(slideShapesList[shapeIndex], slideIndex, shapeIndex));
}}
}}
return JSON.stringify({{ slidesCount: slidesCount, shapes: shapes }});
}});
case "presentation.add_text_slide":
return await callCommand({{
title: payload.title || "",
body: payload.body || ""
}}, function () {{
var presentation = Api.GetPresentation();
var slide = Api.CreateSlide();
presentation.AddSlide(slide);
var fill = Api.CreateSolidFill(Api.CreateRGBColor(255, 255, 255));
var stroke = Api.CreateStroke(0, Api.CreateNoFill());
var shape = Api.CreateShape("rect", 7600000, 4200000, fill, stroke);
shape.SetPosition(700000, 900000);
var content = shape.GetDocContent();
content.RemoveAllElements();
var title = Api.CreateParagraph();
var titleRun = Api.CreateRun();
titleRun.AddText(String(Asc.scope.mnoteBridge.title || ""));
titleRun.SetFontSize(32);
titleRun.SetBold(true);
title.AddElement(titleRun);
content.Push(title);
var body = Api.CreateParagraph();
var bodyRun = Api.CreateRun();
bodyRun.AddText(String(Asc.scope.mnoteBridge.body || ""));
bodyRun.SetFontSize(20);
body.AddElement(bodyRun);
content.Push(body);
slide.AddObject(shape);
return {{ slidesCount: presentation.GetSlidesCount() }};
}});
case "presentation.replace_text":
return await callCommandJson({{
search: String(payload.search || ""),
replace: String(payload.replace || ""),
slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1),
matchCase: !!payload.matchCase,
replaceAll: payload.replaceAll === undefined ? true : !!payload.replaceAll
}}, function () {{
function readContentText(content) {{
if (!content || !content.GetElementsCount) return "";
var parts = [];
for (var index = 0; index < content.GetElementsCount(); index += 1) {{
var element = content.GetElement(index);
if (element && element.GetText) {{
var text = String(element.GetText() || "").replace(/[\r\n]+$/g, "");
if (text) parts.push(text);
}}
}}
return parts.join("\n");
}}
function writeContentText(content, text) {{
content.RemoveAllElements();
var normalized = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "");
var lines = normalized ? normalized.split(/\n/) : [""];
for (var index = 0; index < lines.length; index += 1) {{
var paragraph = Api.CreateParagraph();
paragraph.AddText(lines[index]);
content.Push(paragraph);
}}
}}
function slideShapes(slide) {{
if (!slide) return [];
if (slide.GetAllShapes) return slide.GetAllShapes();
if (slide.GetAllDrawings) return slide.GetAllDrawings();
return [];
}}
function escapeRegExp(value) {{
return String(value).replace(/[.*+?^${{}}()|[\]\\]/g, "\\$&");
}}
function replaceText(text, search, replace, matchCase, replaceAll) {{
if (!search) return {{ text: text, count: 0 }};
var flags = replaceAll ? "g" : "";
if (!matchCase) flags += "i";
var pattern = new RegExp(escapeRegExp(search), flags);
var count = 0;
var next = String(text || "").replace(pattern, function () {{
count += 1;
return replace;
}});
return {{ text: next, count: count }};
}}
var search = String(Asc.scope.mnoteBridge.search || "");
var replace = String(Asc.scope.mnoteBridge.replace || "");
var presentation = Api.GetPresentation();
var slidesCount = presentation.GetSlidesCount();
var wanted = Number(Asc.scope.mnoteBridge.slideIndex);
var changed = [];
var totalReplacements = 0;
for (var slideIndex = 0; slideIndex < slidesCount; slideIndex += 1) {{
if (Number.isFinite(wanted) && wanted >= 0 && slideIndex !== wanted) continue;
var slide = presentation.GetSlideByIndex(slideIndex);
var shapes = slideShapes(slide);
for (var shapeIndex = 0; shapeIndex < shapes.length; shapeIndex += 1) {{
var shape = shapes[shapeIndex];
if (!shape || !shape.GetDocContent) continue;
var content = shape.GetDocContent();
var currentText = readContentText(content);
var replaced = replaceText(
currentText,
search,
replace,
!!Asc.scope.mnoteBridge.matchCase,
Asc.scope.mnoteBridge.replaceAll !== false
);
if (replaced.count > 0) {{
writeContentText(content, replaced.text);
totalReplacements += replaced.count;
changed.push({{ slideIndex: slideIndex, shapeIndex: shapeIndex, replacements: replaced.count }});
}}
}}
}}
return JSON.stringify({{
search: search,
replace: replace,
replacements: totalReplacements,
changed: changed
}});
}});
case "presentation.set_shape_text":
return await callCommandJson({{
slideIndex: numberOrDefault(payload.slideIndex, 0),
shapeIndex: payload.shapeIndex === undefined || payload.shapeIndex === null ? -1 : numberOrDefault(payload.shapeIndex, -1),
shapeId: String(payload.shapeId || ""),
text: String(payload.text || "")
}}, function () {{
function writeContentText(content, text) {{
content.RemoveAllElements();
var normalized = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "");
var lines = normalized ? normalized.split(/\n/) : [""];
for (var index = 0; index < lines.length; index += 1) {{
var paragraph = Api.CreateParagraph();
paragraph.AddText(lines[index]);
content.Push(paragraph);
}}
}}
function slideShapes(slide) {{
if (!slide) return [];
if (slide.GetAllShapes) return slide.GetAllShapes();
if (slide.GetAllDrawings) return slide.GetAllDrawings();
return [];
}}
var presentation = Api.GetPresentation();
var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
var slide = presentation.GetSlideByIndex(slideIndex);
if (!slide) throw new Error("slide_not_found");
var shapes = slideShapes(slide);
var wantedIndex = Number(Asc.scope.mnoteBridge.shapeIndex);
var wantedId = String(Asc.scope.mnoteBridge.shapeId || "");
var shape = null;
var shapeIndex = -1;
if (Number.isFinite(wantedIndex) && wantedIndex >= 0 && wantedIndex < shapes.length) {{
shape = shapes[wantedIndex];
shapeIndex = wantedIndex;
}} else if (wantedId) {{
for (var index = 0; index < shapes.length; index += 1) {{
if (shapes[index] && shapes[index].GetInternalId && String(shapes[index].GetInternalId()) === wantedId) {{
shape = shapes[index];
shapeIndex = index;
break;
}}
}}
}}
if (!shape || !shape.GetDocContent) throw new Error("shape_text_not_found");
writeContentText(shape.GetDocContent(), Asc.scope.mnoteBridge.text);
return JSON.stringify({{
slideIndex: slideIndex,
shapeIndex: shapeIndex,
shapeId: shape.GetInternalId ? String(shape.GetInternalId()) : "",
text: Asc.scope.mnoteBridge.text
}});
}});
case "presentation.delete_slide":
return await callCommandJson({{
slideIndex: numberOrDefault(payload.slideIndex, 0)
}}, function () {{
var presentation = Api.GetPresentation();
var before = presentation.GetSlidesCount();
var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
var slide = presentation.GetSlideByIndex(slideIndex);
if (!slide) throw new Error("slide_not_found");
slide.Delete();
return JSON.stringify({{
slideIndex: slideIndex,
slidesCountBefore: before,
slidesCount: presentation.GetSlidesCount()
}});
}});
case "presentation.add_table":
return await callCommandJson({{
slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1),
rows: Math.max(1, numberOrDefault(payload.rows, 1)),
cols: Math.max(1, numberOrDefault(payload.cols || payload.columns, 1)),
data: Array.isArray(payload.data) ? payload.data : [],
xMm: finiteNumberOrNull(payload.xMm || payload.x_mm),
yMm: finiteNumberOrNull(payload.yMm || payload.y_mm),
widthMm: finiteNumberOrNull(payload.widthMm || payload.width_mm) || 190,
heightMm: finiteNumberOrNull(payload.heightMm || payload.height_mm) || 90
}}, function () {{
function cellText(value) {{
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
return String(value);
}}
var presentation = Api.GetPresentation();
var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
var slide = slideIndex >= 0 ? presentation.GetSlideByIndex(slideIndex) : presentation.GetCurrentSlide();
if (!slide) {{
slideIndex = 0;
slide = presentation.GetSlideByIndex(0);
}}
if (!slide) throw new Error("slide_not_found");
var rows = Number(Asc.scope.mnoteBridge.rows);
var cols = Number(Asc.scope.mnoteBridge.cols);
var table = Api.CreateTable(cols, rows);
if (!table) throw new Error("table_not_created");
var tableWidth = Number(Asc.scope.mnoteBridge.widthMm) * 36000;
var tableHeight = Number(Asc.scope.mnoteBridge.heightMm) * 36000;
var x = Asc.scope.mnoteBridge.xMm === null
? Math.max(0, (presentation.GetWidth() - tableWidth) / 2)
: Number(Asc.scope.mnoteBridge.xMm) * 36000;
var y = Asc.scope.mnoteBridge.yMm === null
? Math.max(0, (presentation.GetHeight() - tableHeight) / 2)
: Number(Asc.scope.mnoteBridge.yMm) * 36000;
if (table.SetPosition) table.SetPosition(x, y);
if (table.SetSize) table.SetSize(tableWidth, tableHeight);
var data = Asc.scope.mnoteBridge.data || [];
for (var rowIndex = 0; rowIndex < rows; rowIndex += 1) {{
var row = table.GetRow(rowIndex);
var rowValues = Array.isArray(data[rowIndex]) ? data[rowIndex] : [];
for (var colIndex = 0; colIndex < cols; colIndex += 1) {{
var cell = row.GetCell(colIndex);
var content = cell && cell.GetContent ? cell.GetContent() : null;
if (!content) continue;
if (content.RemoveAllElements) content.RemoveAllElements();
var paragraph = Api.CreateParagraph();
paragraph.AddText(cellText(rowValues[colIndex]));
content.Push(paragraph);
}}
}}
slide.AddObject(table);
return JSON.stringify({{
slideIndex: slideIndex >= 0 ? slideIndex : 0,
rows: rows,
cols: cols,
widthMm: Number(Asc.scope.mnoteBridge.widthMm),
heightMm: Number(Asc.scope.mnoteBridge.heightMm)
}});
}});
case "presentation.clear_slide":
return await callCommandJson({{
slideIndex: numberOrDefault(payload.slideIndex, 0)
}}, function () {{
var presentation = Api.GetPresentation();
var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
var slide = presentation.GetSlideByIndex(slideIndex);
if (!slide) throw new Error("slide_not_found");
if (!slide.RemoveAllObjects) throw new Error("clear_slide_not_supported");
slide.RemoveAllObjects();
return JSON.stringify({{
slideIndex: slideIndex,
slidesCount: presentation.GetSlidesCount()
}});
}});
case "presentation.add_shape":
return await callCommandJson({{
slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1),
shapeType: String(payload.shapeType || payload.shape_type || "rect"),
text: String(payload.text || ""),
fillColor: String(payload.fillColor || payload.fill_color || "4F81BD"),
strokeColor: String(payload.strokeColor || payload.stroke_color || ""),
strokeWidthMm: finiteNumberOrNull(payload.strokeWidthMm || payload.stroke_width_mm) || 0,
xMm: finiteNumberOrNull(payload.xMm || payload.x_mm) || 20,
yMm: finiteNumberOrNull(payload.yMm || payload.y_mm) || 35,
widthMm: finiteNumberOrNull(payload.widthMm || payload.width_mm) || 160,
heightMm: finiteNumberOrNull(payload.heightMm || payload.height_mm) || 70
}}, function () {{
function colorFromHexLocal(value, fallback) {{
var raw = String(value || fallback || "").trim();
var match = raw.match(/^#?([0-9a-fA-F]{{6}})$/);
if (!match) throw new Error("invalid_hex_color:" + raw);
var hex = match[1];
var red = parseInt(hex.slice(0, 2), 16);
var green = parseInt(hex.slice(2, 4), 16);
var blue = parseInt(hex.slice(4, 6), 16);
if (Api.RGB) return Api.RGB(red, green, blue);
return Api.CreateRGBColor(red, green, blue);
}}
var presentation = Api.GetPresentation();
var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
var slide = slideIndex >= 0 ? presentation.GetSlideByIndex(slideIndex) : presentation.GetCurrentSlide();
if (!slide) {{
slideIndex = 0;
slide = presentation.GetSlideByIndex(0);
}}
if (!slide) throw new Error("slide_not_found");
var fill = Api.CreateSolidFill(colorFromHexLocal(Asc.scope.mnoteBridge.fillColor, "4F81BD"));
var strokeFill = Asc.scope.mnoteBridge.strokeColor
? Api.CreateSolidFill(colorFromHexLocal(Asc.scope.mnoteBridge.strokeColor, "000000"))
: Api.CreateNoFill();
var stroke = Api.CreateStroke(Number(Asc.scope.mnoteBridge.strokeWidthMm) * 36000, strokeFill);
var shape = Api.CreateShape(
String(Asc.scope.mnoteBridge.shapeType || "rect"),
Number(Asc.scope.mnoteBridge.widthMm) * 36000,
Number(Asc.scope.mnoteBridge.heightMm) * 36000,
fill,
stroke
);
if (!shape) throw new Error("shape_not_created");
shape.SetPosition(Number(Asc.scope.mnoteBridge.xMm) * 36000, Number(Asc.scope.mnoteBridge.yMm) * 36000);
if (shape.SetSize) shape.SetSize(Number(Asc.scope.mnoteBridge.widthMm) * 36000, Number(Asc.scope.mnoteBridge.heightMm) * 36000);
var content = shape.GetDocContent ? shape.GetDocContent() : (shape.GetContent ? shape.GetContent() : null);
if (content && Asc.scope.mnoteBridge.text) {{
if (content.RemoveAllElements) content.RemoveAllElements();
var paragraph = Api.CreateParagraph();
paragraph.AddText(String(Asc.scope.mnoteBridge.text || ""));
content.Push(paragraph);
}}
slide.AddObject(shape);
return JSON.stringify({{
slideIndex: slideIndex >= 0 ? slideIndex : 0,
shapeType: String(Asc.scope.mnoteBridge.shapeType || "rect"),
text: String(Asc.scope.mnoteBridge.text || ""),
fillColor: String(Asc.scope.mnoteBridge.fillColor || "4F81BD")
}});
}});
default:
throw new Error("unsupported_onlyoffice_bridge_action:" + command.action);
}}
}}
async function register() {{
await postJson("/api/onlyoffice/bridge/session", {{
sessionId: sessionId,
token: bridgeToken,
editorType: window.Asc.plugin.info && window.Asc.plugin.info.editorType,
documentId: sessionMeta.documentId || "",
assetId: sessionMeta.assetId || "",
fileType: sessionMeta.fileType || "",
docKey: sessionMeta.docKey || "",
pageOrigin: sessionMeta.pageOrigin || ""
}});
}}
async function loop() {{
while (!stopped) {{
try {{
var query = new URLSearchParams({{
sessionId: sessionId,
token: bridgeToken,
timeoutMs: "25000"
}});
var response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString());
if (response.status === 204) continue;
var command = await response.json();
var result = await executeCommand(command);
await postJson("/api/onlyoffice/bridge/results", {{
sessionId: sessionId,
token: bridgeToken,
id: command.id,
ok: true,
result: result === undefined ? null : result
}});
}} catch (error) {{
if (!stopped && command && command.id) {{
await postJson("/api/onlyoffice/bridge/results", {{
sessionId: sessionId,
token: bridgeToken,
id: command.id,
ok: false,
error: error && error.message ? error.message : String(error)
}}).catch(function () {{}});
}} else {{
await new Promise(function (resolve) {{ setTimeout(resolve, 1000); }});
}}
}}
}}
}}
window.Asc.plugin.init = function () {{
register().then(loop).catch(function () {{
setTimeout(function () {{ register().then(loop); }}, 1000);
}});
}};
window.Asc.plugin.button = function () {{}};
window.addEventListener("beforeunload", function () {{ stopped = true; }});
}}());
</script>
</body>
</html>"#,
session_id = json_string(session_id),
api_base = json_string(api_base),
token = json_string(token),
document_id = json_string(document_id.unwrap_or("")),
asset_id = json_string(asset_id.unwrap_or("")),
file_type = json_string(file_type.unwrap_or("")),
doc_key = json_string(doc_key.unwrap_or("")),
page_origin = json_string(page_origin.unwrap_or("")),
);
Html(html)
}
pub async fn register_session(Json(payload): Json<BridgeSessionPayload>) -> Response {
let Some(session_id) = non_empty(Some(payload.session_id)) else {
return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
};
let Some(token) = non_empty(payload.token) else {
return unauthorized("onlyoffice_bridge_token_required", "缺少 bridge token");
};
let mut state = bridge_state().lock().expect("bridge state lock poisoned");
let session = state.entry(session_id.clone()).or_default();
session.token = Some(token);
session.last_seen_millis = now_millis();
session.editor_type = payload
.editor_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
session.document_id = payload
.document_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
session.asset_id = payload
.asset_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
session.file_type = payload
.file_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
session.doc_key = payload
.doc_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
session.page_origin = payload
.page_origin
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
json_response(json!({
"ok": true,
"sessionId": session_id,
"editorType": session.editor_type.clone().unwrap_or_default(),
"documentId": session.document_id.clone().unwrap_or_default(),
"assetId": session.asset_id.clone().unwrap_or_default(),
"fileType": session.file_type.clone().unwrap_or_default(),
"docKey": session.doc_key.clone().unwrap_or_default(),
"pageOrigin": session.page_origin.clone().unwrap_or_default()
}))
}
pub async fn list_sessions() -> Response {
json_response(json!({
"ok": true,
"sessions": session_infos()
}))
}
pub async fn current_session() -> Response {
let current = session_infos().into_iter().next();
json_response(json!({
"ok": true,
"session": current
}))
}
pub async fn close_session(Json(payload): Json<BridgeCloseSessionPayload>) -> Response {
let Some(session_id) = non_empty(Some(payload.session_id)) else {
return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
};
if let Err(response) = ensure_session_token(&session_id, payload.token.as_deref()) {
return response;
}
let mut state = bridge_state().lock().expect("bridge state lock poisoned");
let closed = state.remove(&session_id).is_some();
json_response(json!({
"ok": true,
"sessionId": session_id,
"closed": closed
}))
}
pub async fn capabilities() -> Response {
json_response(capability_payload())
}
pub fn capability_payload() -> Value {
json!({
"ok": true,
"schema": "mnote.onlyoffice.bridge_capabilities.v1",
"guid": MNOTE_ONLYOFFICE_BRIDGE_GUID,
"actions": [
"selection.get",
"document.insert_text",
"document.replace_selection",
"document.insert_html",
"document.export",
"document.search_replace",
"document.insert_table",
"document.get_comments",
"document.add_comment",
"sheet.get_sheets",
"sheet.add_sheet",
"sheet.rename_sheet",
"sheet.get_range",
"sheet.get_range_values",
"sheet.get_values",
"sheet.set_value",
"sheet.set_formula",
"sheet.batch_set_values",
"sheet.set_range_values",
"sheet.format_range",
"sheet.set_dimensions",
"sheet.sort_range",
"sheet.add_chart",
"presentation.get_slides",
"presentation.get_slide_texts",
"presentation.get_shapes",
"presentation.add_text_slide",
"presentation.replace_text",
"presentation.set_shape_text",
"presentation.delete_slide",
"presentation.add_table",
"presentation.clear_slide",
"presentation.add_shape"
],
"editors": {
"word": ["selection.get", "document.insert_text", "document.replace_selection", "document.insert_html", "document.export", "document.search_replace", "document.insert_table", "document.get_comments", "document.add_comment"],
"cell": ["selection.get", "sheet.get_sheets", "sheet.add_sheet", "sheet.rename_sheet", "sheet.get_range", "sheet.get_range_values", "sheet.get_values", "sheet.set_value", "sheet.set_formula", "sheet.batch_set_values", "sheet.set_range_values", "sheet.format_range", "sheet.set_dimensions", "sheet.sort_range", "sheet.add_chart"],
"slide": ["selection.get", "presentation.get_slides", "presentation.get_slide_texts", "presentation.get_shapes", "presentation.add_text_slide", "presentation.replace_text", "presentation.set_shape_text", "presentation.delete_slide", "presentation.add_table", "presentation.clear_slide", "presentation.add_shape"]
}
})
}
pub async fn enqueue_command(Json(payload): Json<BridgeEnqueuePayload>) -> Response {
let Some(session_id) = non_empty(Some(payload.session_id)) else {
return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
};
if let Err(response) = ensure_session_token(&session_id, payload.token.as_deref()) {
return response;
}
let Some(action) = non_empty(Some(payload.action)) else {
return bad_request("onlyoffice_bridge_action_required", "缺少 action");
};
let command = push_command(&session_id, action, payload.payload);
json_response(json!({
"ok": true,
"sessionId": session_id,
"command": command
}))
}
pub async fn next_command(Query(query): Query<BridgeNextQuery>) -> Response {
let Some(session_id) = non_empty(Some(query.session_id)) else {
return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
};
if let Err(response) = ensure_session_token(&session_id, query.token.as_deref()) {
return response;
}
let timeout = query
.timeout_ms
.unwrap_or(MAX_LONG_POLL_MS)
.min(MAX_LONG_POLL_MS);
let deadline = Instant::now() + Duration::from_millis(timeout);
loop {
if let Some(command) = pop_command(&session_id) {
return json_response(json!(command));
}
if Instant::now() >= deadline {
return StatusCode::NO_CONTENT.into_response();
}
sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await;
}
}
pub async fn post_result(Json(payload): Json<Value>) -> Response {
let Some(session_id) = payload
.get("sessionId")
.and_then(Value::as_str)
.map(str::to_string)
.and_then(|value| non_empty(Some(value)))
else {
return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
};
if let Err(response) =
ensure_session_token(&session_id, payload.get("token").and_then(Value::as_str))
{
return response;
}
let Ok(result) = serde_json::from_value::<BridgeResultWire>(payload) else {
return bad_request("onlyoffice_bridge_result_invalid", "bridge result 格式无效");
};
let mut state = bridge_state().lock().expect("bridge state lock poisoned");
let session = state.entry(session_id.clone()).or_default();
session.results.insert(result.id.clone(), result.clone());
json_response(json!({
"ok": true,
"sessionId": session_id,
"result": result
}))
}
pub async fn get_result(Query(query): Query<BridgeResultQuery>) -> Response {
let Some(session_id) = non_empty(Some(query.session_id)) else {
return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
};
let Some(command_id) = non_empty(Some(query.command_id)) else {
return bad_request("onlyoffice_bridge_command_required", "缺少 commandId");
};
if let Err(response) = ensure_session_token(&session_id, query.token.as_deref()) {
return response;
}
let timeout = query
.timeout_ms
.unwrap_or(MAX_LONG_POLL_MS)
.min(MAX_LONG_POLL_MS);
let deadline = Instant::now() + Duration::from_millis(timeout);
loop {
if let Some(result) = take_result(&session_id, &command_id) {
return json_response(json!(result));
}
if Instant::now() >= deadline {
return StatusCode::NO_CONTENT.into_response();
}
sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await;
}
}
pub fn session_info(session_id: &str) -> Option<BridgeSessionInfo> {
let session_id = session_id.trim();
if session_id.is_empty() {
return None;
}
session_infos()
.into_iter()
.find(|session| session.session_id == session_id)
}
pub fn session_token_matches(session_id: &str, token: &str) -> bool {
let session_id = session_id.trim();
let token = token.trim();
if session_id.is_empty() || token.is_empty() {
return false;
}
let state = bridge_state().lock().expect("bridge state lock poisoned");
state
.get(session_id)
.and_then(|session| session.token.as_deref())
.is_some_and(|expected| expected == token)
}
pub fn session_infos() -> Vec<BridgeSessionInfo> {
let state = bridge_state().lock().expect("bridge state lock poisoned");
let mut sessions = state
.iter()
.map(|(session_id, session)| (session_id.clone(), session.last_seen_millis))
.collect::<Vec<_>>();
sessions.sort_by(|(_, left), (_, right)| right.cmp(left));
sessions
.into_iter()
.filter_map(|(session_id, _)| {
let session = state.get(&session_id)?;
Some(BridgeSessionInfo {
session_id: session_id.clone(),
editor_type: session.editor_type.clone(),
document_id: session.document_id.clone(),
asset_id: session.asset_id.clone(),
file_type: session.file_type.clone(),
doc_key: session.doc_key.clone(),
page_origin: session.page_origin.clone(),
last_seen_millis: session.last_seen_millis,
pending_commands: session.queue.len(),
pending_results: session.results.len(),
})
})
.collect()
}
pub async fn run_bridge_command(
session_id: &str,
action: &str,
payload: Value,
timeout_ms: u64,
) -> Result<BridgeResultWire, BridgeRunError> {
let session_id = session_id.trim();
if session_id.is_empty() {
return Err(BridgeRunError::BadRequest(
"缺少 ONLYOFFICE bridge sessionId".into(),
));
}
let action = action.trim();
if action.is_empty() {
return Err(BridgeRunError::BadRequest(
"缺少 ONLYOFFICE bridge action".into(),
));
}
let token = {
let state = bridge_state().lock().expect("bridge state lock poisoned");
state
.get(session_id)
.and_then(|session| session.token.clone())
.ok_or_else(|| {
BridgeRunError::BadRequest("ONLYOFFICE bridge session 未注册或缺少 token".into())
})?
};
let command = push_command(session_id, action.to_string(), payload);
let timeout = timeout_ms.min(MAX_LONG_POLL_MS);
let deadline = Instant::now() + Duration::from_millis(timeout);
loop {
if ensure_session_token(session_id, Some(&token)).is_err() {
return Err(BridgeRunError::BadRequest(
"ONLYOFFICE bridge session token 已失效".into(),
));
}
if let Some(result) = take_result(session_id, &command.id) {
return Ok(result);
}
if Instant::now() >= deadline {
return Err(BridgeRunError::Timeout {
session_id: session_id.to_string(),
command_id: command.id,
});
}
sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await;
}
}
#[derive(Debug, Clone)]
pub enum BridgeRunError {
BadRequest(String),
Timeout {
session_id: String,
command_id: String,
},
}
fn bridge_state() -> &'static Mutex<HashMap<String, BridgeSessionState>> {
BRIDGE_STATE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn push_command(session_id: &str, action: String, payload: Value) -> BridgeCommandWire {
let command = BridgeCommandWire {
id: format!(
"oo_cmd_{}_{}",
now_millis(),
BRIDGE_COMMAND_COUNTER.fetch_add(1, Ordering::Relaxed)
),
action,
payload,
};
let mut state = bridge_state().lock().expect("bridge state lock poisoned");
state
.entry(session_id.to_string())
.or_default()
.queue
.push_back(command.clone());
command
}
fn pop_command(session_id: &str) -> Option<BridgeCommandWire> {
let mut state = bridge_state().lock().expect("bridge state lock poisoned");
let session = state.entry(session_id.to_string()).or_default();
session.last_seen_millis = now_millis();
session.queue.pop_front()
}
fn take_result(session_id: &str, command_id: &str) -> Option<BridgeResultWire> {
let mut state = bridge_state().lock().expect("bridge state lock poisoned");
state
.get_mut(session_id)
.and_then(|session| session.results.remove(command_id))
}
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_api_base(value: Option<&str>) -> String {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http://127.0.0.1:3000")
.trim_end_matches('/')
.to_string()
}
fn encode_index_state(
session_id: &str,
api_base: &str,
token: &str,
document_id: Option<&str>,
asset_id: Option<&str>,
file_type: Option<&str>,
doc_key: Option<&str>,
page_origin: Option<&str>,
) -> String {
let payload = json!(BridgePluginIndexState {
session_id: session_id.to_string(),
api_base: api_base.to_string(),
token: token.to_string(),
document_id: document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
asset_id: asset_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
file_type: file_type
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
doc_key: doc_key
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
page_origin: page_origin
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
});
URL_SAFE_NO_PAD.encode(payload.to_string())
}
fn decode_index_state(encoded_state: &str) -> Option<BridgePluginIndexState> {
let bytes = URL_SAFE_NO_PAD.decode(encoded_state.as_bytes()).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn json_response(payload: Value) -> Response {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
);
(StatusCode::OK, headers, payload.to_string()).into_response()
}
fn unauthorized(code: &'static str, message: &'static str) -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({
"ok": false,
"code": code,
"message": message
})),
)
.into_response()
}
fn bad_request(code: &'static str, message: &'static str) -> Response {
(
StatusCode::BAD_REQUEST,
Json(json!({
"ok": false,
"code": code,
"message": message
})),
)
.into_response()
}
fn new_bridge_token() -> String {
Uuid::new_v4().to_string()
}
fn ensure_session_token(session_id: &str, token: Option<&str>) -> Result<(), Response> {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Err(unauthorized(
"onlyoffice_bridge_token_required",
"缺少 bridge token",
));
};
let state = bridge_state().lock().expect("bridge state lock poisoned");
match state
.get(session_id)
.and_then(|session| session.token.as_deref())
{
Some(expected) if expected == token => Ok(()),
Some(_) => Err(unauthorized(
"onlyoffice_bridge_token_invalid",
"bridge token 无效",
)),
None => Err(unauthorized(
"onlyoffice_bridge_session_unregistered",
"bridge session 未注册",
)),
}
}
fn json_string(input: &str) -> String {
serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into())
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bridge_plugin_config_exposes_dynamic_index_and_guid() {
let response = plugin_config(Query(BridgePluginConfigQuery {
session_id: Some("session-a".into()),
api_base: Some("http://127.0.0.1:3000/".into()),
token: Some("token-a".into()),
document_id: Some("doc-a".into()),
asset_id: Some("asset-a".into()),
file_type: Some("xlsx".into()),
doc_key: Some("doc-key-a".into()),
page_origin: Some("http://127.0.0.1:3000".into()),
}))
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["guid"], MNOTE_ONLYOFFICE_BRIDGE_GUID);
assert_eq!(payload["version"], "0.1.0");
let url = payload["variations"][0]["url"].as_str().unwrap_or_default();
assert!(url.contains("/api/onlyoffice/bridge/plugin/index"));
assert!(!url.contains('?'));
let encoded_state = url.rsplit('/').next().unwrap_or_default();
let state = decode_index_state(encoded_state).expect("encoded state");
assert_eq!(state.session_id, "session-a");
assert_eq!(state.api_base, "http://127.0.0.1:3000");
assert_eq!(state.token, "token-a");
assert_eq!(state.document_id.as_deref(), Some("doc-a"));
assert_eq!(state.asset_id.as_deref(), Some("asset-a"));
assert_eq!(state.file_type.as_deref(), Some("xlsx"));
assert_eq!(state.doc_key.as_deref(), Some("doc-key-a"));
assert_eq!(state.page_origin.as_deref(), Some("http://127.0.0.1:3000"));
}
#[tokio::test]
async fn bridge_plugin_index_accepts_path_state_with_onlyoffice_query_params() {
let encoded_state = encode_index_state(
"session-b",
"http://127.0.0.1:3000",
"token-b",
Some("doc-b"),
Some("asset-b"),
Some("pptx"),
Some("doc-key-b"),
Some("http://127.0.0.1:3001"),
);
let response = plugin_index_with_state(
Path(encoded_state),
Query(BridgePluginIndexQuery {
session_id: None,
api_base: None,
token: None,
document_id: None,
asset_id: None,
file_type: None,
doc_key: None,
page_origin: None,
}),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains(r#"var sessionId = "session-b";"#));
assert!(html.contains(r#"var apiBase = "http://127.0.0.1:3000";"#));
assert!(html.contains(r#"documentId: "doc-b""#));
assert!(html.contains(r#"assetId: "asset-b""#));
assert!(html.contains(r#"fileType: "pptx""#));
assert!(html.contains(r#"docKey: "doc-key-b""#));
assert!(html.contains(r#"pageOrigin: "http://127.0.0.1:3001""#));
}
#[tokio::test]
async fn bridge_plugin_index_exposes_second_batch_recipe_actions() {
let response = plugin_index(Query(BridgePluginIndexQuery {
session_id: Some("session-recipes".into()),
api_base: Some("http://127.0.0.1:3000".into()),
token: Some("token-recipes".into()),
document_id: None,
asset_id: None,
file_type: None,
doc_key: None,
page_origin: None,
}))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains(r#"case "document.search_replace":"#));
assert!(html.contains(r#"case "document.insert_table":"#));
assert!(html.contains(r#"case "document.get_comments":"#));
assert!(html.contains(r#"case "document.add_comment":"#));
assert!(html.contains(r#"case "sheet.get_sheets":"#));
assert!(html.contains(r#"case "sheet.add_sheet":"#));
assert!(html.contains(r#"case "sheet.rename_sheet":"#));
assert!(html.contains(r#"case "sheet.get_range_values":"#));
assert!(html.contains(r#"case "sheet.set_range_values":"#));
assert!(html.contains(r#"case "sheet.format_range":"#));
assert!(html.contains(r#"case "sheet.set_dimensions":"#));
assert!(html.contains(r#"case "sheet.sort_range":"#));
assert!(html.contains(r#"case "sheet.add_chart":"#));
assert!(html.contains("range.SetSort"));
assert!(html.contains("worksheet.AddChart"));
assert!(html.contains(r#"case "presentation.get_slide_texts":"#));
assert!(html.contains(r#"case "presentation.get_shapes":"#));
assert!(html.contains(r#"case "presentation.replace_text":"#));
assert!(html.contains(r#"case "presentation.set_shape_text":"#));
assert!(html.contains(r#"case "presentation.delete_slide":"#));
assert!(html.contains(r#"case "presentation.add_table":"#));
assert!(html.contains(r#"case "presentation.clear_slide":"#));
assert!(html.contains(r#"case "presentation.add_shape":"#));
assert!(html.contains("slide.AddObject(table)"));
assert!(html.contains("slide.RemoveAllObjects()"));
assert!(html.contains("Api.CreateShape"));
assert!(html.contains("function expectedEditorTypeForAction(action)"));
assert!(html.contains(r#"if (name.indexOf("document.") === 0) return "word";"#));
assert!(html.contains(r#"if (name.indexOf("sheet.") === 0) return "cell";"#));
assert!(html.contains(r#"if (name.indexOf("presentation.") === 0) return "slide";"#));
assert!(html.contains("onlyoffice_editor_type_mismatch"));
}
#[tokio::test]
async fn bridge_session_registration_lists_metadata_and_capabilities() {
let session_id = format!("test-session-meta-{}", now_millis());
let token = format!("token-{session_id}");
let registered = register_session(Json(BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token),
editor_type: Some("slide".into()),
document_id: Some("doc-meta".into()),
asset_id: Some("asset-meta".into()),
file_type: Some("pptx".into()),
doc_key: Some("doc-key-meta".into()),
page_origin: Some("http://127.0.0.1:3000".into()),
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let listed = list_sessions().await;
assert_eq!(listed.status(), StatusCode::OK);
let body = axum::body::to_bytes(listed.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let session = payload["sessions"]
.as_array()
.expect("sessions")
.iter()
.find(|value| value["sessionId"] == session_id)
.expect("registered session");
assert_eq!(session["editorType"], "slide");
assert_eq!(session["documentId"], "doc-meta");
assert_eq!(session["assetId"], "asset-meta");
assert_eq!(session["fileType"], "pptx");
assert_eq!(session["docKey"], "doc-key-meta");
assert_eq!(session["pageOrigin"], "http://127.0.0.1:3000");
let current = current_session().await;
assert_eq!(current.status(), StatusCode::OK);
let body = axum::body::to_bytes(current.into_body(), usize::MAX)
.await
.expect("current body");
let payload: Value = serde_json::from_slice(&body).expect("current json");
assert!(payload["session"].is_object() || payload["session"].is_null());
let closed = close_session(Json(BridgeCloseSessionPayload {
session_id: session_id.clone(),
token: Some(format!("token-{session_id}")),
}))
.await;
assert_eq!(closed.status(), StatusCode::OK);
assert!(session_info(&session_id).is_none());
let capabilities = capabilities().await;
let body = axum::body::to_bytes(capabilities.into_body(), usize::MAX)
.await
.expect("capabilities body");
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "presentation.set_shape_text"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "sheet.format_range"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "presentation.delete_slide"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "document.insert_table"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "document.get_comments"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "document.add_comment"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "sheet.sort_range"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "sheet.add_chart"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "presentation.add_table"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "presentation.clear_slide"));
assert!(payload["actions"]
.as_array()
.expect("actions")
.iter()
.any(|action| action == "presentation.add_shape"));
}
#[tokio::test]
async fn bridge_queue_round_trips_command_and_result() {
let session_id = format!("test-session-{}", now_millis());
let token = format!("token-{session_id}");
let registered = register_session(Json(BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: None,
asset_id: None,
file_type: None,
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let denied = enqueue_command(Json(BridgeEnqueuePayload {
session_id: session_id.clone(),
token: None,
action: "selection.get".into(),
payload: json!({"sample": true}),
}))
.await;
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
let enqueue = enqueue_command(Json(BridgeEnqueuePayload {
session_id: session_id.clone(),
token: Some(token.clone()),
action: "selection.get".into(),
payload: json!({"sample": true}),
}))
.await;
assert_eq!(enqueue.status(), StatusCode::OK);
let body = axum::body::to_bytes(enqueue.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let command_id = payload["command"]["id"].as_str().unwrap().to_string();
let next = next_command(Query(BridgeNextQuery {
session_id: session_id.clone(),
token: Some(token.clone()),
timeout_ms: Some(1),
}))
.await;
assert_eq!(next.status(), StatusCode::OK);
let body = axum::body::to_bytes(next.into_body(), usize::MAX)
.await
.expect("body");
let command: BridgeCommandWire = serde_json::from_slice(&body).expect("command");
assert_eq!(command.id, command_id);
assert_eq!(command.action, "selection.get");
let posted = post_result(Json(json!({
"sessionId": session_id,
"token": token,
"id": command_id,
"ok": true,
"result": {"text": "hello"}
})))
.await;
assert_eq!(posted.status(), StatusCode::OK);
}
}