fix local office resource editing

- add local-folder OnlyOffice sign/callback writeback and edit-tab handling

- align main resource tabs, attachment edit menu, slash isolation, and filetree context behavior

- record Sidex/Hermes gap reviews and Reasonix task checklists
This commit is contained in:
lix-2026
2026-05-21 05:40:06 +08:00
parent a29d9868f6
commit eba1010191
50 changed files with 4309 additions and 283 deletions
+11 -11
View File
@@ -2367,10 +2367,10 @@ mod tests {
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("初始化的新页面"));
assert!(!html.contains("初始化的新页面"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
let _ = std::fs::remove_dir_all(&base);
}
@@ -2583,17 +2583,17 @@ mod tests {
.join("user_real")
.join("workspaces")
.join("my-space");
assert!(expected_root
.join("初始化的新页面")
.join("初始化的新页面.md")
.exists());
assert!(html.contains("初始化的新页面"));
assert!(
!expected_root.join("初始化的新页面").exists(),
"默认工作区不应创建'初始化的新页面'目录"
);
assert!(!html.contains("初始化的新页面"));
assert!(html.contains("local_folder"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(html.contains("当前还没有可显示的本地工作区"));
assert!(html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
let _ = std::fs::remove_dir_all(&base);
@@ -1461,13 +1461,22 @@ async fn acp_stream_events(
}
});
let acp_session_id = mgr.create_session(None, None).await.map_err(|e| {
WebError::bad_gateway_code(
"acp_session_create_failed",
format!("ACP session creation failed: {e}"),
)
.with_context(&context)
})?;
// Try to resume stored ACP session, or create a new one.
// Reference: hermes-vscode-main sessionManager.ts ensureSession()
let stored_acp_session_id = payload
.get("acpSessionId")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty());
let acp_session_id = mgr
.ensure_session(None, stored_acp_session_id)
.await
.map_err(|e| {
WebError::bad_gateway_code(
"acp_session_ensure_failed",
format!("ACP session ensure failed: {e}"),
)
.with_context(&context)
})?;
let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string());
ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert(
run_id.to_string(),
@@ -1810,6 +1819,82 @@ pub async fn abort_run(
}
}
/// Resolve a pending permission request from an ACP agent.
///
/// POST /api/hermes/client/runs/{run_id}/resolve-permission
/// Body: { "permissionId": "...", "decision": "allow"|"deny" }
///
/// Looks up the active run, forwards the decision to the ACP subprocess,
/// and emits `permission.allowed` / `permission.denied` SSE event.
pub async fn resolve_permission(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let permission_id = payload
.get("permissionId")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
WebError::bad_request_code("hermes_client_bad_request", "缺少 permissionId")
.with_context(&context)
})?;
let decision = payload
.get("decision")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
WebError::bad_request_code("hermes_client_bad_request", "缺少 decision (allow/deny)")
.with_context(&context)
})?;
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
if acp_runtime_for_run(&run_id, &profile).is_none() {
return Err(WebError::bad_request_code(
"hermes_client_not_acp_run",
format!("run_id={run_id} 不是 ACP run"),
)
.with_context(&context));
}
let active = ACP_ACTIVE_RUNS
.lock()
.expect("acp active runs")
.get(&run_id)
.cloned();
let Some(active) = active else {
return Err(WebError::bad_request_code(
"hermes_client_no_active_run",
format!("run_id={run_id} 没有活跃 ACP run"),
)
.with_context(&context));
};
active
.manager
.resolve_permission(permission_id, decision)
.await
.map_err(|e| {
WebError::bad_request_code(
"hermes_client_permission_resolve_failed",
format!("resolve permission 失败: {e}"),
)
.with_context(&context)
})?;
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"runId": run_id,
"permissionId": permission_id,
"decision": decision,
})),
))
}
pub async fn list_models(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -1554,7 +1554,6 @@ fn create_default_local_workspace_for_actor_at_base(
let manifest = ensure_default_workspace_manifest(actor_id, &canonical_root)?;
ensure_default_workspace_directories(&canonical_root)?;
ensure_default_workspace_home_page(&canonical_root)?;
Ok(json!({
"ok": true,
"workspace": {
@@ -1782,36 +1781,6 @@ fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> {
Ok(())
}
fn ensure_default_workspace_home_page(root: &Path) -> Result<(), WebError> {
let page_dir = root.join("初始化的新页面");
let page = page_dir.join("初始化的新页面.md");
if page.exists() {
return Ok(());
}
if !page.starts_with(root) {
return Err(WebError::bad_request_code(
"local_workspace_root_escape",
"默认本地工作区首页不能越过 root",
));
}
fs::create_dir_all(&page_dir).map_err(|error| {
WebError::bad_request_code(
"local_workspace_create_failed",
format!(
"无法创建默认本地工作区首页目录 {}: {error}",
page_dir.display()
),
)
})?;
let content = "";
fs::write(&page, content).map_err(|error| {
WebError::bad_request_code(
"local_workspace_create_failed",
format!("无法创建默认本地工作区首页 {}: {error}", page.display()),
)
})
}
fn local_workspace_manifest_path(root: &Path) -> PathBuf {
root.join(".mnote").join("workspace.json")
}
@@ -3781,6 +3750,12 @@ fn move_local_directory(
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
if let Some(directory) = resolve_local_directory_id(root, entry_id)? {
// 若目录是页面 bundle(包含同名 .md),按 Markdown 页面生命周期入 trash
if let Some(main_md) = nested_bundle_main_markdown(&directory) {
let relative = normalize_relative_path(root, &main_md)?;
let page_id = local_markdown_path_page_id(&relative);
return trash_local_markdown_page(root, &page_id);
}
return trash_local_directory(root, entry_id, &directory);
}
if let Some(file) = resolve_local_raw_file_id(root, entry_id)? {
@@ -6961,13 +6936,13 @@ mod tests {
execute_local_tree_command, execute_local_tree_command_with_sort, get_local_access_policy,
get_share_grants, initialize_local_page_id, initialize_local_workspace_for_actor,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_resource_write_editor_blocks, local_workspace_id,
open_local_file, record_shared_cache, record_sync_pending_change,
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report,
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
local_folder_watch_revision, local_markdown_path_page_id,
local_resource_write_editor_blocks, local_workspace_id, open_local_file,
record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate,
save_local_markdown_page, update_local_markdown_title, validate_local_access_root,
write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data,
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
};
use crate::context::RequestContext;
@@ -8216,10 +8191,10 @@ fn main() {}
assert_eq!(std::path::Path::new(root_path), expected_root.as_path());
assert!(expected_root.join(".mnote").join("workspace.json").exists());
assert!(expected_root
.join("初始化的新页面")
.join("初始化的新页面.md")
.exists());
assert!(
!expected_root.join("初始化的新页面").exists(),
"默认工作区不应创建'初始化的新页面'目录"
);
assert!(!expected_root.join("pages").exists());
assert!(!expected_root.join("assets").exists());
assert!(!expected_root.join("mindmaps").exists());
@@ -8516,6 +8491,39 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_delete_page_bundle_directory_uses_page_trash() {
let root = temp_root("mnote-local-delete-page-bundle-dir");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page bundle");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write markdown");
std::fs::write(root.join("Page").join("asset.txt"), "asset").expect("write asset");
let root_uri = format!("file://{}", root.display());
let folder_id = format!("local-dir:{}", encode_local_id_segment("Page"));
let page_id = local_markdown_path_page_id("Page/Page.md");
let result = execute_local_tree_command(&root_uri, "delete", &folder_id, None, None)
.expect("delete page bundle");
assert!(
!root.join("Page").exists(),
"页面 bundle 目录应整体移入垃圾箱"
);
assert_eq!(result["id"].as_str(), Some(page_id.as_str()));
assert_eq!(result["documentId"].as_str(), Some(page_id.as_str()));
assert_eq!(result["resourceKind"].as_str(), Some("markdown_bundle"));
assert_ne!(
result["resourceKind"].as_str(),
Some("local_directory"),
"页面 bundle 目录不能作为普通资源目录进入垃圾箱"
);
let trash_path = result["trashPath"].as_str().expect("trashPath");
assert!(root.join(trash_path).join("Page.md").is_file());
assert!(root.join(trash_path).join("asset.txt").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_delete_local_file_id_uses_trash_index() {
let root = temp_root("mnote-local-delete-local-file-id");
+4
View File
@@ -311,6 +311,10 @@ pub fn build_router(state: AppState) -> Router {
"/client/runs/{run_id}/abort",
post(hermes_client::abort_run),
)
.route(
"/client/runs/{run_id}/resolve-permission",
post(hermes_client::resolve_permission),
)
.route("/client/models", get(hermes_client::list_models))
.route("/client/tools", get(hermes_client::list_tools)),
)
+565 -88
View File
@@ -1,19 +1,30 @@
use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
use adapter_onlyoffice::{
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
OnlyOfficeProxyPreparationInput,
};
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use hyper::upgrade::Upgraded;
use hyper_util::rt::TokioIo;
use serde::Deserialize;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::time::Duration;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
use tokio_tungstenite::tungstenite::protocol::Role;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::WebSocketStream;
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
@@ -136,6 +147,9 @@ pub struct OnlyOfficeCallbackQuery {
asset_id: Option<String>,
#[serde(rename = "userId")]
user_id: Option<String>,
#[serde(rename = "rootUri")]
root_uri: Option<String>,
path: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -429,6 +443,13 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
}}
return String(Math.abs(hash));
}}
function safeOnlyOfficeDocKey(input) {{
const raw = String(input || "").trim();
if (!raw) return "mnote_" + hashOnlyOfficeKey(initial.fileName || "document");
const safe = raw.replace(/[^A-Za-z0-9_.=-]/g, "_").replace(/_+/g, "_");
if (safe && safe === raw && safe.length <= 96) return safe;
return "mnote_" + hashOnlyOfficeKey(raw) + "_" + hashOnlyOfficeKey(initial.fileName || "");
}}
function docTypeFromExt(ext) {{
const value = String(ext || "").toLowerCase();
if (["ppt", "pptx", "odp"].includes(value)) return "slide";
@@ -465,19 +486,44 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
return value;
}}
}}
function buildCallbackUrl(assetId, userId) {{
function localFolderOpenParams(raw) {{
try {{
const url = new URL(String(raw || ""), location.origin);
if (url.pathname !== "/api/local-folder/files/open") return null;
const rootUri = String(url.searchParams.get("rootUri") || "").trim();
const path = String(url.searchParams.get("path") || "").trim();
if (!rootUri || !path) return null;
return {{ rootUri, path }};
}} catch {{
return null;
}}
}}
function buildCallbackUrl(assetId, userId, localFile) {{
const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
if (assetId) callback.searchParams.set("assetId", assetId);
if (userId) callback.searchParams.set("userId", userId);
if (localFile && localFile.rootUri && localFile.path) {{
callback.searchParams.set("rootUri", localFile.rootUri);
callback.searchParams.set("path", localFile.path);
}}
return callback.toString();
}}
function loadScript(url) {{
function loadScript(url, timeoutMs) {{
return new Promise((resolve, reject) => {{
const script = document.createElement("script");
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error("无法加载 ONLYOFFICE API: " + url));
const cleanup = () => {{ script.onload = null; script.onerror = null; }};
script.onload = () => {{ cleanup(); resolve(); }};
script.onerror = () => {{ cleanup(); reject(new Error("无法加载 ONLYOFFICE API: " + url)); }};
document.head.appendChild(script);
if (timeoutMs > 0) {{
const timer = window.setTimeout(() => {{
cleanup();
reject(new Error("ONLYOFFICE API 加载超时 (" + (timeoutMs / 1000) + "秒)"));
}}, timeoutMs);
resolve = ((orig) => (value) => {{ window.clearTimeout(timer); return orig(value); }})(resolve);
reject = ((orig) => (reason) => {{ window.clearTimeout(timer); return orig(reason); }})(reject);
}}
}});
}}
const MNOTE_ONLYOFFICE_FRAME_REV = "mnote-proxy-identity-20260511";
@@ -539,10 +585,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
return "";
}}
}}
function isLocalFolderAsset() {{
if (initial.assetId && (initial.assetId.indexOf("local:") === 0 || initial.assetId.indexOf("local-file:") === 0)) return true;
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
return false;
}}
async function resolveAssetUrlAndKey() {{
let effectiveFileUrl = initial.fileUrl;
let storageId = "";
if (initial.assetId) {{
if (initial.assetId && !isLocalFolderAsset()) {{
try {{
const response = await fetch("/api/media/sign?assetId=" + encodeURIComponent(initial.assetId));
const payload = await response.json().catch(() => null);
@@ -554,91 +605,115 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
// ignore
}}
}}
const docKey = initial.assetId
const rawDocKey = initial.assetId
? (storageId ? initial.assetId + "_" + hashOnlyOfficeKey(storageId) : initial.assetId)
: hashOnlyOfficeKey(String(effectiveFileUrl || "") + "-" + initial.fileName);
const docKey = safeOnlyOfficeDocKey(rawDocKey);
return {{ effectiveFileUrl, storageId, docKey, resolvedFileUrl: resolveDocumentUrl(effectiveFileUrl) }};
}}
async function boot() {{
const userId = await resolveWhoami();
const fileState = await resolveAssetUrlAndKey();
if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数");
await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js");
await waitForDocEditorReady(120000);
const bootTimeoutMs = 60000;
const bootDeadline = Date.now() + bootTimeoutMs;
const bootHeartbeat = window.setInterval(() => {{
if (Date.now() > bootDeadline) {{
window.clearInterval(bootHeartbeat);
showError("ONLYOFFICE 页面初始化超时(" + (bootTimeoutMs / 1000) + "秒),请检查 DocumentServer 是否正常运行。");
}}
}}, 5000);
try {{
const userId = await resolveWhoami();
const fileState = await resolveAssetUrlAndKey();
if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数");
await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js", 15000);
await waitForDocEditorReady(60000);
const resolvedMode = initial.mode === "view" ? "view" : "edit";
const config = {{
width: "100%",
height: "100%",
documentType: docTypeFromExt(initial.fileType),
document: {{
const resolvedMode = initial.mode === "view" ? "view" : "edit";
const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl);
const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user";
const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId;
const config = {{
width: "100%",
height: "100%",
documentType: docTypeFromExt(initial.fileType),
document: {{
fileType: initial.fileType,
title: initial.fileName,
url: fileState.resolvedFileUrl,
key: fileState.docKey,
permissions: {{
edit: resolvedMode !== "view",
download: true,
print: true,
copy: true
}}
}},
editorConfig: {{
mode: resolvedMode,
lang: "zh-CN",
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
user: {{
id: displayUserId,
name: displayUserName
}},
customization: {{
feedback: {{ visible: false }},
anonymous: {{ request: false, label: "Guest" }},
features: {{ featuresTips: false }},
forcesave: resolvedMode !== "view"
}}
}},
events: {{
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onError: (event) => showError(JSON.stringify(event))
}}
}};
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
pageOrigin: location.origin,
baseUrl: "/onlyoffice-server",
documentUrlBase,
proxyOrigin,
callbackOrigin,
fileUrlInput: fileState.effectiveFileUrl,
resolvedFileUrl: fileState.resolvedFileUrl,
fileName: initial.fileName,
fileType: initial.fileType,
title: initial.fileName,
url: fileState.resolvedFileUrl,
key: fileState.docKey,
permissions: {{
edit: resolvedMode !== "view",
download: true,
print: true,
copy: true
mode: initial.mode,
resolvedMode,
assetId: initial.assetId,
documentId: initial.documentId,
docKey: fileState.docKey
}};
const readyDeadline = Date.now() + 120000;
const timer = window.setInterval(() => {{
const root = document.getElementById("onlyoffice-frame");
const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length;
if (count > 0) {{
window.__MNOTE_ONLYOFFICE_READY__ = true;
window.clearInterval(timer);
}} else if (Date.now() > readyDeadline) {{
window.clearInterval(timer);
}}
}},
editorConfig: {{
mode: resolvedMode,
lang: "zh-CN",
callbackUrl: buildCallbackUrl(initial.assetId, userId),
customization: {{
feedback: {{ visible: false }},
forcesave: resolvedMode !== "view"
}}
}},
events: {{
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onError: (event) => showError(JSON.stringify(event))
}}
}};
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
pageOrigin: location.origin,
baseUrl: "/onlyoffice-server",
documentUrlBase,
proxyOrigin,
callbackOrigin,
fileUrlInput: fileState.effectiveFileUrl,
resolvedFileUrl: fileState.resolvedFileUrl,
fileName: initial.fileName,
fileType: initial.fileType,
mode: initial.mode,
resolvedMode,
assetId: initial.assetId,
documentId: initial.documentId,
docKey: fileState.docKey
}};
const readyDeadline = Date.now() + 120000;
const timer = window.setInterval(() => {{
const root = document.getElementById("onlyoffice-frame");
const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length;
if (count > 0) {{
window.__MNOTE_ONLYOFFICE_READY__ = true;
window.clearInterval(timer);
}} else if (Date.now() > readyDeadline) {{
window.clearInterval(timer);
}}
}}, 500);
const signResponse = await fetch("/api/onlyoffice/sign", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify({{ config }})
}});
const signPayload = await signResponse.json().catch(() => null);
if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败");
if (signPayload.token) config.token = signPayload.token;
if (signPayload.documentToken) config.document.token = signPayload.documentToken;
if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken;
installOnlyOfficeFrameSrcPatch();
window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config);
}}, 500);
const signResponse = await fetch("/api/onlyoffice/sign", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify({{ config }})
}});
const signPayload = await signResponse.json().catch(() => null);
if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败");
if (signPayload.token) config.token = signPayload.token;
if (signPayload.documentToken) config.document.token = signPayload.documentToken;
if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken;
installOnlyOfficeFrameSrcPatch();
window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config);
}} finally {{
window.clearInterval(bootHeartbeat);
}}
}}
boot().catch((error) => showError(error && error.message ? error.message : error));
boot().catch((error) => {{
showError(error && error.message ? error.message : error);
}});
</script>
</body>
</html>"#,
@@ -972,6 +1047,131 @@ fn proxy_local_folder_file_open(
Ok(Some(response))
}
fn is_onlyoffice_local_asset_id(asset_id: &str) -> bool {
let value = asset_id.trim();
value.starts_with("local:") || value.starts_with("local-file:")
}
fn onlyoffice_callback_success(extra: Value) -> Response {
let mut payload = json!({ "error": 0 });
if let (Some(object), Some(extra_object)) = (payload.as_object_mut(), extra.as_object()) {
for (key, value) in extra_object {
object.insert(key.clone(), value.clone());
}
}
Json(payload).into_response()
}
fn onlyoffice_callback_failure(error: WebError) -> Response {
Json(json!({
"error": 1,
"code": error.code(),
"message": error.message(),
}))
.into_response()
}
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|error| {
WebError::internal(format!("OnlyOffice callback HTTP 客户端创建失败: {error}"))
})?;
let response = client.get(download_url).send().await.map_err(|error| {
WebError::bad_gateway_code(
"onlyoffice_local_callback_download_failed",
format!("OnlyOffice 保存文件下载失败: {error}"),
)
})?;
let status = response.status();
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"onlyoffice_local_callback_download_failed",
format!("OnlyOffice 保存文件下载失败: HTTP {status}"),
));
}
response.bytes().await.map_err(|error| {
WebError::bad_gateway_code(
"onlyoffice_local_callback_body_failed",
format!("OnlyOffice 保存文件读取失败: {error}"),
)
})
}
async fn local_folder_onlyoffice_callback(
query: &OnlyOfficeCallbackQuery,
body: &Value,
status: i64,
) -> Result<Response, WebError> {
let asset_id = query.asset_id.as_deref().unwrap_or("").trim();
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_local_callback_root_missing",
"OnlyOffice 本地保存缺少 rootUri",
)
})?;
let relative_path = query
.path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_local_callback_path_missing",
"OnlyOffice 本地保存缺少 path",
)
})?;
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
asset_id: asset_id.to_string(),
document_id: None,
workspace_id: None,
user_id: query.user_id.clone(),
session_id: None,
status,
url: body
.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
key: body
.get("key")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
onlyoffice_internal_url,
})
.map_err(|error| WebError::bad_request_code("onlyoffice_local_callback_invalid", error))?;
if !prepared.should_write {
return Ok(onlyoffice_callback_success(json!({
"localWrite": false,
"status": status,
})));
}
let download_url = prepared.download_url.as_deref().ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_local_callback_url_missing",
"OnlyOffice 本地保存缺少下载地址",
)
})?;
let bytes = download_onlyoffice_callback_body(download_url).await?;
fs::write(&target, &bytes).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})?;
Ok(onlyoffice_callback_success(json!({
"localWrite": true,
"bytes": bytes.len(),
})))
}
pub async fn callback(
State(state): State<AppState>,
uri: Uri,
@@ -988,6 +1188,19 @@ pub async fn callback(
status,
"OnlyOffice callback received by mnote-web"
);
let is_local_callback = query
.asset_id
.as_deref()
.map(is_onlyoffice_local_asset_id)
.unwrap_or(false)
|| query.root_uri.as_deref().is_some()
|| query.path.as_deref().is_some();
if is_local_callback {
return match local_folder_onlyoffice_callback(&query, &body, status).await {
Ok(response) => response,
Err(error) => onlyoffice_callback_failure(error),
};
}
match proxy_legacy_onlyoffice_json(
state.config(),
"/api/onlyoffice/callback",
@@ -1308,14 +1521,101 @@ async fn proxy_onlyoffice_path(
Ok(response)
}
fn onlyoffice_websocket_url(base: &str, upstream_path: &str, query: Option<&str>) -> String {
let mut target = append_path_and_query(base, upstream_path, query);
if let Some(rest) = target.strip_prefix("http://") {
target = format!("ws://{rest}");
} else if let Some(rest) = target.strip_prefix("https://") {
target = format!("wss://{rest}");
}
target
}
async fn bridge_onlyoffice_websocket(upgraded: Upgraded, target: String) {
let client_io = TokioIo::new(upgraded);
let mut client_socket = WebSocketStream::from_raw_socket(client_io, Role::Server, None).await;
let Ok((mut upstream_socket, _response)) = connect_async(&target).await else {
let _ = client_socket.close(None).await;
return;
};
loop {
tokio::select! {
client_message = client_socket.next() => {
let Some(Ok(message)) = client_message else {
let _ = upstream_socket.send(TungsteniteMessage::Close(None)).await;
break;
};
let is_close = matches!(message, TungsteniteMessage::Close(_));
if upstream_socket.send(message).await.is_err() {
break;
}
if is_close {
break;
}
}
upstream_message = upstream_socket.next() => {
let Some(Ok(message)) = upstream_message else {
let _ = client_socket.send(TungsteniteMessage::Close(None)).await;
break;
};
let is_close = matches!(message, TungsteniteMessage::Close(_));
if client_socket.send(message).await.is_err() {
break;
}
if is_close {
break;
}
}
}
}
}
pub async fn server_proxy(
State(_state): State<AppState>,
Path(path): Path<String>,
uri: Uri,
method: Method,
headers: HeaderMap,
request: Request<Body>,
mut request: Request<Body>,
) -> Result<Response, WebError> {
let upgrade = headers
.get(header::UPGRADE)
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("websocket"))
.unwrap_or(false);
if upgrade {
let key = headers
.get(header::SEC_WEBSOCKET_KEY)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_websocket_key_missing",
"缺少 Sec-WebSocket-Key",
)
})?;
let target =
onlyoffice_websocket_url(&resolve_onlyoffice_internal_url().await, &path, uri.query());
let upgraded = hyper::upgrade::on(&mut request);
tokio::spawn(async move {
if let Ok(upgraded) = upgraded.await {
bridge_onlyoffice_websocket(upgraded, target).await;
}
});
let response = Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.header(header::CONNECTION, "Upgrade")
.header(header::UPGRADE, "websocket")
.header(
header::SEC_WEBSOCKET_ACCEPT,
derive_accept_key(key.as_bytes()),
)
.body(Body::empty())
.map_err(|error| {
WebError::internal(format!("构造 OnlyOffice WebSocket 响应失败: {error}"))
})?;
return Ok(response);
}
proxy_onlyoffice_path("", &path, uri, method, headers, request).await
}
@@ -1332,13 +1632,43 @@ pub async fn cache_proxy(
#[cfg(test)]
pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String {
if !asset_id.trim().is_empty() {
let raw = if !asset_id.trim().is_empty() {
if storage_id.trim().is_empty() {
return asset_id.trim().to_string();
asset_id.trim().to_string()
} else {
format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id))
}
return format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id));
} else {
js_hash_abs(&format!("{file_url}-{file_name}"))
};
safe_onlyoffice_doc_key(&raw, file_name)
}
#[cfg(test)]
fn safe_onlyoffice_doc_key(input: &str, file_name: &str) -> String {
let raw = input.trim();
if raw.is_empty() {
return format!("mnote_{}", js_hash_abs(file_name));
}
js_hash_abs(&format!("{file_url}-{file_name}"))
let mut safe = String::new();
let mut previous_underscore = false;
for ch in raw.chars() {
let allowed = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-');
let next = if allowed { ch } else { '_' };
if next == '_' {
if previous_underscore {
continue;
}
previous_underscore = true;
} else {
previous_underscore = false;
}
safe.push(next);
}
if !safe.is_empty() && safe == raw && safe.len() <= 96 {
return safe;
}
format!("mnote_{}_{}", js_hash_abs(raw), js_hash_abs(file_name))
}
#[cfg(test)]
@@ -1374,6 +1704,24 @@ mod tests {
assert!(key.starts_with("asset_1_"));
}
#[test]
fn stable_doc_key_hashes_local_unicode_asset_ids() {
let key = stable_doc_key(
"local:asset:Alpha/重庆发展特殊化妆品可行性报告_政府汇报版.docx",
"",
"",
"重庆发展特殊化妆品可行性报告_政府汇报版.docx",
);
assert!(key.len() <= 128);
assert!(key.starts_with("mnote_"));
assert!(key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
assert!(!key.contains('/'));
assert!(!key.contains(':'));
assert!(!key.contains('重'));
}
#[tokio::test]
async fn onlyoffice_object_shell_exposes_resource_identity() {
let response = object_shell(
@@ -1533,6 +1881,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
root_uri: None,
path: None,
}),
Json(json!({
"status": 2,
@@ -1550,6 +1900,85 @@ mod tests {
assert_eq!(payload["degraded"], true);
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
root.display()
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": download_url
})),
)
.await;
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["error"], 0);
assert_eq!(fs::read(&target).expect("read target"), b"new docx bytes");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_ignores_non_write_status() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-ignore-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
root.display()
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({ "status": 1 })),
)
.await;
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["error"], 0);
assert_eq!(fs::read(&target).expect("read target"), b"old");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
@@ -1561,6 +1990,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
root_uri: None,
path: None,
}),
Json(json!({
"status": 2,
@@ -1635,4 +2066,50 @@ mod tests {
assert!(request
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
}
#[tokio::test]
async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() {
let response = page(Query(OnlyOfficePageQuery {
file_url: Some(
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
.into(),
),
file_name: Some("report.docx".into()),
file_type: Some("docx".into()),
asset_id: Some("local-file:Page/report.docx".into()),
document_id: Some("local-md:Page".into()),
user_id: None,
mode: Some("view".into()),
}))
.await
.expect("onlyoffice page");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
// 1) Guard function is present
assert!(html.contains("function isLocalFolderAsset()"));
// 2) Condition uses guard to skip /api/media/sign
assert!(html.contains("if (initial.assetId && !isLocalFolderAsset())"));
// 3) local: prefix detection
assert!(html.contains("initial.assetId.indexOf(\"local:\") === 0"));
// 4) local-file: prefix detection
assert!(html.contains("initial.assetId.indexOf(\"local-file:\") === 0"));
// 5) fileUrl path detection for /api/local-folder/files/open
assert!(html.contains(
"initial.fileUrl && initial.fileUrl.indexOf(\"/api/local-folder/files/open\") !== -1"
));
// 6) Non-local asset still fetches /api/media/sign (general code path preserved)
assert!(html.contains("/api/media/sign?assetId="));
// 7) local rootUri/path are propagated into callback for save writeback
assert!(html.contains("function localFolderOpenParams(raw)"));
assert!(html.contains("callback.searchParams.set(\"rootUri\", localFile.rootUri);"));
assert!(html.contains("callback.searchParams.set(\"path\", localFile.path);"));
// 8) user name is explicit so OnlyOffice does not ask for collaboration name
assert!(html.contains("user: {"));
assert!(html.contains("name: displayUserName"));
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
assert!(html.contains("features: { featuresTips: false }"));
}
}
+99 -8
View File
@@ -465,6 +465,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title');
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
}
if (documentId) {
const escapedId = cssEscape(documentId);
@@ -2616,6 +2618,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
if (pageTab instanceof HTMLElement) {
pageTab.setAttribute('data-document-id', documentId);
pageTab.setAttribute('data-workspace-id', workspaceId);
const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title');
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
}
window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', {
detail: { documentId, workspaceId, title }
}));
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
@@ -2986,12 +2998,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE
? anchorNode
: anchorNode?.parentElement || null;
const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => node instanceof HTMLElement);
const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => (
node instanceof HTMLElement
&& node.offsetParent !== null
&& node.getAttribute('data-mnote-side-target-unsupported') !== 'true'
));
if (anchorElement instanceof Element) {
const activeRoot = roots.find((root) => root.contains(anchorElement));
if (activeRoot) return activeRoot;
}
return roots.find((root) => root.offsetParent !== null) || roots[0] || null;
const focused = document.activeElement instanceof Element
? roots.find((root) => root.contains(document.activeElement))
: null;
if (focused) return focused;
return roots.find((root) => root.querySelector('.ProseMirror:focus-within')) || roots[0] || null;
};
const slashMenuAnchorFromSelection = (root) => {
@@ -3042,12 +3062,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return fallback();
};
const positionSlashMenuForRoot = (root) => {
if (!(root instanceof HTMLElement)) root = activeEditorRootForSlashMenu();
if (!(root instanceof HTMLElement)) return;
const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')
|| document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
const setSlashMenuInactive = (menu, inactive) => {
if (!(menu instanceof HTMLElement)) return;
if (inactive) {
if (menu.getAttribute('data-mnote-slash-inactive') !== 'true') {
menu.setAttribute('data-mnote-slash-inactive', 'true');
}
if (menu.style.display !== 'none') menu.style.display = 'none';
return;
}
if (menu.getAttribute('data-mnote-slash-inactive') === 'true') {
menu.removeAttribute('data-mnote-slash-inactive');
}
if (menu.style.display === 'none') menu.style.display = '';
};
const hideSlashMenusOutsideRoot = (activeRoot) => {
document.querySelectorAll(`${ROOT_SELECTOR} [data-testid="mnote-leptos-tiptap-slash-menu"]`).forEach((menu) => {
const root = menu.closest(ROOT_SELECTOR);
if (root !== activeRoot) setSlashMenuInactive(menu, true);
});
};
const positionSlashMenuForRoot = (root) => {
const activeRoot = activeEditorRootForSlashMenu();
if (!(root instanceof HTMLElement)) root = activeRoot;
if (!(root instanceof HTMLElement)) return;
if (activeRoot instanceof HTMLElement && root !== activeRoot) {
const inactiveMenu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
setSlashMenuInactive(inactiveMenu, true);
hideSlashMenusOutsideRoot(activeRoot);
return;
}
hideSlashMenusOutsideRoot(root);
const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
if (!(menu instanceof HTMLElement)) return;
setSlashMenuInactive(menu, false);
const anchor = slashMenuAnchorFromSelection(root);
const gap = 8;
const menuWidth = Math.min(316, Math.max(160, window.innerWidth - 16));
@@ -3611,6 +3661,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
};
const refreshExistingOfficeResourceTab = (entry, input) => {
if (!entry || entry.kind !== 'office') return false;
const nextHref = String(input.officeUrl || input.href || '').trim();
if (!nextHref) return false;
const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame');
const currentHref = frame instanceof HTMLIFrameElement
? String(frame.getAttribute('src') || frame.src || '').trim()
: '';
if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
return true;
};
const openMindmapResourceTab = async (entry, input) => {
const documentId = String(input.documentId || currentDocumentId() || '').trim();
const mindmapId = String(input.mindmapId || input.assetId || '').trim();
@@ -3668,6 +3730,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源';
if (root instanceof HTMLElement) {
root.replaceChildren();
document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => {
if (node instanceof HTMLElement) node.remove();
});
root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target');
root.setAttribute('data-mnote-side-target-unsupported', 'true');
root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
@@ -3691,6 +3756,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (!objectIdentity) return false;
const existing = resourceTabRegistry.get(objectIdentity);
if (existing) {
refreshExistingOfficeResourceTab(existing, input);
activateMainEditorTab(objectIdentity);
return true;
}
@@ -3754,6 +3820,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('primary', descriptor);
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
activateMainEditorTab('');
return true;
},
openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => {
@@ -3778,6 +3845,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
},
resolveResourceOpen: (input) => resolveResourceOpen(input),
openResourceInActiveTab: openResourceInActiveTab,
activatePageTab: () => {
bindMainEditorPageTab();
activateMainEditorTab('');
return true;
},
openResourceAsSideTarget: async (input = {}) => {
const resolved = resolveResourceOpen({ ...input, openTarget: 'side' });
if (resolved.editorKind === 'markdown' || resolved.editorKind === 'text' || resolved.editorKind === 'code') {
@@ -4466,12 +4538,25 @@ mod tests {
assert!(html.contains("data-mnote-main-tab-strip"));
assert!(html.contains("class=\"mnote-main-tab-badge\""));
assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title"));
assert!(html.contains("pageTab.setAttribute('data-document-id', documentId);"));
assert!(html.contains("data-mnote-tab-badge-kind"));
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
assert!(html.contains("positionSlashMenuForRoot"));
assert!(html.contains("menu.style.position = 'fixed';"));
assert!(html.contains("installGlobalSlashMenuPositioning();"));
assert!(html.contains("data-mnote-slash-positioned', 'host'"));
assert!(html.contains(
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
));
assert!(html.contains("const setSlashMenuInactive = (menu, inactive) =>"));
assert!(html.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
assert!(html.contains("data-mnote-slash-inactive"));
assert!(html.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)"));
assert!(!html.contains(
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
));
assert!(html.contains("data-mnote-side-target-unsupported') !== 'true'"));
assert!(
html.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
);
@@ -4509,6 +4594,12 @@ mod tests {
assert!(html.contains("resourceTabRegistry.delete(key)"));
assert!(html.contains("activateMainEditorTab(lastActiveResourceTabKey())"));
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
assert!(html.contains("const refreshExistingOfficeResourceTab = (entry, input) =>"));
assert!(html.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(
html.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);")
);
assert!(html.contains("refreshExistingOfficeResourceTab(existing, input);"));
}
#[tokio::test]
@@ -4769,7 +4860,7 @@ mod tests {
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-page-openable=\"false\""));
assert!(html.contains("fileAction === 'open' && rowKind === 'folder'"));
assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'"));
assert!(html.contains("openTrigger.getAttribute('data-page-openable') === 'false'"));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
assert!(html.contains("refreshSessionFromExternalFileChange"));
assert!(html.contains("refreshSessionFromExternalChange"));