收口 MNote P0 P1 P2 审查尾项

- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
This commit is contained in:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,8 +8,8 @@ use crate::page_aggregate::{
use crate::routes::local_markdown_parser::{
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_search_index;
use crate::routes::snapshot_support::ProjectionSnapshot;
use crate::routes::{local_ocr, local_search_index};
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
@@ -4076,8 +4076,13 @@ fn local_mindmap_file_name(mindmap_id: &str) -> String {
fn is_local_mindmap_file_name(file_name: &str) -> bool {
let trimmed = file_name.trim();
let lower = trimmed.to_ascii_lowercase();
if !lower.ends_with(".json") {
return false;
}
lower.ends_with(".mindmap.json")
|| (trimmed.starts_with("思维导图") && lower.ends_with(".json"))
|| lower.starts_with("mindmap-")
|| lower.starts_with("mindmap_")
|| trimmed.starts_with("思维导图")
}
fn resolve_local_mindmap_path(
@@ -7395,6 +7400,9 @@ fn scan_markdown_page_tree(
continue;
}
if is_markdown_file(&entry.file_name) {
if local_ocr::is_local_ocr_sidecar_relative_path(&entry.relative_path) {
continue;
}
let markdown = fs::read_to_string(&entry.path).unwrap_or_default();
let parsed = parse_markdown_page(&markdown, &entry.file_name);
let page_id = local_markdown_path_page_id(&entry.relative_path);
@@ -8426,7 +8434,6 @@ fn editor_blocks_to_markdown_with_rewrite(
lines.push(text);
} else {
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
.map(|value| markdown_href_for_relative_path(&value))
.unwrap_or_else(|| src.to_string());
lines.push(format!(
"![{}]({})",
@@ -10424,6 +10431,61 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_generated_mindmap_id_roundtrips_as_mindmap_block() {
let root = temp_root("mnote-local-markdown-generated-mindmap-roundtrip");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
let root_uri = format!("file://{}", root.display());
write_local_mindmap_data(
&root_uri,
"local-md:Page~2FPage.md",
"mindmap-123456",
serde_json::json!({"data":{"text":"中心主题"},"children":[]}),
false,
)
.expect("write generated mindmap");
save_local_markdown_page(
&root_uri,
"local-md:Page~2FPage.md",
None,
&serde_json::json!([
{
"type": "mindmap",
"props": {
"name": "思维导图",
"mindmapId": "mindmap-123456",
"rootNodeId": "root"
}
}
]),
)
.expect("save");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[思维导图](mindmap-123456.json)"));
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page~2FPage.md")
.expect("aggregate");
let mindmap = aggregate.body.content.as_array().expect("blocks")[0].clone();
assert_eq!(mindmap["type"], "mindmap");
assert_eq!(mindmap["props"]["sourcePath"], "mindmap-123456.json");
assert_eq!(mindmap["props"]["mindmapId"], "mindmap-123456.json");
assert_eq!(
aggregate.body.block_document["blocks"][0]["attrs"]["mindmapId"],
serde_json::json!("mindmap-123456.json")
);
assert_eq!(
aggregate.body.block_document["blocks"][0]["attrs"]["sourcePath"],
serde_json::json!("mindmap-123456.json")
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_workspace_access_rejects_owner_mismatch() {
let root = temp_root("mnote-local-workspace-owner-mismatch");
@@ -12656,6 +12718,8 @@ fn main() {}
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap");
std::fs::write(root.join("Page").join("mindmap-123456.json"), "{}")
.expect("write generated mindmap");
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
std::fs::write(root.join("Page").join("sheet.xlsx"), b"xlsx").expect("write xlsx");
std::fs::write(root.join("Page").join("slides.pptx"), b"pptx").expect("write pptx");
@@ -12687,6 +12751,17 @@ fn main() {}
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
Some("local-file:Page/思维导图123456.json")
);
let generated_mindmap = items
.iter()
.find(|item| item["title"].as_str() == Some("mindmap-123456.json"))
.expect("generated mindmap row");
assert_eq!(generated_mindmap["rowKind"].as_str(), Some("asset"));
assert_eq!(generated_mindmap["iconHint"].as_str(), Some("mindmap"));
assert_eq!(
generated_mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"]
.as_str(),
Some("mindmap")
);
let office = items
.iter()
@@ -13239,6 +13314,41 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_ocr_sidecar_is_filetree_resource_not_page_tree_document() {
let root = temp_root("mnote-local-ocr-sidecar-page-tree");
init_workspace(&root);
let root_uri = format!("file://{}", root.display());
std::fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
std::fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
std::fs::write(
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token\n",
)
.expect("ocr markdown");
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
.expect("file tree");
let file_items = file_tree.projection["items"]
.as_array()
.expect("file items");
assert!(file_items
.iter()
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
let page_items = page_tree.projection["items"]
.as_array()
.expect("page items");
assert!(page_items
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")));
assert!(!page_items.iter().any(|item| item["documentId"].as_str()
== Some("local-md:docs~2FPage.ocr~2Fphoto.png.ocr.md")));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_rename_markdown_page_renames_nested_bundle() {
let root = temp_root("mnote-local-rename-nested-bundle");
@@ -915,10 +915,7 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
.and_then(|value| value.to_str())
.unwrap_or(target)
.trim();
let lower = file_name.to_ascii_lowercase();
let is_mindmap = lower.ends_with(".mindmap.json")
|| (file_name.starts_with("思维导图") && lower.ends_with(".json"));
if !is_mindmap {
if !is_local_mindmap_file_name(file_name) {
return None;
}
let name = collect_plain_text(node).trim().to_string();
@@ -932,6 +929,18 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
))
}
fn is_local_mindmap_file_name(file_name: &str) -> bool {
let trimmed = file_name.trim();
let lower = trimmed.to_ascii_lowercase();
if !lower.ends_with(".json") {
return false;
}
lower.ends_with(".mindmap.json")
|| lower.starts_with("mindmap-")
|| lower.starts_with("mindmap_")
|| trimmed.starts_with("思维导图")
}
fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value {
Value::Array(
document
@@ -1239,6 +1248,25 @@ mod tests {
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
#[test]
fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() {
let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n");
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("mindmap"));
assert_eq!(
first["props"]["sourcePath"].as_str(),
Some("mindmap-123456.json")
);
assert_eq!(
first["props"]["mindmapId"].as_str(),
Some("mindmap-123456.json")
);
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ use crate::routes::local_folder_source::encode_local_id_segment;
use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_ocr;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
@@ -55,6 +56,7 @@ pub(crate) fn query_local_search_index(
limit: u32,
title_only: bool,
exact: bool,
include_ocr: bool,
) -> Result<Value, WebError> {
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
let normalized_query = normalize_search_text(query);
@@ -90,6 +92,39 @@ pub(crate) fn query_local_search_index(
}
}
}
if include_ocr && results.len() < limit.max(1) as usize {
for entry in local_ocr::ocr_index_entries(root_path)? {
if let Some(page_id) = page_id {
if entry.owner_document_id != page_id {
continue;
}
}
if entry.status != "done" {
continue;
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let markdown = match fs::read_to_string(&ocr_path) {
Ok(markdown) => markdown,
Err(_) => continue,
};
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
continue;
}
let body = local_ocr::strip_ocr_frontmatter(&markdown);
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_ocr_projection(
&entry,
body,
root_uri,
&normalized_query,
));
if results.len() >= limit.max(1) as usize {
break;
}
}
}
Ok(json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
@@ -100,7 +135,8 @@ pub(crate) fn query_local_search_index(
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
"resourceCount": index.resources.len(),
"includeOcr": include_ocr
},
"recentChanges": recent_changes,
"results": results
@@ -404,6 +440,14 @@ fn collect_markdown_documents(
continue;
}
if is_markdown_path(&path) {
let relative_path = path
.strip_prefix(root_path)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
continue;
}
documents.push(index_markdown_file(root_path, &path)?);
} else if resource_type_from_path(&path).is_some() {
resources.push(index_resource_file(root_path, &path)?);
@@ -565,6 +609,34 @@ fn local_search_resource_matches(
}
}
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&format!(
"{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path
))
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path, body
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
}
}
fn local_search_document_projection(
document: &LocalSearchDocument,
root_uri: &str,
@@ -608,6 +680,42 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
root_uri: &str,
query: &str,
) -> Value {
let title = Path::new(&entry.owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("OCR")
.to_string();
json!({
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
"documentId": entry.owner_document_id,
"title": title,
"path": entry.owner_document_path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"hasOcr": true,
"snippet": ocr_search_snippet(body, query),
"ocrEvidence": {
"sourceRootRelativePath": entry.source_root_relative_path,
"ocrRootRelativePath": entry.ocr_root_relative_path,
"provider": entry.provider,
"status": entry.status,
},
"updatedAt": entry.updated_at_ms,
"publicPath": format!(
"/documents/{}?sourceKind=local_folder&rootUri={}",
entry.owner_document_id,
encode_query_component(root_uri),
)
})
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
@@ -764,6 +872,27 @@ fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
document.raw_text.chars().take(180).collect()
}
fn ocr_search_snippet(body: &str, query: &str) -> String {
let normalized_body = body.replace('\n', " ");
let normalized_query = query.trim();
if normalized_query.is_empty() {
return normalized_body.chars().take(160).collect();
}
let lower = normalized_body.to_ascii_lowercase();
let lower_query = normalized_query.to_ascii_lowercase();
if let Some(byte_index) = lower.find(&lower_query) {
let start = normalized_body[..byte_index]
.char_indices()
.rev()
.nth(40)
.map(|(idx, _)| idx)
.unwrap_or(0);
normalized_body[start..].chars().take(160).collect()
} else {
normalized_body.chars().take(160).collect()
}
}
fn is_markdown_path(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
@@ -845,6 +974,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("projection");
let results = projection["results"].as_array().expect("results");
@@ -898,6 +1028,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("child search");
let child_result = child_search["results"]
@@ -929,6 +1060,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("mindmap projection");
assert!(mindmap_projection["results"]
@@ -953,6 +1085,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("office projection");
assert!(office_projection["results"]
@@ -1017,6 +1150,104 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
let root = temp_root("mnote-local-search-ocr");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-ocr";
fs::write(root.join("docs").join("Page.md"), "# Page\n正文\n").expect("page");
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
fs::write(
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token 识别正文\n",
)
.expect("ocr markdown");
fs::create_dir_all(root.join(".mnote")).expect("mnote dir");
fs::write(
root.join(".mnote").join("ocr-index.json"),
serde_json::to_string_pretty(&json!({
"version": 1,
"entries": {
"docs/Page.assets/photo.png": {
"jobId": "ocr_test",
"ownerDocumentId": "local-md:docs~2FPage.md",
"ownerDocumentPath": "docs/Page.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
"provider": "mock",
"modelVersion": "vlm",
"status": "done",
"sourceSize": 3,
"sourceMtimeMs": 1,
"createdAtMs": 1,
"updatedAtMs": 1,
"plainTextPreview": "OCR-only-token 识别正文"
}
}
}))
.expect("serialize index"),
)
.expect("ocr index");
let without_ocr = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OCR-only-token",
None,
10,
false,
false,
false,
)
.expect("without ocr");
assert_eq!(without_ocr["results"].as_array().map(Vec::len), Some(0));
let with_ocr = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OCR-only-token",
None,
10,
false,
false,
true,
)
.expect("with ocr");
let result = with_ocr["results"]
.as_array()
.and_then(|items| items.first())
.expect("ocr result");
assert_eq!(
result["documentId"].as_str(),
Some("local-md:docs~2FPage.md")
);
assert_eq!(result["hasOcr"].as_bool(), Some(true));
assert_eq!(
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
Some("docs/Page.assets/photo.png")
);
assert_eq!(
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
Some("docs/Page.ocr/photo.png.ocr.md")
);
let index = read_local_search_index(&root)
.expect("read search index")
.expect("search index");
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_query_reads_existing_index_without_rebuilding() {
let root = temp_root("mnote-local-search-query-cache");
@@ -1038,6 +1269,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("first query");
assert_eq!(
@@ -1060,6 +1292,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("query existing index");
assert_eq!(
@@ -1078,6 +1311,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("query refreshed index");
assert_eq!(
+97 -1
View File
@@ -13,12 +13,14 @@ mod kernel;
mod local_folder_events;
mod local_folder_source;
mod local_markdown_parser;
mod local_ocr;
mod local_search_index;
mod media;
mod mindmap_api;
mod mindmap_shell;
pub(crate) mod navigation_recent;
mod onlyoffice;
pub(crate) mod onlyoffice_bridge;
mod page_ai_workflow;
mod query_support;
mod resource_trash;
@@ -35,7 +37,7 @@ pub(crate) mod web_shell;
mod ws;
pub(crate) use local_folder_source::{
ensure_local_path_read_access, ensure_local_workspace_access,
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
};
@@ -183,6 +185,34 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
get(web_shell::sidebar_page_ai_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
get(web_shell::sidebar_page_ai_markdown_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
get(web_shell::sidebar_page_ai_render_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
get(web_shell::sidebar_page_ai_permission_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
get(web_shell::sidebar_page_ai_profile_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
get(web_shell::sidebar_page_ai_session_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
get(web_shell::sidebar_page_ai_skill_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
get(web_shell::sidebar_page_ai_target_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
get(web_shell::sidebar_page_settings_runtime_asset),
@@ -288,6 +318,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/whoami", get(session::session))
.route("/api/auth/mnote-web-token", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route(
"/api/ai/agent-profiles",
get(hermes_client::list_agent_profiles),
)
.route(
"/api/sidebar/shortcuts",
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
@@ -382,6 +416,54 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/onlyoffice/proxy", get(onlyoffice::proxy))
.route("/api/onlyoffice/callback", post(onlyoffice::callback))
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
.route(
"/api/onlyoffice/bridge/plugin/config",
get(onlyoffice_bridge::plugin_config),
)
.route(
"/api/onlyoffice/bridge/plugin/index",
get(onlyoffice_bridge::plugin_index),
)
.route(
"/api/onlyoffice/bridge/plugin/index/config.json",
get(onlyoffice_bridge::plugin_config),
)
.route(
"/api/onlyoffice/bridge/plugin/index/{state}",
get(onlyoffice_bridge::plugin_index_with_state),
)
.route(
"/api/onlyoffice/bridge/session",
post(onlyoffice_bridge::register_session),
)
.route(
"/api/onlyoffice/bridge/session/current",
get(onlyoffice_bridge::current_session),
)
.route(
"/api/onlyoffice/bridge/session/close",
post(onlyoffice_bridge::close_session),
)
.route(
"/api/onlyoffice/bridge/sessions",
get(onlyoffice_bridge::list_sessions),
)
.route(
"/api/onlyoffice/bridge/capabilities",
get(onlyoffice_bridge::capabilities),
)
.route(
"/api/onlyoffice/bridge/commands",
post(onlyoffice_bridge::enqueue_command),
)
.route(
"/api/onlyoffice/bridge/commands/next",
get(onlyoffice_bridge::next_command),
)
.route(
"/api/onlyoffice/bridge/results",
get(onlyoffice_bridge::get_result).post(onlyoffice_bridge::post_result),
)
.route("/api/media/upload", post(media::upload))
.route("/api/media/sign", get(media::sign))
.route("/api/media/batch", post(resource_trash::media_batch))
@@ -452,6 +534,13 @@ pub fn build_router(state: AppState) -> Router {
"/api/local-folder/events",
get(local_folder_events::local_folder_events),
)
.route(
"/api/local-folder/ocr/jobs",
get(local_ocr::list_jobs).post(local_ocr::create_job),
)
.route("/api/local-folder/ocr/status", get(local_ocr::status))
.route("/api/local-folder/ocr/read", get(local_ocr::read))
.route("/api/local-folder/ocr/insert", post(local_ocr::insert))
.route(
"/api/local-folder/workspaces/default",
post(local_folder_source::create_default_local_workspace),
@@ -1027,6 +1116,13 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
"/api/mnote-browser-runtime/tree-live-controller.js",
+416 -10
View File
@@ -1,3 +1,4 @@
use super::onlyoffice_bridge;
use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
@@ -147,6 +148,9 @@ pub struct OnlyOfficeCallbackQuery {
asset_id: Option<String>,
#[serde(rename = "userId")]
user_id: Option<String>,
#[serde(rename = "sessionId")]
session_id: Option<String>,
token: Option<String>,
#[serde(rename = "rootUri")]
root_uri: Option<String>,
path: Option<String>,
@@ -506,10 +510,12 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
return null;
}}
}}
function buildCallbackUrl(assetId, userId, localFile) {{
function buildCallbackUrl(assetId, userId, localFile, bridgeSessionId, bridgeToken) {{
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 (bridgeSessionId) callback.searchParams.set("sessionId", bridgeSessionId);
if (bridgeToken) callback.searchParams.set("token", bridgeToken);
if (localFile && localFile.rootUri && localFile.path) {{
callback.searchParams.set("rootUri", localFile.rootUri);
callback.searchParams.set("path", localFile.path);
@@ -598,6 +604,11 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
return false;
}}
function editModeLocationHref() {{
const next = new URL(location.href);
next.searchParams.set("mode", "edit");
return next.toString();
}}
async function resolveAssetUrlAndKey() {{
let effectiveFileUrl = initial.fileUrl;
let storageId = "";
@@ -639,6 +650,18 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
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 bridgeSessionSalt = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
const bridgeSessionId = "mnote-oo-" + fileState.docKey + "-" + bridgeSessionSalt;
const bridgeToken = (crypto && crypto.randomUUID) ? crypto.randomUUID() : ("mnote-oo-token-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
const bridgePluginConfigUrl = new URL("/api/onlyoffice/bridge/plugin/config", location.origin);
bridgePluginConfigUrl.searchParams.set("sessionId", bridgeSessionId);
bridgePluginConfigUrl.searchParams.set("token", bridgeToken);
bridgePluginConfigUrl.searchParams.set("apiBase", location.origin);
bridgePluginConfigUrl.searchParams.set("documentId", initial.documentId || "");
bridgePluginConfigUrl.searchParams.set("assetId", initial.assetId || "");
bridgePluginConfigUrl.searchParams.set("fileType", initial.fileType || "");
bridgePluginConfigUrl.searchParams.set("docKey", fileState.docKey);
bridgePluginConfigUrl.searchParams.set("pageOrigin", location.origin);
const config = {{
width: "100%",
height: "100%",
@@ -658,11 +681,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
editorConfig: {{
mode: resolvedMode,
lang: "zh-CN",
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile, bridgeSessionId, bridgeToken),
user: {{
id: displayUserId,
name: displayUserName
}},
plugins: {{
autostart: [MNOTE_AGENT_PLUGIN_GUID],
pluginsData: [bridgePluginConfigUrl.toString()]
}},
customization: {{
feedback: {{ visible: false }},
anonymous: {{ request: false, label: "Guest" }},
@@ -673,6 +700,14 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
events: {{
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onRequestEditRights: () => {{
const editHref = editModeLocationHref();
window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__ = {{
requested: true,
targetUrl: editHref
}};
window.location.replace(editHref);
}},
onError: (event) => showError(JSON.stringify(event))
}}
}};
@@ -690,7 +725,52 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
resolvedMode,
assetId: initial.assetId,
documentId: initial.documentId,
docKey: fileState.docKey
docKey: fileState.docKey,
bridgeSessionId,
bridgeToken,
bridgePluginConfigUrl: bridgePluginConfigUrl.toString()
}};
try {{
if (window.parent && window.parent !== window) {{
window.parent.postMessage({{
type: "mnote:onlyoffice-bridge-ready",
bridgeSessionId,
documentId: initial.documentId,
assetId: initial.assetId,
fileType: initial.fileType
}}, location.origin);
}}
}} catch (_) {{}}
window.__MNOTE_ONLYOFFICE_BRIDGE__ = {{
sessionId: bridgeSessionId,
run: async function(action, payload, timeoutMs) {{
const enqueue = await fetch("/api/onlyoffice/bridge/commands", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify({{
sessionId: bridgeSessionId,
token: bridgeToken,
action,
payload: payload || {{}}
}})
}});
const enqueued = await enqueue.json().catch(() => null);
if (!enqueue.ok) throw new Error(enqueued && enqueued.message || "ONLYOFFICE bridge command enqueue failed");
const commandId = enqueued && enqueued.command && enqueued.command.id;
if (!commandId) throw new Error("ONLYOFFICE bridge command id missing");
const resultUrl = new URL("/api/onlyoffice/bridge/results", location.origin);
resultUrl.searchParams.set("sessionId", bridgeSessionId);
resultUrl.searchParams.set("token", bridgeToken);
resultUrl.searchParams.set("commandId", commandId);
resultUrl.searchParams.set("timeoutMs", String(timeoutMs || 25000));
const resultResponse = await fetch(resultUrl.toString());
if (resultResponse.status === 204) throw new Error("ONLYOFFICE bridge command timed out");
const result = await resultResponse.json().catch(() => null);
if (!resultResponse.ok || !result || result.ok === false) {{
throw new Error(result && result.error || result && result.message || "ONLYOFFICE bridge command failed");
}}
return result.result;
}}
}};
const readyDeadline = Date.now() + 120000;
const timer = window.setInterval(() => {{
@@ -1070,12 +1150,15 @@ fn onlyoffice_callback_success(extra: Value) -> Response {
}
fn onlyoffice_callback_failure(error: WebError) -> Response {
Json(json!({
(
error.status(),
Json(json!({
"error": 1,
"code": error.code(),
"message": error.message(),
}))
.into_response()
})),
)
.into_response()
}
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
@@ -1135,6 +1218,53 @@ async fn local_folder_onlyoffice_callback(
)
})?;
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
let session_id = query
.session_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_session_required",
"OnlyOffice 本地保存缺少 bridge sessionId",
)
})?;
let token = query
.token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_token_required",
"OnlyOffice 本地保存缺少 bridge token",
)
})?;
if !onlyoffice_bridge::session_token_matches(session_id, token) {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_token_invalid",
"OnlyOffice 本地保存 bridge token 无效",
));
}
let session = onlyoffice_bridge::session_info(session_id).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_session_unregistered",
"OnlyOffice 本地保存 bridge session 未注册",
)
})?;
if let Some(session_asset_id) = session.asset_id.as_deref() {
if !session_asset_id.trim().is_empty() && session_asset_id.trim() != asset_id {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"onlyoffice_local_callback_asset_mismatch",
"OnlyOffice 本地保存 session 与资源不匹配",
));
}
}
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
asset_id: asset_id.to_string(),
@@ -1840,6 +1970,22 @@ mod tests {
));
assert!(html.contains("if (proxyOrigin && isLocalFolderFileOpen)"));
assert!(html.contains("if (proxyOrigin && (isLocal || url.searchParams.has(\"token\")))"));
assert!(html.contains(
"const MNOTE_AGENT_PLUGIN_GUID = \"asc.{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}\";"
));
assert!(html.contains(
"const bridgePluginConfigUrl = new URL(\"/api/onlyoffice/bridge/plugin/config\", location.origin);"
));
assert!(
html.contains("bridgePluginConfigUrl.searchParams.set(\"apiBase\", location.origin);")
);
assert!(html.contains("const bridgeSessionSalt ="));
assert!(html.contains(
"const bridgeSessionId = \"mnote-oo-\" + fileState.docKey + \"-\" + bridgeSessionSalt;"
));
assert!(html.contains("/api/onlyoffice/bridge/plugin/config"));
assert!(html.contains("pluginsData: [bridgePluginConfigUrl.toString()]"));
assert!(html.contains("window.__MNOTE_ONLYOFFICE_BRIDGE__"));
}
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
@@ -1897,6 +2043,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
session_id: None,
token: None,
root_uri: None,
path: None,
}),
@@ -1917,9 +2065,9 @@ mod tests {
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
async fn onlyoffice_local_callback_rejects_unauthenticated_local_write() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-{}",
"mnote-onlyoffice-local-callback-unauth-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
@@ -1938,6 +2086,74 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: None,
token: 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;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
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"], 1);
assert_eq!(
payload["code"],
"onlyoffice_local_callback_session_required"
);
assert_eq!(fs::read(&target).expect("read target"), b"old");
let _ = fs::remove_dir_all(&root);
}
#[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 session_id = format!("mnote-oo-local-callback-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
@@ -1958,6 +2174,147 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_six_body_to_original_file() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-status-six-{}",
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("status six bytes").await;
let session_id = format!("mnote-oo-local-callback-six-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({
"status": 6,
"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"status six bytes");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_rejects_root_escape_path() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-root-{}",
std::process::id()
));
let outside = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-outside-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
fs::create_dir_all(root.join("Page")).expect("create page");
fs::create_dir_all(&outside).expect("create outside");
fs::write(outside.join("report.docx"), b"outside").expect("write outside");
let (download_url, _captured) = spawn_legacy_json_server("should not write").await;
let session_id = format!("mnote-oo-local-callback-escape-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=..%2F{}%2Freport.docx",
session_id,
token,
root.display(),
outside.file_name().and_then(|value| value.to_str()).unwrap_or_default(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some(format!(
"../{}/report.docx",
outside
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
)),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": download_url
})),
)
.await;
let status = response.status();
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!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["error"], 1);
assert_eq!(payload["code"], "onlyoffice_local_file_root_escape");
assert_eq!(
fs::read(outside.join("report.docx")).expect("read outside"),
b"outside"
);
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
}
#[tokio::test]
async fn onlyoffice_local_callback_ignores_non_write_status() {
let root = std::env::temp_dir().join(format!(
@@ -1968,17 +2325,36 @@ mod tests {
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 session_id = format!("mnote-oo-local-callback-ignore-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
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()
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
@@ -2006,6 +2382,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
session_id: None,
token: None,
root_uri: None,
path: None,
}),
@@ -2128,4 +2506,32 @@ mod tests {
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
assert!(html.contains("features: { featuresTips: false }"));
}
#[tokio::test]
async fn onlyoffice_page_reinitializes_edit_url_on_request_edit_rights() {
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");
assert!(html.contains("function editModeLocationHref()"));
assert!(html.contains("next.searchParams.set(\"mode\", \"edit\");"));
assert!(html.contains("onRequestEditRights: () =>"));
assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__"));
assert!(html.contains("window.location.replace(editHref);"));
}
}
File diff suppressed because it is too large Load Diff
@@ -1215,10 +1215,11 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
serde_json::from_slice(&body).expect("json")
}
@@ -1236,10 +1237,11 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
serde_json::from_slice(&body).expect("json")
}
@@ -1256,10 +1258,11 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
serde_json::from_slice(&body).expect("json")
}
@@ -192,6 +192,7 @@ pub async fn documents(
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?
} else {
load_search_results_with_filters(
+120 -1
View File
@@ -1623,6 +1623,104 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_render_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_permission_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_profile_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_session_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_skill_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_settings_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js");
Response::builder()
@@ -3151,8 +3249,18 @@ mod tests {
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
));
assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document"));
let block_document_source_index = conversion_runtime
.find("if (blockDocument) return 'page_aggregate.block_document';")
.expect("blockDocument source should be explicit");
let local_markdown_source_index = conversion_runtime
.find("return 'local_markdown.content';")
.expect("local markdown legacy fallback should remain explicit");
assert!(
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")
block_document_source_index < local_markdown_source_index,
"local-first 浏览器转换应优先消费 blockDocument,再降级到 body.content"
);
assert!(
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body?.content")
);
assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref"));
assert!(conversion_runtime.contains("mnote-uploaded-attachment-code"));
@@ -4428,4 +4536,15 @@ mod tests {
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
#[test]
fn mindmap_resize_runtime_contract_includes_dimension_attrs() {
let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
assert!(runtime.contains("mindmapWidth"));
assert!(runtime.contains("mindmapHeight"));
assert!(runtime.contains("data?.mindmap_width"));
assert!(runtime.contains("dataset.mnoteMindmapWidth"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("--mnote-mindmap-block-max-width"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("currentPageWidthPreferences().mindmap"));
}
}