- 归档 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 .
2227 lines
89 KiB
Rust
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);
|
|
}
|
|
}
|