收口 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:
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!(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user