Files
mnote/rust/crates/mnote-web/src/routes/web_shell.rs
T
Agent Board b798f628ee chore: land tree view-state, vault, Pi module split, and repo hygiene
Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
2026-07-21 05:13:05 +08:00

5795 lines
243 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::document_buffer_store::{self};
use crate::error::WebError;
use crate::page_aggregate::PageAggregate;
use crate::routes::documents::{
load_document_content_result, load_document_meta_result, DocumentContentQuery,
DocumentMetaQuery,
};
use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot_with_reveal,
load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
use crate::ssr::pages::document::DocumentPage;
use crate::tree_shell::filetree_renderer::{
render_filetree_pending_shell_html, render_initial_filetree_html, FileTreeInitialRenderInput,
};
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
use crate::workspace_shell::{
apply_active_page, build_workspace_shell_projection, render_page_breadcrumb_html,
render_workspace_shell_sidebar_html, WorkspaceShellProjection,
};
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use control_plane::UpsertNavigationRecentInput;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::path::{Component, Path as FsPath, PathBuf};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
#[cfg(test)]
static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentShellQuery {
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub tree_view: Option<String>,
pub file_tree_scope: Option<String>,
pub secondary_document_id: Option<String>,
pub secondary_source_kind: Option<String>,
pub secondary_root_uri: Option<String>,
}
pub async fn document_page_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
uri: Uri,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
if crate::routes::gateway::current_actor_id(&state, &context).is_none() {
return redirect_response(&format!(
"/auth?next={}",
query_escape(
uri.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/")
)
));
}
let mut primary_source_kind = normalize_source_kind(query.source_kind.as_deref());
let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref());
if primary_source_kind.is_none()
&& document_id.trim().starts_with("local-md:")
&& primary_root_uri.is_some()
{
primary_source_kind = Some("local_folder");
}
if primary_source_kind.is_none() && document_id.trim().starts_with("local-md:") {
return redirect_response(&format!(
"/?routeGuard={}&missingPage={}",
query_escape("local_folder_source_required"),
query_escape(&document_id),
));
}
let aggregate = match build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
primary_source_kind,
primary_root_uri,
)
.await
{
Ok(aggregate) => aggregate,
Err(error)
if primary_source_kind == Some("local_folder")
&& error.code() == "local_markdown_not_found" =>
{
return redirect_response(&local_folder_navigation_redirect_location(
primary_root_uri.unwrap_or_default(),
query.file_tree_scope.as_deref(),
&document_id,
));
}
Err(error)
if primary_source_kind == Some("local_folder")
&& error.code() == "local_folder_root_required" =>
{
return redirect_response(&format!(
"/?routeGuard={}&missingPage={}",
query_escape("local_folder_root_required"),
query_escape(&document_id),
));
}
Err(error)
if primary_source_kind == Some("local_folder")
&& error.code() == "local_workspace_access_denied" =>
{
return redirect_response(&format!(
"/?routeGuard={}&missingPage={}",
query_escape("local_workspace_access_denied"),
query_escape(&document_id),
));
}
Err(error) => return Err(error),
};
// 初始化 BufferStorelocal_folder 文档打开时记录 file_version
if primary_source_kind == Some("local_folder") {
if let Some(root_uri) = primary_root_uri {
let relative_path = local_markdown_relative_path_from_document_id(&document_id);
let file_version = aggregate.body.file_version.as_str().map(|s| s.to_string());
let ws_path = document_buffer_store::build_local_folder_workspace_path(
&aggregate.identity.workspace_id,
root_uri,
&relative_path,
&document_id,
);
state.buffer_store.init_buffer(&ws_path, file_version, None);
record_navigation_recent_page(
&state,
&context,
root_uri,
&relative_path,
&document_id,
aggregate.head.title.as_str(),
Some(&aggregate.identity.workspace_id),
);
}
}
let title = aggregate.head.title.as_str();
let workspace_id = aggregate.identity.workspace_id.clone();
let requested_secondary_document_id =
normalize_optional_owned(query.secondary_document_id.as_deref());
let secondary_source_kind = normalize_source_kind(
query
.secondary_source_kind
.as_deref()
.or(primary_source_kind),
);
let secondary_root_uri =
normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri));
let mut secondary_requested = false;
let mut secondary_invalid = false;
let secondary_aggregate =
if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() {
secondary_requested = true;
match build_page_aggregate_snapshot(
&state,
&context,
secondary_document_id,
query.workspace_id.as_deref(),
secondary_source_kind,
secondary_root_uri,
)
.await
{
Ok(aggregate) => Some(aggregate),
Err(_) => {
secondary_invalid = true;
None
}
}
} else {
None
};
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let mut workspace_projection = load_workspace_shell_projection(
Some(&state),
state.config(),
&context,
&workspace_id,
Some(&document_id),
&default_workspace_name,
)
.await;
apply_active_page(&mut workspace_projection, Some(&document_id));
let is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
let requests_filetree_first = query
.tree_view
.as_deref()
.map(str::trim)
.is_some_and(|value| value == "filetree");
let file_tree_scope = query
.file_tree_scope
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
let root_uri = query.root_uri.as_deref().unwrap_or_default();
(
render_local_sidebar_tree_html_scoped(root_uri, Some(&document_id), file_tree_scope)
.unwrap_or_default(),
render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
.unwrap_or_default(),
)
} else {
(
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default(),
load_file_tree_html(
state.config(),
&context,
&workspace_id,
Some(&document_id),
None,
)
.await
.unwrap_or_default(),
)
};
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
if requests_filetree_first {
Some("filetree")
} else {
None
},
file_tree_scope,
);
let workspace_name = workspace_projection.workspace_name.clone();
let breadcrumb_html = render_page_breadcrumb_html(&workspace_projection, Some(&document_id));
let page_subtree_json =
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
.unwrap_or_else(|_| "null".to_string());
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json =
build_editor_bootstrap_json(&aggregate, &context, primary_source_kind, primary_root_uri);
let secondary_page_subtree_json = secondary_aggregate.as_ref().map(|aggregate| {
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string())
});
let secondary_page_options_json = secondary_aggregate.as_ref().map(|aggregate| {
serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string())
});
let secondary_snapshot_json = secondary_aggregate
.as_ref()
.map(|aggregate| serde_json::to_string(aggregate).unwrap_or_else(|_| "null".to_string()));
let secondary_bootstrap_json = secondary_aggregate.as_ref().map(|aggregate| {
build_editor_bootstrap_json_with_ids(
aggregate,
&context,
secondary_source_kind,
secondary_root_uri,
"__MNOTE_SECONDARY_PAGE_AGGREGATE__",
"secondary",
)
});
let panes_bootstrap_json = build_document_panes_bootstrap_json(
&aggregate,
&context,
primary_source_kind,
primary_root_uri,
secondary_aggregate.as_ref(),
secondary_source_kind,
secondary_root_uri,
secondary_requested,
secondary_invalid,
);
let pi_lab_loader_script =
render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab);
let vault_nav_href = crate::routes::gateway::vault_nav_href_for_context(
primary_source_kind,
primary_root_uri,
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
document_id={document_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
breadcrumb_html={breadcrumb_html}
page_subtree_json={page_subtree_json}
page_options_json={page_options_json}
secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
secondary_document_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.document_id.clone()).unwrap_or_default()}
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
primary_hide_title_header={aggregate.layout.page_options.hide_title_header}
secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))}
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
vault_nav_href={vault_nav_href}
/>
});
let hermes_settings_config_script = render_hermes_settings_config_script();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
{}
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}
{}
{}
{}
{}
{}
{}
</body>
</html>"#,
escape_html(title),
render_editor_runtime_preload_links(),
crate::ssr::MNOTE_CSS,
escape_html(&document_id),
escape_html(primary_source_kind.unwrap_or("local_folder")),
escape_html(primary_root_uri.unwrap_or("")),
secondary_requested,
secondary_invalid,
body_content,
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
escape_script_json(&panes_bootstrap_json),
hermes_settings_config_script,
secondary_snapshot_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
.unwrap_or_default(),
secondary_bootstrap_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__" type="application/json">{}</script>"#, escape_script_json(value)))
.unwrap_or_default(),
render_document_title_controller_script(),
render_editor_island_adapter_script(),
format!(
r#"<script type="module" src="{}"></script>"#,
mnote_browser_runtime_src("document-conflict-panel-runtime.js")
),
pi_lab_loader_script,
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "document");
stamp_recent_page_cookie(response.headers_mut(), &document_id);
Ok(response)
}
fn redirect_response(location: &str) -> Result<Response, WebError> {
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, location)
.body(Body::empty())
.map_err(|error| WebError::internal(format!("跳转响应构造失败: {error}")))
}
fn local_folder_navigation_redirect_location(
root_uri: &str,
file_tree_scope: Option<&str>,
missing_document_id: &str,
) -> String {
let scope = file_tree_scope
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| local_markdown_parent_scope_from_document_id(missing_document_id));
let mut location = format!(
"/?sourceKind=local_folder&rootUri={}&treeView=filetree",
query_escape(root_uri)
);
if !scope.trim().is_empty() {
location.push_str("&fileTreeScope=");
location.push_str(&query_escape(&scope));
}
location.push_str("&missingPage=");
location.push_str(&query_escape(missing_document_id));
location
}
fn local_markdown_parent_scope_from_document_id(document_id: &str) -> String {
let relative_path = local_markdown_relative_path_from_document_id(document_id);
relative_path
.rsplit_once('/')
.map(|(parent, _)| parent.to_string())
.unwrap_or_default()
}
fn local_markdown_relative_path_from_document_id(document_id: &str) -> String {
document_id
.trim()
.strip_prefix("local-md:")
.unwrap_or_default()
.replace("~2F", "/")
}
fn record_navigation_recent_page(
state: &AppState,
context: &RequestContext,
root_uri: &str,
relative_path: &str,
document_id: &str,
title: &str,
workspace_id: Option<&str>,
) {
let Some(actor_id) = crate::routes::gateway::current_actor_id(state, context) else {
return;
};
if actor_id.trim().is_empty()
|| actor_id.trim() == "anonymous"
|| root_uri.trim().is_empty()
|| document_id.trim().is_empty()
{
return;
}
let fallback_title = relative_path
.rsplit('/')
.next()
.filter(|value| !value.trim().is_empty())
.unwrap_or("未命名页面");
let _ = state
.control_plane()
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: actor_id,
workspace_id: workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
kind: "page".to_string(),
source_kind: "local_folder".to_string(),
root_uri: root_uri.trim().to_string(),
relative_path: Some(relative_path.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty()),
document_id: Some(document_id.trim().to_string()),
title: title
.trim()
.is_empty()
.then(|| fallback_title.to_string())
.unwrap_or_else(|| title.trim().to_string()),
metadata_json: "{}".to_string(),
});
}
fn query_escape(value: &str) -> String {
value
.bytes()
.flat_map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
vec![byte as char]
}
_ => format!("%{byte:02X}").chars().collect::<Vec<_>>(),
})
.collect()
}
pub(crate) fn build_editor_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> String {
build_editor_bootstrap_json_with_ids(
aggregate,
context,
source_kind,
root_uri,
"__MNOTE_PAGE_AGGREGATE__",
"primary",
)
}
fn render_hermes_settings_config_script() -> String {
let Some(base_url) = [
"MNOTE_WEB_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_API_BASE_URL",
]
.into_iter()
.find_map(env_or_dotenv)
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()) else {
return String::new();
};
let settings_url = format!("{base_url}/hermes/settings");
let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string());
format!(
r#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
escape_script_json(&encoded)
)
}
fn env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
if cfg!(test) {
return None;
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((candidate_key, value)) = line.split_once('=') else {
continue;
};
if candidate_key.trim() != key {
continue;
}
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
pub(crate) fn build_editor_bootstrap_json_with_ids(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
page_aggregate_script_id: &str,
pane_role: &str,
) -> String {
let normalized_source_kind = source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("local_folder");
let save_endpoint = if normalized_source_kind == "local_folder" {
"/api/page-body/write"
} else {
"/api/documents/save"
};
serde_json::to_string(&json!({
"schema": "mnote.editor_bootstrap.v1",
"documentId": aggregate.identity.document_id,
"workspaceId": aggregate.identity.workspace_id,
"paneRole": pane_role,
"sourceKind": normalized_source_kind,
"rootUri": root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(""),
"pageAggregateScriptId": page_aggregate_script_id,
"saveEndpoint": save_endpoint,
"titleEndpoint": "/api/documents/title",
"editorHostKind": "leptos_tiptap_island",
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}))
.unwrap_or_else(|_| "{}".to_string())
}
pub(crate) fn build_document_panes_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
secondary_aggregate: Option<&PageAggregate>,
secondary_source_kind: Option<&str>,
secondary_root_uri: Option<&str>,
secondary_requested: bool,
secondary_invalid: bool,
) -> String {
let primary = json!({
"role": "primary",
"aggregate": aggregate,
"bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
aggregate,
context,
source_kind,
root_uri,
"__MNOTE_PAGE_AGGREGATE__",
"primary",
)).unwrap_or_else(|_| json!({})),
});
let secondary = secondary_aggregate.map(|aggregate| {
json!({
"role": "secondary",
"aggregate": aggregate,
"bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
aggregate,
context,
secondary_source_kind,
secondary_root_uri,
"__MNOTE_SECONDARY_PAGE_AGGREGATE__",
"secondary",
)).unwrap_or_else(|_| json!({})),
})
});
serde_json::to_string(&json!({
"schema": "mnote.document_panes_bootstrap.v1",
"secondaryRequested": secondary_requested,
"secondaryInvalid": secondary_invalid,
"panes": match secondary {
Some(secondary) => vec![primary, secondary],
None => vec![primary],
},
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn normalize_optional_query_value(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn normalize_source_kind(value: Option<&str>) -> Option<&str> {
normalize_optional_query_value(value)
}
fn normalize_optional_owned(value: Option<&str>) -> Option<String> {
normalize_optional_query_value(value).map(ToOwned::to_owned)
}
pub(crate) fn render_document_title_controller_script() -> &'static str {
r#"<script>
(() => {
const CONTRACT = 'mnote.document_title_controller.v1';
const inputs = Array.from(document.querySelectorAll('[data-page-title-input="true"]')).filter((node) => node instanceof HTMLTextAreaElement);
if (!inputs.length) return;
const cssEscape = (value) => {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
};
const autosize = (input) => {
input.style.height = 'auto';
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
};
const setStatus = (input, status, message) => {
input.setAttribute('data-title-save-status', status);
const shell = input.closest('.document-shell');
if (shell instanceof HTMLElement) shell.setAttribute('data-title-save-status', status);
if (message) input.setAttribute('data-title-save-error', message);
else input.removeAttribute('data-title-save-error');
};
const setText = (selector, title) => {
document.querySelectorAll(selector).forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
};
const fileTreePageTitle = (value) => {
const normalized = String(value || '无标题').trim() || '无标题';
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
};
const updateVisibleTitle = (input, title, documentId) => {
const pane = input.closest('[data-document-pane="true"]');
const isPrimaryDocument = documentId && document.body?.dataset.documentId === documentId;
if (isPrimaryDocument) {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title');
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
}
if (documentId) {
const escapedId = cssEscape(documentId);
document.querySelectorAll(`[data-page-title-input="true"][data-document-id="${escapedId}"]`).forEach((node) => {
if (!(node instanceof HTMLTextAreaElement) || node === input) return;
node.value = title;
autosize(node);
setStatus(node, 'saved');
});
document.querySelectorAll(`[data-document-pane="true"][data-pane-document-id="${escapedId}"] [data-page-title-current="true"]`).forEach((node) => {
if (node instanceof HTMLElement && node !== input) node.textContent = title;
});
} else {
pane?.querySelectorAll('[data-page-title-current]').forEach((node) => {
if (node instanceof HTMLElement && node !== input) node.textContent = title;
});
}
const current = document.querySelector('.wolai-breadcrumb-current');
if (current instanceof HTMLElement && isPrimaryDocument) {
let titleNode = current.querySelector('[data-page-title-current]');
if (!(titleNode instanceof HTMLElement)) {
titleNode = document.createElement('span');
titleNode.setAttribute('data-page-title-current', 'true');
current.appendChild(titleNode);
}
titleNode.textContent = title;
}
if (!documentId) return;
const escapedId = cssEscape(documentId);
const escapedDocRowId = cssEscape(`doc:${documentId}`);
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title));
setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
};
inputs.forEach((input) => {
input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
const resolveTitleTarget = (targetInput) => {
const paneRole = (targetInput.getAttribute('data-pane-role') || 'primary').trim();
const rawDocumentId = (targetInput.getAttribute('data-document-id') || '').trim();
const documentId = rawDocumentId || (
paneRole === 'primary'
? (document.body?.dataset.documentId || '').trim()
: ''
);
const query = new URLSearchParams(window.location.search);
const paneQueryParams = paneRole === 'secondary'
? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' }
: { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' };
return {
documentId,
workspaceId: (targetInput.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(),
sourceKind: (query.get(paneQueryParams.sourceKindParam) || '').trim(),
rootUri: (query.get(paneQueryParams.rootUriParam) || '').trim(),
};
};
const readLastSavedTitle = () => input.getAttribute('data-title-last-saved') || '无标题';
const writeLastSavedTitle = (title) => {
input.setAttribute('data-title-last-saved', title || '无标题');
};
writeLastSavedTitle(input.value.trim() || '无标题');
let saving = false;
const saveTitle = async () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
const title = input.value.trim() || '无标题';
const currentTarget = resolveTitleTarget(input);
autosize(input);
if (!currentTarget.documentId || saving || title === readLastSavedTitle()) {
updateVisibleTitle(input, title, currentTarget.documentId);
setStatus(input, 'saved');
return;
}
saving = true;
setStatus(input, 'saving');
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: currentTarget.documentId,
workspaceId: currentTarget.workspaceId || null,
sourceKind: currentTarget.sourceKind || undefined,
rootUri: currentTarget.rootUri || undefined,
title,
commandName: 'page.head.updateTitle',
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
}
const result = payload?.result || {};
const nextDocumentId = String(result.documentId || result.id || currentTarget.documentId || '').trim();
const previousDocumentId = currentTarget.documentId;
const nextTitle = String(result.title || title || '无标题').trim() || '无标题';
if (nextDocumentId) {
input.setAttribute('data-document-id', nextDocumentId);
}
writeLastSavedTitle(nextTitle);
updateVisibleTitle(input, nextTitle, nextDocumentId || currentTarget.documentId);
setStatus(input, 'saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', {
detail: {
documentId: nextDocumentId || currentTarget.documentId,
previousDocumentId,
workspaceId: currentTarget.workspaceId || null,
title: nextTitle,
payload,
},
}));
} catch (error) {
setStatus(input, 'error', error instanceof Error ? error.message : String(error));
} finally {
saving = false;
}
};
input.addEventListener('input', () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
autosize(input);
setStatus(input, (input.value.trim() || '无标题') === readLastSavedTitle() ? 'saved' : 'dirty');
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
input.blur();
}
});
input.addEventListener('blur', () => { void saveTitle(); });
autosize(input);
updateVisibleTitle(input, readLastSavedTitle(), resolveTitleTarget(input).documentId);
setStatus(input, 'saved');
});
})();
</script>"#
}
pub(crate) fn mnote_browser_runtime_src(asset: &str) -> String {
let base = format!("/api/mnote-browser-runtime/{asset}");
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
format!("{base}?devHot={cache_buster}")
} else {
base
}
}
pub(crate) fn render_editor_island_adapter_script() -> String {
format!(
r#"<script type="module" src="{}"></script>"#,
mnote_browser_runtime_src("document-editor-adapter-runtime.js")
)
}
pub(crate) fn render_page_ai_pi_lab_loader_script(enabled: bool) -> String {
if !enabled {
return String::new();
}
format!(
r#"<script>
(function() {{
var s = document.createElement('script');
s.src = '{}';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#,
mnote_browser_runtime_src("sidebar-page-ai-pi-lab-runtime.js")
)
}
fn leptos_tiptap_runtime_src(asset: &str) -> String {
let base = format!("/api/leptos-tiptap-runtime/{asset}");
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
format!("{base}?devHot={cache_buster}")
} else {
base
}
}
pub(crate) fn render_editor_runtime_preload_links() -> String {
format!(
r#"<link rel="modulepreload" href="{}">
<link rel="preload" href="{}" as="fetch" type="application/wasm" crossorigin>"#,
leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island.js"),
leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island_bg.wasm")
)
}
fn runtime_asset_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../spikes/leptos-tiptap-spike/generated/island")
}
fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
let asset_path = asset_path.trim();
if asset_path.is_empty() || asset_path.starts_with('/') || asset_path.contains('\\') {
return None;
}
let mut resolved = runtime_asset_root();
for component in FsPath::new(asset_path).components() {
match component {
Component::Normal(part) => resolved.push(part),
_ => return None,
}
}
Some(resolved)
}
fn resolve_lazy_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
let asset_path = asset_path.trim();
if asset_path != "tiptap_mindmap_paragraph_runtime.js" {
return None;
}
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../reference-code/leptos-tiptap/src/js/generated")
.join(asset_path),
)
}
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
if asset_path.ends_with(".wasm") {
"application/wasm"
} else if asset_path.ends_with(".js") {
"application/javascript; charset=utf-8"
} else if asset_path.ends_with(".json") {
"application/json; charset=utf-8"
} else {
"application/octet-stream"
}
}
fn runtime_asset_cache_control() -> &'static str {
if crate::routes::dev_hot::dev_hot_reload_enabled() {
"no-store"
} else {
"public, max-age=3600, stale-while-revalidate=86400"
}
}
fn browser_runtime_js_body(js: &'static str) -> Body {
let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else {
return Body::from(js);
};
Body::from(rewrite_browser_runtime_imports_for_dev_hot(
js,
cache_buster,
))
}
fn rewrite_browser_runtime_imports_for_dev_hot(js: &str, cache_buster: &str) -> String {
let suffix = format!("?devHot={cache_buster}");
js.replace(".js';", &format!(".js{suffix}';"))
.replace(".js\";", &format!(".js{suffix}\";"))
.replace(".js');", &format!(".js{suffix}');"))
.replace(".js\");", &format!(".js{suffix}\");"))
.replace(".js'\n", &format!(".js{suffix}'\n"))
.replace(".js\"\n", &format!(".js{suffix}\"\n"))
}
fn runtime_asset_body(asset_path: &str, bytes: Vec<u8>) -> Body {
let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else {
return Body::from(bytes);
};
if !asset_path.ends_with(".js") {
return Body::from(bytes);
}
match String::from_utf8(bytes) {
Ok(js) => Body::from(rewrite_browser_runtime_imports_for_dev_hot(
&js,
cache_buster,
)),
Err(error) => Body::from(error.into_bytes()),
}
}
pub async fn editor_image_placeholder_asset() -> Response {
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="E24 image placeholder">
<rect width="640" height="360" rx="18" fill="#f3f4f6"/>
<rect x="42" y="42" width="556" height="276" rx="14" fill="#ffffff" stroke="#d1d5db" stroke-width="2"/>
<circle cx="168" cy="132" r="34" fill="#93c5fd"/>
<path d="M98 278 246 174 340 242 410 192 542 278Z" fill="#86efac"/>
<path d="M98 278 246 174 340 242 410 192 542 278" fill="none" stroke="#16a34a" stroke-width="8" stroke-linejoin="round"/>
<text x="320" y="322" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#374151">E24 Image</text>
</svg>"##;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(SVG))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfPreviewQuery {
#[serde(default, alias = "fileUrl")]
file_url: Option<String>,
#[serde(default, alias = "fileName")]
file_name: Option<String>,
#[serde(default)]
page: Option<u32>,
#[serde(default)]
bbox: Option<String>,
#[serde(default, alias = "blockId")]
block_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfficePreviewQuery {
file_url: Option<String>,
file_name: Option<String>,
file_type: Option<String>,
workspace_id: Option<String>,
source_kind: Option<String>,
root_uri: Option<String>,
document_id: Option<String>,
#[serde(default)]
page: Option<u32>,
#[serde(default)]
bbox: Option<String>,
#[serde(default, alias = "sourceMapPath")]
source_map_path: Option<String>,
#[serde(default, alias = "blockId")]
block_id: Option<String>,
#[serde(default, alias = "paragraphOrdinal")]
paragraph_ordinal: Option<String>,
#[serde(default, alias = "paraIdStart")]
para_id_start: Option<String>,
#[serde(default, alias = "paraIdEnd")]
para_id_end: Option<String>,
#[serde(default, alias = "textFingerprint")]
text_fingerprint: Option<String>,
#[serde(default, alias = "evidenceText")]
evidence_text: Option<String>,
#[serde(default, alias = "searchQuery")]
search_query: Option<String>,
}
pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
let file_url = query.file_url.unwrap_or_default();
let file_name = query
.file_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Office 预览");
let file_type = query
.file_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
file_name
.rsplit_once('.')
.map(|(_, ext)| ext)
.unwrap_or("docx")
})
.to_ascii_lowercase();
let page_width_content_type = office_preview_page_width_content_type(&file_type);
let workspace_id = query.workspace_id.unwrap_or_default();
let source_kind = query.source_kind.unwrap_or_default();
let root_uri = query.root_uri.unwrap_or_default();
let document_id = query.document_id.unwrap_or_default();
let target_page = query
.page
.map(|value| value.to_string())
.unwrap_or_default();
let target_bbox = query.bbox.unwrap_or_default();
let target_source_map_path = query.source_map_path.unwrap_or_default();
let target_block_id = query.block_id.unwrap_or_default();
let target_paragraph_ordinal = query.paragraph_ordinal.unwrap_or_default();
let target_para_id_start = query.para_id_start.unwrap_or_default();
let target_para_id_end = query.para_id_end.unwrap_or_default();
let target_text_fingerprint = query.text_fingerprint.unwrap_or_default();
let target_evidence_text = query.evidence_text.unwrap_or_default();
let target_search_query = query.search_query.unwrap_or_default();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{title}</title>
<style>
:root {{
color-scheme: light;
--mnote-office-bg: #f7f7f5;
--mnote-office-panel: #ffffff;
--mnote-office-border: #d9d9d3;
--mnote-office-text: #232321;
--mnote-office-muted: #696963;
--mnote-office-accent: #2563eb;
--mnote-preview-max-width: none;
}}
* {{ box-sizing: border-box; }}
html, body {{ margin: 0; width: 100%; min-height: 100%; background: var(--mnote-office-bg); color: var(--mnote-office-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.mnote-office-preview {{ min-height: 100vh; }}
.mnote-office-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 12px; overflow: auto; }}
.mnote-office-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-office-border); border-radius: 8px; background: var(--mnote-office-panel); color: var(--mnote-office-muted); font-size: 14px; line-height: 1.6; }}
.mnote-office-sheet-tabs {{ display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 10px; }}
.mnote-office-sheet-tab {{ min-height: 30px; padding: 0 10px; border: 1px solid var(--mnote-office-border); border-radius: 6px; background: #fff; cursor: pointer; }}
.mnote-office-sheet-tab[aria-selected="true"] {{ border-color: var(--mnote-office-accent); color: var(--mnote-office-accent); }}
.mnote-office-table-wrap {{ width: 100%; overflow: auto; border: 1px solid var(--mnote-office-border); background: #fff; }}
.mnote-office-table {{ width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; }}
.mnote-office-table th, .mnote-office-table td {{ max-width: 360px; padding: 6px 8px; border: 1px solid #ecece7; text-align: left; vertical-align: top; white-space: pre-wrap; }}
.mnote-office-table th {{ position: sticky; top: 0; background: #f2f2ed; color: var(--mnote-office-muted); font-weight: 600; }}
.mnote-office-viewer .docx-wrapper,
.mnote-office-viewer .mnote-docx-wrapper {{ width: 100%; background: transparent !important; padding: 0 !important; align-items: stretch !important; }}
.mnote-office-viewer .docx-wrapper > section.docx,
.mnote-office-viewer .mnote-docx-wrapper > section.mnote-docx {{ width: 100% !important; max-width: none !important; margin: 0 0 14px !important; box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
.mnote-office-viewer {{ position: relative; }}
.mnote-office-viewer [data-mnote-office-evidence-target="true"] {{ outline: 0; border-radius: 2px; background: #FFE9E6; color: #D83A32; box-shadow: 0 0 0 1px rgba(216, 58, 50, .22); }}
.mnote-office-evidence-marker {{ position: absolute; z-index: 3; left: 24px; max-width: min(720px, calc(100% - 48px)); padding: 6px 10px; border: 1px solid rgba(216, 58, 50, .45); border-radius: 6px; background: rgba(255, 249, 248, .96); color: #D83A32; font-size: 13px; line-height: 1.5; box-shadow: 0 2px 10px rgba(15, 23, 42, .12); }}
.mnote-office-evidence-marker[data-mnote-office-evidence-marker-mode="range"] {{ pointer-events: none; background: rgba(255, 233, 230, .72); box-shadow: 0 0 0 1px rgba(216, 58, 50, .28); }}
.mnote-office-pptx-stage {{ width: 100%; overflow: auto; }}
.mnote-office-pptx-stage .pptx-preview-wrapper {{ max-width: 100%; background: transparent !important; }}
.mnote-office-pptx-stage .pptx-preview-slide-wrapper {{ box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
@media (max-width: 640px) {{
.mnote-office-viewer {{ padding: 6px; }}
}}
</style>
</head>
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}" data-evidence-paragraph-ordinal="{target_paragraph_ordinal}" data-evidence-para-id-start="{target_para_id_start}" data-evidence-para-id-end="{target_para_id_end}" data-evidence-text-fingerprint="{target_text_fingerprint}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
<main class="mnote-office-preview">
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
</main>
<script src="/api/office-preview/vendor/jszip.min.js"></script>
<script src="/api/office-preview/vendor/docx-preview.min.js"></script>
<script src="/api/office-preview/vendor/xlsx.full.min.js"></script>
<script src="/api/office-preview/vendor/pptx-preview.umd.js"></script>
<script>
const body = document.body;
const viewer = document.getElementById('mnote-office-viewer');
const fileUrl = body.dataset.fileUrl || '';
const fileName = body.dataset.fileName || '';
const fileType = (body.dataset.fileType || '').toLowerCase();
const pageWidthContentType = body.dataset.pageWidthContentType || 'word';
let evidencePage = Number(body.dataset.evidencePage || 0);
let evidenceBbox = body.dataset.evidenceBbox || '';
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
let evidenceBlockId = body.dataset.evidenceBlockId || '';
let evidenceParagraphOrdinal = body.dataset.evidenceParagraphOrdinal || '';
let evidenceParaIdStart = body.dataset.evidenceParaIdStart || '';
let evidenceParaIdEnd = body.dataset.evidenceParaIdEnd || '';
let evidenceTextFingerprint = body.dataset.evidenceTextFingerprint || '';
let evidenceText = body.dataset.evidenceText || '';
let evidenceSearchQuery = body.dataset.evidenceSearchQuery || '';
let currentPptxBuffer = null;
let pptxRenderToken = 0;
let pptxResizeTimer = 0;
function previewCssMaxWidth(mode) {{
if (mode === 'readable') return '760px';
if (mode === 'comfortable') return '980px';
if (mode === 'wide') return '1180px';
if (mode === 'full') return 'none';
return pageWidthContentType === 'excel' ? 'none' : '1180px';
}}
function applyPreviewWidthPreference(preferences) {{
const table = preferences && typeof preferences === 'object' ? preferences : {{}};
const fallbackMode = pageWidthContentType === 'excel' ? 'full' : 'wide';
const preference = table[pageWidthContentType] && typeof table[pageWidthContentType] === 'object'
? table[pageWidthContentType]
: {{ resolvedMode: fallbackMode, cssMaxWidth: previewCssMaxWidth(fallbackMode), source: 'system' }};
const resolvedMode = String(preference.resolvedMode || preference.resolved_mode || preference.mode || fallbackMode);
const cssMaxWidth = String(preference.cssMaxWidth || preference.css_max_width || previewCssMaxWidth(resolvedMode));
document.documentElement.setAttribute('data-page-width-content-type', pageWidthContentType);
document.documentElement.setAttribute('data-page-width-resolved-mode', resolvedMode);
document.documentElement.setAttribute('data-page-width-source', String(preference.source || 'system'));
document.documentElement.style.setProperty('--mnote-preview-max-width', cssMaxWidth === 'none' ? 'none' : cssMaxWidth);
if (viewer) viewer.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
}}
async function loadPreviewWidthPreferences() {{
applyPreviewWidthPreference(null);
try {{
const params = new URLSearchParams();
if (body.dataset.workspaceId) params.set('workspaceId', body.dataset.workspaceId);
if (body.dataset.mnoteSourceKind) params.set('sourceKind', body.dataset.mnoteSourceKind);
if (body.dataset.mnoteRootUri) params.set('rootUri', body.dataset.mnoteRootUri);
if (body.dataset.documentId) params.set('documentId', body.dataset.documentId);
const response = await fetch('/api/ui/preferences/effective' + (params.toString() ? '?' + params.toString() : ''), {{
credentials: 'same-origin',
headers: {{ accept: 'application/json' }}
}});
const payload = await response.json().catch(() => null);
if (response.ok && payload && payload.ok === true) {{
applyPreviewWidthPreference(payload.result && payload.result.pageWidthPreferences);
}}
}} catch (_) {{}}
}}
function setStatus(text) {{
document.documentElement.setAttribute('data-mnote-office-preview-status', text || '');
}}
function fileReadErrorMessage(status) {{
if (status === 401 || status === 403) {{
return '文件读取被拒绝,请先登录并确认已授权当前本地文件夹。';
}}
return '文件读取失败:' + status;
}}
function showMessage(text) {{
if (!viewer) return;
viewer.replaceChildren();
const message = document.createElement('div');
message.className = 'mnote-office-message';
message.textContent = text;
viewer.append(message);
}}
function normalizeEvidenceText(value) {{
return String(value || '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/[#*_`~>\[\](){{}}]+/g, ' ')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.replace(/\s+/g, ' ')
.trim();
}}
function markEvidenceTarget(target) {{
if (!(target instanceof HTMLElement)) return false;
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-office-evidence-target');
}});
target.setAttribute('data-mnote-office-evidence-target', 'true');
window.setTimeout(() => target.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
document.documentElement.setAttribute('data-mnote-office-evidence-applied', 'true');
return true;
}}
function normalizedTextWithRawOffsets(value) {{
const raw = String(value || '');
let text = '';
const offsets = [];
let previousWhitespace = true;
for (let index = 0; index < raw.length; index += 1) {{
const ch = raw[index];
if (/\s/.test(ch)) {{
if (text && !previousWhitespace) {{
text += ' ';
offsets.push(index);
}}
previousWhitespace = true;
}} else {{
text += ch;
offsets.push(index);
previousWhitespace = false;
}}
}}
if (text.endsWith(' ')) {{
text = text.slice(0, -1);
offsets.pop();
}}
return {{ text, offsets }};
}}
function compactTextWithRawOffsets(value) {{
const raw = String(value || '');
let text = '';
const offsets = [];
for (let index = 0; index < raw.length; index += 1) {{
const ch = raw[index];
if (/\s/.test(ch)) continue;
text += ch;
offsets.push(index);
}}
return {{ text, offsets }};
}}
function wrapEvidenceTextNode(node, needle) {{
if (!(node instanceof Text)) return null;
const raw = String(node.textContent || '');
let start = raw.indexOf(needle);
let end = start >= 0 ? start + needle.length : -1;
if (start < 0) {{
const compact = compactTextWithRawOffsets(raw);
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
let normalizedStart = compact.text.indexOf(compactNeedle);
let sourceOffsets = compact.offsets;
if (normalizedStart < 0) {{
const mapped = normalizedTextWithRawOffsets(raw);
const mappedNeedle = normalizeEvidenceText(needle);
normalizedStart = mapped.text.indexOf(mappedNeedle);
sourceOffsets = mapped.offsets;
if (normalizedStart < 0) return null;
start = sourceOffsets[normalizedStart];
end = sourceOffsets[normalizedStart + mappedNeedle.length - 1] + 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const span = document.createElement('span');
span.setAttribute('data-mnote-office-evidence-target', 'true');
try {{
range.surroundContents(span);
return span;
}} catch (_) {{
return null;
}}
}}
if (normalizedStart < 0) return null;
start = sourceOffsets[normalizedStart];
end = sourceOffsets[normalizedStart + compactNeedle.length - 1] + 1;
}}
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const span = document.createElement('span');
span.setAttribute('data-mnote-office-evidence-target', 'true');
try {{
range.surroundContents(span);
return span;
}} catch (_) {{
return null;
}}
}}
function markEvidenceRangeAcrossTextNodes(needle) {{
if (!viewer) return false;
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
if (!compactNeedle) return false;
if (compactNeedle.length > 160) return false;
const refs = [];
let compactText = '';
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {{
const raw = String(node.textContent || '');
for (let offset = 0; offset < raw.length; offset += 1) {{
const ch = raw[offset];
if (/\s/.test(ch)) continue;
compactText += ch;
refs.push({{ node, offset }});
}}
node = walker.nextNode();
}}
const startIndex = compactText.indexOf(compactNeedle);
if (startIndex < 0) return false;
const endIndex = startIndex + compactNeedle.length - 1;
const startRef = refs[startIndex];
const endRef = refs[endIndex];
if (!startRef || !endRef) return false;
const range = document.createRange();
range.setStart(startRef.node, startRef.offset);
range.setEnd(endRef.node, endRef.offset + 1);
const rect = range.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
const rects = Array.from(range.getClientRects()).filter(item => item && item.width > 0 && item.height > 0);
if (rects.length > 6 || rect.height > Math.min(140, window.innerHeight * 0.35)) return false;
const paragraphTarget = startRef.node?.parentElement?.closest('p, li, td, th, blockquote');
if (paragraphTarget instanceof HTMLElement && normalizeEvidenceText(paragraphTarget.textContent).length < 4000) {{
return markEvidenceTarget(paragraphTarget);
}}
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
if (!(marker instanceof HTMLElement)) {{
marker = document.createElement('div');
marker.className = 'mnote-office-evidence-marker';
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
viewer.append(marker);
}}
const viewerRect = viewer.getBoundingClientRect();
marker.textContent = '';
marker.setAttribute('data-mnote-office-evidence-target-text', normalizeEvidenceText(needle));
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'range');
marker.style.left = Math.max(0, Math.round(rect.left - viewerRect.left + viewer.scrollLeft)).toString() + 'px';
marker.style.top = Math.max(0, Math.round(rect.top - viewerRect.top + viewer.scrollTop)).toString() + 'px';
marker.style.width = Math.max(8, Math.round(rect.width)).toString() + 'px';
marker.style.height = Math.max(8, Math.round(rect.height)).toString() + 'px';
marker.style.maxWidth = 'none';
marker.style.padding = '0';
return markEvidenceTarget(marker);
}}
function pageForEvidenceBlock(sourceMap, block) {{
if (!sourceMap || typeof sourceMap !== 'object' || !block) return null;
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
for (const page of pages) {{
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
if (blocks.includes(block)) return page;
}}
return null;
}}
function findEvidenceBlockInSourceMap(sourceMap) {{
if (!sourceMap || typeof sourceMap !== 'object' || !evidenceBlockId) return null;
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
for (const page of pages) {{
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
const block = blocks.find(item => String(item && (item.id || item.blockId || item.block_id) || '') === evidenceBlockId);
if (block) return block;
}}
return null;
}}
async function fetchEvidenceSourceMap() {{
if (!evidenceSourceMapPath || !body.dataset.mnoteRootUri) return null;
const params = new URLSearchParams();
params.set('rootUri', body.dataset.mnoteRootUri);
params.set('path', evidenceSourceMapPath);
const response = await fetch('/api/local-folder/files/open?' + params.toString(), {{
credentials: 'same-origin',
headers: {{ accept: 'application/json, text/plain, */*' }}
}});
if (!response.ok) return null;
return response.json().catch(() => null);
}}
function scrollToEvidenceText(text) {{
const needle = normalizeEvidenceText(text);
if (!needle || !viewer) return false;
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
if (node instanceof HTMLElement) {{
if (node.tagName === 'SPAN' && node.childNodes.length === 1 && node.firstChild instanceof Text) {{
node.replaceWith(node.firstChild);
}} else {{
node.removeAttribute('data-mnote-office-evidence-target');
}}
}}
}});
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {{
if (normalizeEvidenceText(node.textContent).includes(needle)) {{
const target = node.parentElement && node.parentElement.closest('p, div, span, table, section') || node.parentElement;
if (target instanceof HTMLElement) {{
const rect = target.getBoundingClientRect();
if (rect.height > window.innerHeight * 1.8 || normalizeEvidenceText(target.textContent).length > 4000) break;
if (needle.length < 20) {{
const paragraphTarget = node.parentElement && node.parentElement.closest('p, li, td, th, blockquote') || target;
if (paragraphTarget instanceof HTMLElement) return markEvidenceTarget(paragraphTarget);
}}
}}
const inlineTarget = wrapEvidenceTextNode(node, needle);
if (inlineTarget) return markEvidenceTarget(inlineTarget);
return markEvidenceTarget(target);
}}
node = walker.nextNode();
}}
if (markEvidenceRangeAcrossTextNodes(needle)) return true;
return false;
}}
function evidenceTextCandidates(text) {{
const raw = String(text || '');
const cleaned = raw
.replace(/<[^>]+>/g, ' ')
.replace(/[#*_`~>\[\](){{}}]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const specific = [];
function pushSpecificEvidenceTerm(term) {{
let value = normalizeEvidenceText(term);
if (!value || value.length < 3) return;
const anchor = value.search(/[一二三四五六七八九十甲乙丙丁戊己庚辛壬癸叔仲异正特苯][\u3400-\u9fffA-Za-z0-9()()\\-]{{0,18}}硅/);
if (anchor > 0) value = value.slice(anchor);
value = value.split(/[::。;;,\n]/)[0];
if (value.length < 3 || value.length > 48) return;
specific.push(value);
if (/[基酯醚]$/.test(value) && value.length > 3) specific.push(value.slice(0, -1));
}}
const withoutTags = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const siliconTerms = cleaned.match(/[\u3400-\u9fffA-Za-z0-9()()\\-]{{0,24}}硅(?:基|酯|醚)?/g) || [];
siliconTerms.forEach(pushSpecificEvidenceTerm);
const cjkTerms = cleaned.match(/[\u3400-\u9fff][\u3400-\u9fffA-Za-z0-9()()\\-]{{2,48}}/g) || [];
const fallbackTerms = [];
cjkTerms.forEach(term => {{
const value = normalizeEvidenceText(term);
if (value.length < 3 || /^参考文献$/.test(value) || /^保护$/.test(value)) return;
pushSpecificEvidenceTerm(value);
const rawIndex = withoutTags.indexOf(value);
if (rawIndex >= 0) {{
const rawWindow = withoutTags.slice(rawIndex, rawIndex + value.length + 36).split(/[::。;;,\n]/)[0];
specific.push(normalizeEvidenceText(rawWindow));
}}
const cleanedIndex = cleaned.indexOf(value);
if (cleanedIndex >= 0) {{
const cleanedWindow = cleaned.slice(cleanedIndex, cleanedIndex + value.length + 36).split(/[::。;;,\n]/)[0];
specific.push(normalizeEvidenceText(cleanedWindow));
}}
if (value.endsWith('基') && value.length > 3) specific.push(value.slice(0, -1));
fallbackTerms.push(value);
}});
const candidates = [];
cleaned.split(/[。;;,\n]/).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value.length >= 8) candidates.push(value);
if (value.length >= 28) candidates.push(value.slice(0, 28));
}});
candidates.push(...fallbackTerms);
candidates.push(cleaned, raw);
const seen = new Set();
return specific.concat(candidates)
.map(normalizeEvidenceText)
.filter(value => value.length >= 3 && !seen.has(value) && seen.add(value))
.sort((left, right) => right.length - left.length);
}}
function compactEvidenceText(value) {{
return normalizeEvidenceText(value).replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
}}
function compactEvidenceTextWithoutNumbers(value) {{
return normalizeEvidenceText(value).replace(/[0-9-]+/g, '').replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
}}
function evidenceLeadingAnchors(text) {{
const raw = String(text || '');
const cleaned = normalizeEvidenceText(raw);
const anchors = [];
const queryCompact = compactEvidenceText(evidenceSearchQuery);
function push(value, options) {{
const normalized = normalizeEvidenceText(value);
const allowShort = options && options.allowShort === true;
if (normalized.length < (allowShort ? 3 : 6)) return;
anchors.push(normalized.length > 80 ? normalized.slice(0, 80) : normalized);
if (normalized.length > 24) anchors.push(normalized.slice(0, 24));
}}
function pushQueryNearPrefix(value) {{
const normalized = normalizeEvidenceText(value);
if (!normalized || !queryCompact || !compactEvidenceText(normalized).includes(queryCompact)) return;
push(normalized, {{ allowShort: true }});
const queryIndex = compactEvidenceText(normalized).indexOf(queryCompact);
if (queryIndex >= 0 && normalized.length > 24) push(normalized.slice(0, 36), {{ allowShort: true }});
}}
const prefixWindow = cleaned.slice(0, 260);
if (queryCompact.length >= 2) {{
const prefixCompact = compactEvidenceText(prefixWindow);
const compactIndex = prefixCompact.indexOf(queryCompact);
if (compactIndex >= 0) {{
const queryIndex = prefixWindow.indexOf(evidenceSearchQuery);
const start = queryIndex >= 0 ? queryIndex : 0;
const queryWindow = prefixWindow.slice(start, start + 96).split(/[。;;]/)[0];
push(queryWindow, {{ allowShort: true }});
queryWindow.split(/[,]/).slice(0, 2).forEach(part => push(part, {{ allowShort: true }}));
}}
}}
const catalogMatches = prefixWindow.match(/[^,。;;#]{{2,56}}[,]\s*[0-9-]{{1,5}}/g) || [];
catalogMatches.slice(0, 8).forEach(match => {{
const value = normalizeEvidenceText(match);
push(value, {{ allowShort: true }});
const withoutPage = value.replace(/[,]\s*[0-9-]{{1,5}}\s*$/, '');
push(withoutPage, {{ allowShort: true }});
pushQueryNearPrefix(withoutPage);
}});
const headingMatches = prefixWindow.match(/[0-9-]+(?:\.[0-9-]+){{1,5}}\s+[^。;;]{{2,72}}/g) || [];
headingMatches.slice(0, 4).forEach(match => {{
const firstPart = normalizeEvidenceText(match).split(/[,]/)[0];
push(firstPart, {{ allowShort: true }});
}});
raw.split(/[\n。;;]/).slice(0, 4).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value) push(value);
}});
cleaned.split(/[。;;]/).slice(0, 4).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value) push(value);
}});
evidenceTextCandidates(text).forEach(candidate => {{
if (!queryCompact || compactEvidenceText(candidate).includes(queryCompact)) push(candidate);
}});
const seen = new Set();
return anchors
.map(normalizeEvidenceText)
.filter(value => {{
if (!value || seen.has(value)) return false;
const compactValue = compactEvidenceText(value);
const shortQueryAnchor = queryCompact.length >= 2
&& compactValue.includes(queryCompact)
&& compactValue.length >= queryCompact.length + 1
&& /[0-9-A-Za-z]/.test(value);
if (value.length < 6 && !shortQueryAnchor) return false;
seen.add(value);
return true;
}});
}}
function shortLeadingAnchorTarget(element, elements, index) {{
const normalized = normalizeEvidenceText(element && element.textContent || '');
if (normalized.length >= 18) return element;
for (let offset = 1; offset <= 3; offset += 1) {{
const next = elements[index + offset];
if (!(next instanceof HTMLElement)) continue;
const nextText = normalizeEvidenceText(next.textContent || '');
if (nextText.length >= 18 && evidenceElementMatchesSearchQuery(next)) return next;
}}
return element;
}}
function scrollToEvidenceLeadingAnchor(text) {{
if (!viewer) return false;
const anchors = evidenceLeadingAnchors(text);
if (!anchors.length) return false;
const queryCompact = compactEvidenceText(evidenceSearchQuery);
const elements = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
.filter(node => node instanceof HTMLElement)
.filter(node => {{
const normalized = normalizeEvidenceText(node.textContent || '');
return normalized.length >= 3 && normalized.length <= 1200;
}});
for (const anchor of anchors) {{
const compactAnchor = compactEvidenceText(anchor);
const compactAnchorWithoutNumbers = compactEvidenceTextWithoutNumbers(anchor);
const shortQueryAnchor = queryCompact.length >= 2
&& compactAnchor.includes(queryCompact)
&& compactAnchor.length >= queryCompact.length + 1
&& /[0-9-A-Za-z]/.test(anchor);
if (!shortQueryAnchor && compactAnchor.length < 6 && compactAnchorWithoutNumbers.length < 6) continue;
for (const element of elements) {{
const compactElement = compactEvidenceText(element.textContent || '');
if ((compactAnchor.length >= 6 || shortQueryAnchor) && compactElement.includes(compactAnchor)) {{
return markEvidenceTarget(element);
}}
if (
compactAnchorWithoutNumbers.length >= 8
&& /[\u3400-\u9fff]/.test(anchor)
&& compactEvidenceTextWithoutNumbers(element.textContent || '').includes(compactAnchorWithoutNumbers)
) {{
return markEvidenceTarget(element);
}}
}}
}}
return false;
}}
function evidenceParagraphAnchors(text) {{
const cleaned = normalizeEvidenceText(text);
const anchors = [];
function push(value) {{
const normalized = normalizeEvidenceText(value);
if (normalized.length < 6) return;
anchors.push(normalized.length > 140 ? normalized.slice(0, 140) : normalized);
}}
evidenceTextCandidates(text).forEach(push);
cleaned.split(/[。;;,\n]/).forEach(part => {{
const value = normalizeEvidenceText(part);
if (value.length >= 10) push(value);
if (value.length >= 36) push(value.slice(0, 36));
}});
if (cleaned.length >= 24) {{
for (let index = 0; index < cleaned.length; index += 48) {{
push(cleaned.slice(index, index + 96));
}}
}}
push(cleaned);
const seen = new Set();
return anchors
.map(normalizeEvidenceText)
.filter(value => value.length >= 6 && !seen.has(value) && seen.add(value))
.sort((left, right) => right.length - left.length);
}}
function scoreEvidenceParagraphElement(element, anchors) {{
if (!(element instanceof HTMLElement)) return 0;
const text = normalizeEvidenceText(element.textContent || '');
if (!text || text.length < 3 || text.length > 6000) return 0;
const compactText = compactEvidenceText(text);
let score = 0;
for (const anchor of anchors) {{
const compactAnchor = compactEvidenceText(anchor);
if (!compactAnchor || compactAnchor.length < 4) continue;
if (text.includes(anchor)) {{
score += anchor.length * anchor.length * 4;
continue;
}}
if (compactText.includes(compactAnchor)) {{
score += compactAnchor.length * compactAnchor.length * 2;
continue;
}}
if (compactAnchor.length >= 14) {{
const prefix = compactAnchor.slice(0, Math.min(36, compactAnchor.length));
if (prefix.length >= 8 && compactText.includes(prefix)) score += prefix.length * 20;
}}
}}
return score;
}}
function evidenceElementMatchesSearchQuery(element) {{
if (!(element instanceof HTMLElement)) return false;
const compactSearchQuery = compactEvidenceText(evidenceSearchQuery);
if (compactSearchQuery.length < 2) return false;
return compactEvidenceText(element.textContent || '').includes(compactSearchQuery);
}}
function scrollToEvidenceParagraph(text) {{
if (!viewer) return false;
if (scrollToEvidenceLeadingAnchor(text)) return true;
const anchors = evidenceParagraphAnchors(text);
if (!anchors.length) return false;
const selector = 'p, li, td, th, blockquote, section.docx, section.mnote-docx, div';
const elements = Array.from(viewer.querySelectorAll(selector))
.filter(node => node instanceof HTMLElement)
.filter(node => {{
const normalized = normalizeEvidenceText(node.textContent || '');
if (normalized.length < 3 || normalized.length > 6000) return false;
const childBlocks = Array.from(node.children || []).filter(child => child instanceof HTMLElement && /^(P|LI|TD|TH|BLOCKQUOTE)$/.test(child.tagName));
return childBlocks.length === 0 || /^(TD|TH|SECTION)$/.test(node.tagName);
}});
let best = null;
let bestWithSearchQuery = null;
for (const element of elements) {{
const score = scoreEvidenceParagraphElement(element, anchors);
if (score <= 0) continue;
if (!best || score > best.score) best = {{ element, score }};
if (evidenceElementMatchesSearchQuery(element) && (!bestWithSearchQuery || score > bestWithSearchQuery.score)) {{
bestWithSearchQuery = {{ element, score }};
}}
}}
const threshold = Math.max(180, Math.min(800, anchors[0].length * 4));
if (bestWithSearchQuery && bestWithSearchQuery.score >= threshold) return markEvidenceTarget(bestWithSearchQuery.element);
if (!best) return false;
if (best.score < threshold) return false;
return markEvidenceTarget(best.element);
}}
function scrollToEvidenceTextCandidates(text) {{
for (const candidate of evidenceTextCandidates(text)) {{
if (scrollToEvidenceText(candidate)) return true;
}}
return false;
}}
function scrollToEvidenceCoordinate(sourceMap, block) {{
if (!viewer || !sourceMap || !block) return false;
const page = pageForEvidenceBlock(sourceMap, block);
const bbox = block.bbox && typeof block.bbox === 'object' ? block.bbox : null;
const pageNumber = Number(page && page.page || evidencePage || 0);
const pageCount = Math.max(1, Number(sourceMap.pageCount || (Array.isArray(sourceMap.pages) ? sourceMap.pages.length : 0)) || 1);
if (!Number.isFinite(pageNumber) || pageNumber <= 0) return false;
const renderedPages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'))
.filter(node => node instanceof HTMLElement);
const pageElement = renderedPages[pageNumber - 1];
let top = 0;
if (pageElement instanceof HTMLElement) {{
const pageHeight = Math.max(1, pageElement.scrollHeight || pageElement.getBoundingClientRect().height || 1);
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || pageHeight);
top = pageElement.offsetTop + (bbox ? (Number(bbox.y0) / sourcePageHeight) * pageHeight : pageHeight / 2);
}} else {{
const contentHeight = Math.max(1, viewer.scrollHeight || document.documentElement.scrollHeight || 1);
const estimatedPageHeight = contentHeight / pageCount;
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || estimatedPageHeight);
top = estimatedPageHeight * (pageNumber - 1) + (bbox ? (Number(bbox.y0) / sourcePageHeight) * estimatedPageHeight : estimatedPageHeight / 2);
}}
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
if (!(marker instanceof HTMLElement)) {{
marker = document.createElement('div');
marker.className = 'mnote-office-evidence-marker';
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
viewer.append(marker);
}}
marker.textContent = normalizeEvidenceText(block.text || evidenceBlockId || '命中位置');
marker.removeAttribute('data-mnote-office-evidence-target-text');
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'estimated');
marker.style.width = '';
marker.style.height = '';
marker.style.padding = '';
marker.style.top = Math.max(0, Math.round(top)).toString() + 'px';
return markEvidenceTarget(marker);
}}
function evidenceParagraphElements() {{
if (!viewer) return [];
const paragraphs = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
.filter(node => node instanceof HTMLElement)
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
if (paragraphs.length) return paragraphs;
return Array.from(viewer.querySelectorAll('div, section.docx, section.mnote-docx'))
.filter(node => node instanceof HTMLElement)
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
}}
function scrollToEvidenceParagraphOrdinal() {{
const ordinal = Number(evidenceParagraphOrdinal);
if (!Number.isFinite(ordinal) || ordinal < 0) return false;
const elements = evidenceParagraphElements();
if (!elements.length) return false;
const anchors = evidenceParagraphAnchors(evidenceText || evidenceSearchQuery);
const indices = [];
for (let offset = 0; offset <= 4; offset += 1) {{
if (offset === 0) indices.push(ordinal);
else {{
indices.push(ordinal - offset);
indices.push(ordinal + offset);
}}
}}
let best = null;
for (const index of indices) {{
if (index < 0 || index >= elements.length) continue;
const element = elements[index];
const score = anchors.length ? scoreEvidenceParagraphElement(element, anchors) : 0;
if (!best || score > best.score) best = {{ element, score }};
if (score >= 2400 && evidenceElementMatchesSearchQuery(element)) break;
}}
if (!best) return false;
if (!anchors.length) {{
return evidenceElementMatchesSearchQuery(best.element) ? markEvidenceTarget(best.element) : false;
}}
const threshold = Math.max(180, Math.min(800, anchors[0].length * 4));
if (best.score >= threshold) return markEvidenceTarget(best.element);
if (best.score > 0 && evidenceElementMatchesSearchQuery(best.element)) return markEvidenceTarget(best.element);
return false;
}}
function scrollToEvidencePageFallback() {{
if (!viewer || !Number.isFinite(evidencePage) || evidencePage <= 0) return false;
const pages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'));
const target = pages[Math.max(0, Math.min(pages.length - 1, evidencePage - 1))];
return markEvidenceTarget(target);
}}
async function applyEvidenceLocator() {{
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText && !evidenceParagraphOrdinal && !evidenceTextFingerprint)) return;
try {{
const sourceMap = await fetchEvidenceSourceMap();
const block = findEvidenceBlockInSourceMap(sourceMap);
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (block && scrollToEvidenceParagraph(block.text)) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
if (block && scrollToEvidenceTextCandidates(block.text)) return;
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
}} catch (_) {{}}
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
scrollToEvidencePageFallback();
}}
function updateEvidenceLocator(locator) {{
const next = locator && typeof locator === 'object' ? locator : {{}};
evidencePage = Number(next.page || 0);
evidenceBbox = String(next.bbox || '');
evidenceSourceMapPath = String(next.sourceMapPath || '');
evidenceBlockId = String(next.blockId || '');
evidenceParagraphOrdinal = String(next.paragraphOrdinal || '');
evidenceParaIdStart = String(next.paraIdStart || '');
evidenceParaIdEnd = String(next.paraIdEnd || '');
evidenceTextFingerprint = String(next.textFingerprint || '');
evidenceText = String(next.evidenceText || next.query || '');
evidenceSearchQuery = String(next.searchQuery || '');
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
body.dataset.evidenceBbox = evidenceBbox;
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
body.dataset.evidenceBlockId = evidenceBlockId;
body.dataset.evidenceParagraphOrdinal = evidenceParagraphOrdinal;
body.dataset.evidenceParaIdStart = evidenceParaIdStart;
body.dataset.evidenceParaIdEnd = evidenceParaIdEnd;
body.dataset.evidenceTextFingerprint = evidenceTextFingerprint;
body.dataset.evidenceText = evidenceText;
body.dataset.evidenceSearchQuery = evidenceSearchQuery;
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
void applyEvidenceLocator();
}}
window.addEventListener('message', (event) => {{
if (event.origin !== window.location.origin) return;
const data = event.data && typeof event.data === 'object' ? event.data : {{}};
if (data.type === 'mnote:office-evidence-locator') updateEvidenceLocator(data);
}});
async function fetchArrayBuffer() {{
const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
return response.arrayBuffer();
}}
function csvRows(text) {{
const rows = [];
let row = [];
let cell = '';
let quote = false;
for (let index = 0; index < text.length; index += 1) {{
const ch = text[index];
if (quote) {{
if (ch === '"' && text[index + 1] === '"') {{
cell += '"';
index += 1;
}} else if (ch === '"') {{
quote = false;
}} else {{
cell += ch;
}}
}} else if (ch === '"') {{
quote = true;
}} else if (ch === ',') {{
row.push(cell);
cell = '';
}} else if (ch === '\n') {{
row.push(cell);
rows.push(row);
row = [];
cell = '';
}} else if (ch !== '\r') {{
cell += ch;
}}
}}
row.push(cell);
rows.push(row);
return rows;
}}
function renderRows(rows) {{
const wrap = document.createElement('div');
wrap.className = 'mnote-office-table-wrap';
const table = document.createElement('table');
table.className = 'mnote-office-table';
const thead = document.createElement('thead');
const trh = document.createElement('tr');
const width = Math.max(1, ...rows.slice(0, 200).map(row => row.length));
for (let col = 0; col < width; col += 1) {{
const th = document.createElement('th');
th.textContent = String.fromCharCode(65 + (col % 26));
trh.append(th);
}}
thead.append(trh);
table.append(thead);
const tbody = document.createElement('tbody');
rows.slice(0, 2000).forEach(row => {{
const tr = document.createElement('tr');
for (let col = 0; col < width; col += 1) {{
const td = document.createElement('td');
td.textContent = row[col] == null ? '' : String(row[col]);
tr.append(td);
}}
tbody.append(tr);
}});
table.append(tbody);
wrap.append(table);
if (rows.length > 2000) {{
const note = document.createElement('div');
note.className = 'mnote-office-message';
note.textContent = '当前 POC 仅显示前 2000 行,完整表格请用 OnlyOffice 打开。';
viewer.append(note);
}}
viewer.append(wrap);
}}
async function renderDocx() {{
if (!window.docx || typeof window.docx.renderAsync !== 'function') {{
throw new Error('DOCX 预览库不可用');
}}
const buffer = await fetchArrayBuffer();
viewer.replaceChildren();
const options = {{
className: 'mnote-docx',
inWrapper: true,
breakPages: true,
renderHeaders: true,
renderFooters: true
}};
try {{
await window.docx.renderAsync(buffer, viewer, null, options);
}} catch (error) {{
if (!String(error && error.message || '').includes("reading 'type'")) {{
throw error;
}}
viewer.replaceChildren();
document.documentElement.setAttribute('data-mnote-office-preview-footnotes-disabled', 'true');
await window.docx.renderAsync(buffer.slice(0), viewer, null, {{
...options,
renderFootnotes: false,
renderEndnotes: false
}});
}}
}}
async function renderWorkbook() {{
if (!window.XLSX || typeof window.XLSX.read !== 'function') {{
throw new Error('XLSX 预览库不可用');
}}
const buffer = await fetchArrayBuffer();
const workbook = window.XLSX.read(new Uint8Array(buffer), {{ type: 'array' }});
viewer.replaceChildren();
const tabs = document.createElement('div');
tabs.className = 'mnote-office-sheet-tabs';
const renderSheet = name => {{
viewer.querySelector('.mnote-office-table-wrap')?.remove();
tabs.querySelectorAll('button').forEach(button => button.setAttribute('aria-selected', button.dataset.sheet === name ? 'true' : 'false'));
const rows = window.XLSX.utils.sheet_to_json(workbook.Sheets[name], {{ header: 1, raw: false, blankrows: false }});
renderRows(rows.length ? rows : [['空工作表']]);
}};
workbook.SheetNames.forEach((name, index) => {{
const button = document.createElement('button');
button.className = 'mnote-office-sheet-tab';
button.type = 'button';
button.dataset.sheet = name;
button.textContent = name;
button.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
button.addEventListener('click', () => renderSheet(name));
tabs.append(button);
}});
viewer.append(tabs);
renderSheet(workbook.SheetNames[0]);
}}
async function renderCsv() {{
const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
viewer.replaceChildren();
renderRows(csvRows(await response.text()));
}}
async function renderPptx() {{
if (!window.pptxPreview || typeof window.pptxPreview.init !== 'function') {{
throw new Error('PPTX 预览库不可用');
}}
const token = ++pptxRenderToken;
const buffer = currentPptxBuffer || await fetchArrayBuffer();
currentPptxBuffer = buffer;
viewer.replaceChildren();
const stage = document.createElement('div');
stage.className = 'mnote-office-pptx-stage';
viewer.append(stage);
const width = Math.max(320, Math.floor(stage.clientWidth || viewer.clientWidth || window.innerWidth));
const height = Math.round(width * 9 / 16);
const previewer = window.pptxPreview.init(stage, {{
width,
height,
mode: 'list'
}});
const result = await previewer.preview(buffer);
if (token !== pptxRenderToken) return;
document.documentElement.setAttribute('data-mnote-office-pptx-slide-count', String(result && result.slides ? result.slides.length : ''));
}}
async function refreshPreviewWidth() {{
await loadPreviewWidthPreferences();
if (fileType === 'pptx' && currentPptxBuffer) await renderPptx();
}}
window.addEventListener('message', (event) => {{
if (event.origin !== window.location.origin) return;
const data = event.data && typeof event.data === 'object' ? event.data : {{}};
if (data.type === 'mnote:page-width-preference-changed') void refreshPreviewWidth();
}});
window.addEventListener('resize', () => {{
if (fileType !== 'pptx' || !currentPptxBuffer) return;
if (pptxResizeTimer) window.clearTimeout(pptxResizeTimer);
pptxResizeTimer = window.setTimeout(() => {{
pptxResizeTimer = 0;
void renderPptx();
}}, 160);
}});
async function main() {{
if (!fileUrl) {{
showMessage('Office 文件链接不可用');
return;
}}
setStatus('加载中');
showMessage('正在打开轻量预览...');
try {{
await loadPreviewWidthPreferences();
if (fileType === 'docx') await renderDocx();
else if (fileType === 'xlsx' || fileType === 'xls') await renderWorkbook();
else if (fileType === 'csv') await renderCsv();
else if (fileType === 'pptx') await renderPptx();
else showMessage('当前轻量预览 POC 暂不支持 .' + fileType + ',请用 OnlyOffice 打开。');
await applyEvidenceLocator();
setStatus('完成');
}} catch (error) {{
console.warn('[mnote office preview] render failed', error);
setStatus('打开失败');
showMessage(error && error.message ? error.message : 'Office 轻量预览失败,请用 OnlyOffice 打开。');
}}
}}
main();
</script>
</body>
</html>"#,
title = escape_html(file_name),
file_url = escape_html(&file_url),
file_name = escape_html(file_name),
file_type = escape_html(&file_type),
page_width_content_type = escape_html(page_width_content_type),
workspace_id = escape_html(&workspace_id),
source_kind = escape_html(&source_kind),
root_uri = escape_html(&root_uri),
document_id = escape_html(&document_id),
target_page = escape_html(&target_page),
target_bbox = escape_html(&target_bbox),
target_source_map_path = escape_html(&target_source_map_path),
target_block_id = escape_html(&target_block_id),
target_paragraph_ordinal = escape_html(&target_paragraph_ordinal),
target_para_id_start = escape_html(&target_para_id_start),
target_para_id_end = escape_html(&target_para_id_end),
target_text_fingerprint = escape_html(&target_text_fingerprint),
target_evidence_text = escape_html(&target_evidence_text),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "office-preview");
response
}
fn office_preview_page_width_content_type(file_type: &str) -> &'static str {
match file_type.trim().to_ascii_lowercase().as_str() {
"xls" | "xlsx" | "ods" | "csv" => "excel",
"ppt" | "pptx" | "odp" => "ppt",
_ => "word",
}
}
pub async fn office_preview_vendor_asset(
Path(asset_path): Path<String>,
) -> Result<Response, WebError> {
let asset_path = asset_path.trim();
let relative = match asset_path {
"jszip.min.js" => "node_modules/jszip/dist/jszip.min.js",
"docx-preview.min.js" => "node_modules/docx-preview/dist/docx-preview.min.js",
"xlsx.full.min.js" => "node_modules/@e965/xlsx/dist/xlsx.full.min.js",
"pptx-preview.umd.js" => "node_modules/pptx-preview/dist/pptx-preview.umd.js",
_ => {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"office_preview_vendor_asset_not_found",
format!("Office preview vendor asset 不存在: {asset_path}"),
));
}
};
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(relative);
let bytes = std::fs::read(&path).map_err(|error| {
WebError::new(
StatusCode::SERVICE_UNAVAILABLE,
"office_preview_vendor_asset_unavailable",
format!("Office preview vendor asset 不可用,请先运行 npm install: {error}"),
)
})?;
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(Body::from(bytes))
.map_err(|error| {
WebError::internal(format!("Office preview vendor asset 响应构造失败: {error}"))
})
}
pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response {
let file_url = query.file_url.unwrap_or_default();
let file_name = query
.file_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("PDF 预览");
let target_page = query.page.unwrap_or_default();
let target_bbox = query.bbox.unwrap_or_default();
let target_block_id = query.block_id.unwrap_or_default();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{title}</title>
<style>
:root {{
color-scheme: light;
--mnote-pdf-bg: #f7f7f5;
--mnote-pdf-panel: #ffffff;
--mnote-pdf-border: #d8d8d2;
--mnote-pdf-text: #242422;
--mnote-pdf-muted: #6b6b64;
--mnote-preview-max-width: 1180px;
}}
* {{ box-sizing: border-box; }}
html, body {{ margin: 0; min-height: 100%; background: var(--mnote-pdf-bg); color: var(--mnote-pdf-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.mnote-pdf-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 8px 12px 28px; }}
.mnote-pdf-page {{ display: block; max-width: 100%; margin: 0 auto 14px; background: #fff; border: 1px solid var(--mnote-pdf-border); box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
.mnote-pdf-page[data-mnote-evidence-page="true"] {{ outline: 2px solid #2563eb; outline-offset: 2px; }}
.mnote-pdf-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-pdf-border); border-radius: 8px; background: var(--mnote-pdf-panel); color: var(--mnote-pdf-muted); font-size: 14px; line-height: 1.6; }}
@media (max-width: 640px) {{
.mnote-pdf-viewer {{ padding: 0 6px 18px; }}
.mnote-pdf-page {{ margin-bottom: 8px; }}
}}
</style>
</head>
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-block-id="{target_block_id}">
<main class="mnote-pdf-viewer" id="mnote-pdf-viewer"></main>
<script type="module">
import * as pdfjsLib from '/api/pdfjs/pdf.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/api/pdfjs/pdf.worker.mjs';
const body = document.body;
const viewer = document.getElementById('mnote-pdf-viewer');
const fileUrl = body.dataset.fileUrl || '';
const pageWidthContentType = 'pdf';
const evidencePage = Number(body.dataset.evidencePage || 0);
const evidenceBBox = parseEvidenceBBox(body.dataset.evidenceBbox || '');
const activeRenderTasks = new Set();
let pdfDocument = null;
let disposed = false;
function parseEvidenceBBox(value) {{
const parts = String(value || '').split(',').map((item) => Number(item.trim()));
if (parts.length < 4 || parts.slice(0, 4).some((item) => !Number.isFinite(item))) return null;
return {{ x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }};
}}
function previewCssMaxWidth(mode) {{
if (mode === 'readable') return '760px';
if (mode === 'comfortable') return '980px';
if (mode === 'wide') return '1180px';
if (mode === 'full') return 'none';
return '1180px';
}}
function applyPreviewWidthPreference(preferences) {{
const table = preferences && typeof preferences === 'object' ? preferences : {{}};
const preference = table.pdf && typeof table.pdf === 'object'
? table.pdf
: {{ resolvedMode: 'wide', cssMaxWidth: '1180px', source: 'system' }};
const resolvedMode = String(preference.resolvedMode || preference.resolved_mode || preference.mode || 'wide');
const cssMaxWidth = String(preference.cssMaxWidth || preference.css_max_width || previewCssMaxWidth(resolvedMode));
document.documentElement.setAttribute('data-page-width-content-type', pageWidthContentType);
document.documentElement.setAttribute('data-page-width-resolved-mode', resolvedMode);
document.documentElement.setAttribute('data-page-width-source', String(preference.source || 'system'));
document.documentElement.style.setProperty('--mnote-preview-max-width', cssMaxWidth === 'none' ? 'none' : cssMaxWidth);
if (viewer) viewer.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
}}
async function loadPreviewWidthPreferences() {{
applyPreviewWidthPreference(null);
try {{
const response = await fetch('/api/ui/preferences/effective', {{
credentials: 'same-origin',
headers: {{ accept: 'application/json' }}
}});
const payload = await response.json().catch(() => null);
if (response.ok && payload && payload.ok === true) {{
applyPreviewWidthPreference(payload.result && payload.result.pageWidthPreferences);
}}
}} catch (_) {{}}
}}
function setStatus(text) {{
document.documentElement.setAttribute('data-mnote-pdf-status', text || '');
}}
function showMessage(text) {{
if (!viewer) return;
viewer.replaceChildren();
const message = document.createElement('div');
message.className = 'mnote-pdf-message';
message.textContent = text;
viewer.append(message);
}}
async function renderPage(pdf, pageNumber) {{
if (disposed || !pdf || pdf !== pdfDocument) return;
const page = await pdf.getPage(pageNumber);
if (disposed || pdf !== pdfDocument) return;
const baseViewport = page.getViewport({{ scale: 1 }});
const availableWidth = Math.max(280, (viewer ? viewer.clientWidth : window.innerWidth) - 20);
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
const viewport = page.getViewport({{ scale }});
const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1));
const canvas = document.createElement('canvas');
canvas.className = 'mnote-pdf-page';
canvas.setAttribute('data-page-number', String(pageNumber));
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
const context = canvas.getContext('2d', {{ alpha: false }});
if (!context) return;
if (disposed || pdf !== pdfDocument) return;
const renderTask = page.render({{
canvasContext: context,
viewport,
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
}});
activeRenderTasks.add(renderTask);
try {{
await renderTask.promise;
}} finally {{
activeRenderTasks.delete(renderTask);
}}
if (disposed || pdf !== pdfDocument) return;
if (viewer) viewer.append(canvas);
if (evidencePage === pageNumber) {{
canvas.setAttribute('data-mnote-evidence-page', 'true');
let evidenceTargetY = null;
if (evidenceBBox) {{
const normalizedMineruBox = evidenceBBox.x0 >= 0 && evidenceBBox.y0 >= 0 && evidenceBBox.x1 <= 1000 && evidenceBBox.y1 <= 1000;
let x;
let y;
let width;
let height;
if (normalizedMineruBox) {{
x = Math.min(evidenceBBox.x0, evidenceBBox.x1) / 1000 * viewport.width;
y = Math.min(evidenceBBox.y0, evidenceBBox.y1) / 1000 * viewport.height;
width = Math.max(1, Math.abs(evidenceBBox.x1 - evidenceBBox.x0) / 1000 * viewport.width);
height = Math.max(1, Math.abs(evidenceBBox.y1 - evidenceBBox.y0) / 1000 * viewport.height);
}} else {{
const rect = viewport.convertToViewportRectangle([evidenceBBox.x0, evidenceBBox.y0, evidenceBBox.x1, evidenceBBox.y1]);
x = Math.min(rect[0], rect[2]);
y = Math.min(rect[1], rect[3]);
width = Math.max(1, Math.abs(rect[2] - rect[0]));
height = Math.max(1, Math.abs(rect[3] - rect[1]));
}}
context.save();
context.scale(outputScale, outputScale);
context.fillStyle = 'rgba(37, 99, 235, 0.18)';
context.strokeStyle = 'rgba(37, 99, 235, 0.9)';
context.lineWidth = 2;
context.fillRect(x, y, width, height);
context.strokeRect(x, y, width, height);
context.restore();
evidenceTargetY = y + height / 2;
}}
window.setTimeout(() => {{
if (Number.isFinite(evidenceTargetY)) {{
const rect = canvas.getBoundingClientRect();
const absoluteTargetTop = rect.top + window.scrollY + evidenceTargetY;
window.scrollTo({{ top: Math.max(0, absoluteTargetTop - window.innerHeight / 2), behavior: 'auto' }});
return;
}}
canvas.scrollIntoView({{ block: 'center', inline: 'nearest' }});
}}, 0);
}}
}}
function disposePreview() {{
disposed = true;
for (const task of Array.from(activeRenderTasks)) {{
try {{ task.cancel(); }} catch (_) {{}}
}}
activeRenderTasks.clear();
const doomedDocument = pdfDocument;
if (doomedDocument && typeof doomedDocument.destroy === 'function') {{
try {{ void doomedDocument.destroy(); }} catch (_) {{}}
}}
pdfDocument = null;
}}
window.__mnotePdfPreviewDispose = disposePreview;
window.addEventListener('pagehide', () => {{
void disposePreview();
}}, {{ once: true }});
async function main() {{
disposed = false;
if (!fileUrl) {{
setStatus('不可用');
showMessage('PDF 链接不可用');
return;
}}
try {{
await loadPreviewWidthPreferences();
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(location.origin);
const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }}).promise;
pdfDocument = pdf;
if (viewer) viewer.replaceChildren();
setStatus('0 / ' + pdf.numPages);
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {{
if (disposed || pdf !== pdfDocument) return;
await renderPage(pdf, pageNumber);
setStatus(pageNumber + ' / ' + pdf.numPages);
}}
}} catch (error) {{
console.warn('[mnote pdf preview] render failed', error);
setStatus('打开失败');
showMessage(error && error.message ? error.message : 'PDF 打开失败');
}}
}}
main();
</script>
</body>
</html>"#,
title = escape_html(file_name),
file_url = escape_html(&file_url),
file_name = escape_html(file_name),
target_page = target_page,
target_bbox = escape_html(&target_bbox),
target_block_id = escape_html(&target_block_id),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "pdf-preview");
response
}
pub async fn pdfjs_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
let asset_path = asset_path.trim();
let relative = match asset_path {
"pdf.mjs" => "build/pdf.mjs",
"pdf.worker.mjs" => "build/pdf.worker.mjs",
_ => {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"pdfjs_asset_not_found",
format!("PDF.js asset 不存在: {asset_path}"),
));
}
};
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join("node_modules/pdfjs-dist")
.join(relative);
let bytes = std::fs::read(&path).map_err(|error| {
WebError::new(
StatusCode::SERVICE_UNAVAILABLE,
"pdfjs_asset_unavailable",
format!("PDF.js asset 不可用,请先运行 npm install: {error}"),
)
})?;
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(Body::from(bytes))
.map_err(|error| WebError::internal(format!("PDF.js asset 响应构造失败: {error}")))
}
pub async fn resource_open_runtime_asset() -> Response {
// include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/)
const JS: &str = include_str!("../../browser/resource-open-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 mnote_ui_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/mnote-ui-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 local_upload_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/local-upload-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_tree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-tree-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 agent_stream_event_router_asset() -> Response {
const JS: &str = include_str!("../../browser/agent-stream-event-router.js");
Response::builder()
.header("content-type", "application/javascript; charset=utf-8")
.header("cache-control", "public, max-age=3600")
.body(Body::from(JS))
.expect("agent-stream-event-router.js")
}
pub async fn sidebar_page_ai_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-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_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_ai_pi_lab_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-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()
.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_shell_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-shell-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_workspace_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-workspace-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_tree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-tree-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_tree_live_apply_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-tree-live-apply-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_filetree_open_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-filetree-open-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_filetree_command_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-filetree-command-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_filetree_upload_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-filetree-upload-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_attachment_open_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-attachment-open-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 filetree_keyboard_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-keyboard-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 filetree_dnd_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-dnd-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 filetree_context_menu_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-context-menu-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 filetree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-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 filetree_selection_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-selection-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 local_folder_event_bus_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/local-folder-event-bus-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 tree_live_controller_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-live-controller.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 tree_shell_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-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 tree_shell_render_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-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 tree_shell_page_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-page-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 tree_shell_state_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-state-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 tree_shell_icons_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-icons-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 tree_shell_filetree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-filetree-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 tree_shell_filetree_menu_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-filetree-menu-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 tree_shell_filetree_dnd_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-filetree-dnd-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 tree_shell_picker_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-picker-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 tree_shell_dom_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-dom-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 document_conflict_panel_runtime_asset() -> Response {
// include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/)
const JS: &str = include_str!("../../browser/document-conflict-panel-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 document_pane_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-pane-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 document_mindmap_host_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-mindmap-host-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 document_resource_tab_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-resource-tab-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 document_session_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-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 document_slash_position_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-slash-position-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 document_tiptap_conversion_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-tiptap-conversion-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 document_editor_adapter_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-editor-adapter-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 vault_workbench_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/vault-workbench-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 leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
"assetPaths": [
"mnote-leptos-tiptap-spike-island.js",
"mnote-leptos-tiptap-spike-island_bg.wasm",
"tiptap_mindmap_paragraph_runtime.js"
],
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
});
let mut response = Json(manifest).into_response();
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(runtime_asset_cache_control()),
);
response
}
pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
let Some(resolved) = resolve_runtime_asset_path(&asset_path) else {
return Err(WebError::bad_request_code(
"runtime_asset_path_invalid",
"leptos-tiptap runtime asset 路径非法",
));
};
let bytes = match std::fs::read(&resolved) {
Ok(bytes) => bytes,
Err(_) => {
let Some(lazy_resolved) = resolve_lazy_runtime_asset_path(&asset_path) else {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
));
};
std::fs::read(&lazy_resolved).map_err(|_| {
WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
)
})?
}
};
let response = Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
runtime_asset_content_type(&asset_path),
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(runtime_asset_body(&asset_path, bytes))
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
Ok(response)
}
pub async fn page_aggregate(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
query.source_kind.as_deref(),
query.root_uri.as_deref(),
)
.await?;
let projection_owner = aggregate.source_label();
let mut response = (
StatusCode::OK,
Json(json!({
"ok": true,
"owner": "mnote-web",
"schema": "mnote.page_aggregate.v1",
"result": aggregate,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.into_response();
stamp_shell_headers(response.headers_mut(), "page-aggregate");
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
if let Ok(value) = HeaderValue::from_str(projection_owner) {
response.headers_mut().insert(name, value);
}
}
Ok(response)
}
pub(crate) async fn build_page_aggregate_snapshot(
state: &AppState,
context: &RequestContext,
document_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<PageAggregate, WebError> {
if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
let root_uri = root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(state, context, root_uri)
.map_err(|error| error.with_context(context))?;
let root_uri_for_build = root_uri.to_string();
let root_uri_for_preferences = root_uri_for_build.clone();
let document_id = document_id.to_string();
let mut aggregate = tokio::task::spawn_blocking(move || {
#[cfg(test)]
block_local_page_aggregate_for_test();
resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id)
})
.await
.map_err(|error| {
WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}"))
})??;
annotate_local_attachment_refs_authorization(state, context, &mut aggregate)?;
super::ui_preferences::apply_effective_page_preferences(
state,
context,
&mut aggregate,
source_kind,
Some(root_uri_for_preferences.as_str()),
)?;
return Ok(aggregate);
}
let meta = load_document_meta_result(
state,
context,
DocumentMetaQuery {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(str::to_string),
},
)
.await?;
let content = load_document_content_result(
state,
context,
DocumentContentQuery {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(str::to_string),
},
)
.await?;
let projection = execute_runtime_query_against_data(
context,
workspace_id,
RuntimeQueryEnvelopeWire {
name: "page.aggregate.get".into(),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
}),
},
json!({
"meta": meta,
"content": content,
}),
)?;
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
})
}
fn annotate_local_attachment_refs_authorization(
state: &AppState,
context: &RequestContext,
aggregate: &mut PageAggregate,
) -> Result<(), WebError> {
let Some(refs) = aggregate.body.attachment_refs.as_array_mut() else {
return Ok(());
};
for item in refs {
let Some(object) = item.as_object_mut() else {
continue;
};
let resolved_uri = object
.get("resolvedUri")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with("file://"));
let Some(resolved_uri) = resolved_uri else {
continue;
};
let kind = object
.get("kind")
.and_then(Value::as_str)
.unwrap_or_default();
let owner_root_uri = object
.get("ownerRootUri")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if kind == "pageLocal"
&& !owner_root_uri.is_empty()
&& (resolved_uri == owner_root_uri
|| resolved_uri
.strip_prefix(owner_root_uri)
.is_some_and(|suffix| suffix.starts_with('/')))
{
object.insert("authorized".to_string(), Value::Bool(true));
continue;
}
let access = state
.control_plane()
.resolve_access(&context.auth.actor_id, resolved_uri)
.map_err(|error| {
WebError::internal(format!("control-plane 附件授权解析失败: {error}"))
})?;
object.insert(
"authorized".to_string(),
Value::Bool(access.permission != "none"),
);
}
Ok(())
}
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
let value = document_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'_'
}
})
.collect::<String>();
if value.is_empty() {
return;
}
let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
headers.insert(name, HeaderValue::from_static(shell));
}
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
pub(crate) fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub(crate) fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
pub(crate) async fn load_workspace_shell_projection(
state: Option<&AppState>,
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
default_workspace_name: &str,
) -> WorkspaceShellProjection {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::SidebarTree,
query: None,
max_results: None,
};
let mut dataset = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => snapshot.dataset,
Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id
.map(|document_id| {
json!([{
"id": document_id,
"workspace_id": workspace_id,
"title": "个人",
"parent_id": null,
"sort_order": 0,
"is_starred": false
}])
})
.unwrap_or_else(|| json!([]));
json!({
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": documents,
"dev_fixture": true
})
}
Err(_) => json!({
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
"degraded": true,
"degraded_reason": "projection_unavailable"
}),
};
if let Some(state) = state {
attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset);
}
build_workspace_shell_projection(
&dataset,
workspace_id,
active_document_id,
default_workspace_name,
)
}
pub(crate) fn attach_sidebar_shortcuts_to_dataset(
state: &AppState,
context: &RequestContext,
workspace_id: &str,
dataset: &mut Value,
) {
let shortcuts = crate::routes::sidebar_shortcuts::load_sidebar_shortcut_dataset(
state,
context,
workspace_id,
);
if shortcuts.is_empty() {
return;
}
if let Some(object) = dataset.as_object_mut() {
object.insert("sidebarShortcuts".to_string(), Value::Array(shortcuts));
}
}
/// 加载侧栏页面树 HTMLSSR
///
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
/// 如果加载失败(如 cloud/compat source 不可用),返回空字符串,侧栏静默降级为无树状态。
/// 当 allow_dev_fixtures 启用且 cloud/compat source 不可用时,使用内建示例数据展示页面树。
pub(crate) async fn load_sidebar_tree_html(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
) -> Option<String> {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::SidebarTree,
query: None,
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some((snapshot.projection, false)),
Err(_) if config.allow_dev_fixtures => {
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
let documents = active_document_id
.map(|document_id| {
serde_json::json!([
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
])
})
.unwrap_or_else(|| {
serde_json::json!([
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }
])
});
let dev_dataset = serde_json::json!({
"active_workspace_id": workspace_id,
"documents": documents,
"trashed_documents": [],
"media_assets": [],
"trashed_media_assets": [],
"mindmap_assets": [],
"trashed_mindmap_assets": [],
"table_assets": [],
"trashed_table_assets": [],
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
.ok()
.map(|projection| (projection, true))
}
Err(_) => None,
};
result.map(|(projection, dev_fixture)| {
let rows = collect_page_tree_render_rows(&projection);
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
});
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
})
}
/// 加载文件树 HTMLSSR
///
/// 文件树与页面树共用同一份 sidebar dataset,再由 Rust kernel 输出 file_tree projection。
pub(crate) async fn load_file_tree_html(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
) -> Option<String> {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::FileTree,
query: None,
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some((snapshot.projection, false)),
Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id
.map(|document_id| {
serde_json::json!([
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
])
})
.unwrap_or_else(|| {
serde_json::json!([
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }
])
});
let dev_dataset = serde_json::json!({
"active_workspace_id": workspace_id,
"documents": documents,
"trashed_documents": [],
"media_assets": [],
"trashed_media_assets": [],
"mindmap_assets": [],
"trashed_mindmap_assets": [],
"table_assets": [],
"trashed_table_assets": [],
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
.ok()
.map(|projection| (projection, true))
}
Err(_) => None,
};
result.map(|(projection, dev_fixture)| {
let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id);
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
mark_dev_fixture_html(html, dev_fixture, "file-tree")
})
}
fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String {
if !dev_fixture {
return html;
}
format!(
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="{kind}"></span>{html}"#
)
}
pub(crate) fn render_local_sidebar_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
) -> Result<String, WebError> {
render_local_sidebar_tree_html_scoped(root_uri, active_document_id, None)
}
pub(crate) fn render_local_sidebar_tree_html_scoped(
root_uri: &str,
active_document_id: Option<&str>,
file_tree_scope: Option<&str>,
) -> Result<String, WebError> {
let snapshot = if let Some(scope) = file_tree_scope
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
active_document_id,
)?
} else {
load_local_folder_page_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
Ok(render_local_sidebar_tree_html_from_snapshot(
&snapshot,
active_document_id,
))
}
pub(crate) fn render_local_sidebar_tree_html_from_snapshot(
snapshot: &crate::routes::snapshot_support::ProjectionSnapshot,
active_document_id: Option<&str>,
) -> String {
let rows = collect_page_tree_render_rows(&snapshot.projection);
render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
})
}
pub(crate) fn render_local_file_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
) -> Result<String, WebError> {
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
}
/// Fast path for home SSR: keep FileTree shell structure without scanning the folder.
/// Browser hydrates rows after first paint when `data-filetree-ssr="pending"`.
pub(crate) fn render_local_file_tree_pending_shell_html() -> String {
render_filetree_pending_shell_html()
}
pub(crate) fn render_local_file_tree_html_scoped(
root_uri: &str,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
file_tree_scope: Option<&str>,
) -> Result<String, WebError> {
let snapshot = if let Some(scope) = file_tree_scope
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_file_tree_children_snapshot_with_reveal(
root_uri,
scope,
active_document_id,
)?
} else {
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
let rows =
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
}
#[cfg(test)]
fn block_local_page_aggregate_for_test() {
let delay_ms = LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
if delay_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput};
use serde_json::Value;
use std::time::{Duration, Instant};
use tower::util::ServiceExt;
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
include_str!("../../browser/document-editor-adapter-runtime.js");
const DOCUMENT_MINDMAP_HOST_RUNTIME_JS: &str =
include_str!("../../browser/document-mindmap-host-runtime.js");
const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js");
const DOCUMENT_RESOURCE_TAB_RUNTIME_JS: &str =
include_str!("../../browser/document-resource-tab-runtime.js");
const DOCUMENT_SESSION_RUNTIME_JS: &str =
include_str!("../../browser/document-session-runtime.js");
const DOCUMENT_SLASH_POSITION_RUNTIME_JS: &str =
include_str!("../../browser/document-slash-position-runtime.js");
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
include_str!("../../browser/sidebar-page-settings-runtime.js");
const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
include_str!("../../browser/document-tiptap-conversion-runtime.js");
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"updated_at": "2026-04-18T09:30:00Z",
"can_edit": true,
"word_count": 42,
"character_count": 128,
"block_count": 3
},
"documents:getContent": {
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
"editorDocument": {
"documentId": "doc_1",
"rootBlockIds": ["editor_1"],
"blocks": [{
"blockId": "editor_1",
"blockType": "paragraph",
"contentNodes": [{
"payload": {"type": "text", "text": "来自 editorDocument 的正文"},
"attrs": {}
}],
"childBlockIds": []
}]
},
"revision": 7,
"conflict_detection_key": "doc_1:7",
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
}
}"#
.into(),
),
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
.layer(axum::middleware::from_fn(inject_test_actor))
}
fn app_without_test_actor() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"updated_at": "2026-04-18T09:30:00Z",
"can_edit": true,
"word_count": 42,
"character_count": 128,
"block_count": 3
},
"documents:getContent": {
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
"revision": 7,
"conflict_detection_key": "doc_1:7",
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
}
}"#
.into(),
),
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
fn temp_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp root");
root
}
async fn inject_test_actor(
mut request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
request
.headers_mut()
.entry("x-mnote-actor-id")
.or_insert(HeaderValue::from_static("user_test"));
request
.headers_mut()
.entry("x-mnote-actor-type")
.or_insert(HeaderValue::from_static("user"));
next.run(request).await
}
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
actor_id,
&format!("file://{}", root.display()),
)
.expect("init local workspace");
}
fn request_context(actor_id: &str, actor_type: &str) -> RequestContext {
let mut headers = HeaderMap::new();
headers.insert("x-mnote-actor-id", actor_id.parse().unwrap());
headers.insert("x-mnote-actor-type", actor_type.parse().unwrap());
RequestContext::from_http_parts(
&Method::GET,
&"/api/page-aggregate/local-md:test.md".parse().expect("uri"),
&headers,
)
}
fn grant_local_workspace_read_access(
state: &AppState,
actor_id: &str,
root_uri: &str,
root: &std::path::Path,
) {
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(actor_id.into()),
email: Some(format!("{actor_id}@example.com")),
username: actor_id.into(),
display_name: actor_id.into(),
role: None,
password_hash: None,
})
.expect("upsert control-plane reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: actor_id.into(),
workspace_id: None,
root_uri: root_uri.into(),
root_path: root
.canonicalize()
.expect("canonical root")
.display()
.to_string(),
permission: "read".into(),
recursive: true,
capabilities: vec![],
source: "unit-test".into(),
created_by: None,
})
.expect("grant control-plane read");
}
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:9".into()),
convex_admin_key: Some("test-admin-key".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn document_shell_unauthenticated_redirects_to_auth_with_next() {
let response = app_without_test_actor()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/auth?next=%2Fdocuments%2Fdoc_1%3FworkspaceId%3Dws_demo")
);
}
#[tokio::test]
async fn document_shell_missing_local_markdown_redirects_to_folder_navigation() {
let root = temp_root("mnote-document-shell-missing-local-md");
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("Existing.md"), "# Existing\n")
.expect("write existing");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:docs~2FMissing.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=docs"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let location = response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(location.starts_with("/?"));
assert!(location.contains("sourceKind=local_folder"));
assert!(location.contains("fileTreeScope=docs"));
assert!(location.contains("missingPage=local-md%3Adocs%7E2FMissing.md"));
}
#[tokio::test]
async fn document_shell_local_folder_without_root_uri_redirects_to_navigation_home() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/local-md:docs~2FPlan.md?sourceKind=local_folder")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let location = response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(location.starts_with("/?"));
assert!(location.contains("routeGuard=local_folder_root_required"));
assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md"));
}
#[tokio::test]
async fn document_shell_local_markdown_without_source_kind_does_not_fall_back_to_convex() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/documents/local-md:docs~2FPlan.md?resourceTab=primary%3A%3Aresource%3Afile%3Afile%3A%2F%2F%2Ftmp%3Adocs%2FPlan.pdf")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_ne!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
let location = response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(location.starts_with("/?"));
assert!(location.contains("routeGuard=local_folder_source_required"));
assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md"));
}
#[tokio::test]
async fn document_shell_records_local_markdown_page_recent() {
let root = temp_root("mnote-document-shell-record-page-recent");
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n正文\n").expect("write plan");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("user_test".to_string()),
email: Some("user_test@example.com".to_string()),
username: "user_test".to_string(),
display_name: "user_test".to_string(),
role: None,
password_hash: None,
})
.expect("upsert user");
let app = build_app(state.clone());
let response = app
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:docs~2FPlan.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=docs"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let recent = state
.control_plane()
.list_navigation_recent("user_test", Some("page"), 10)
.expect("list recent pages");
assert_eq!(recent.len(), 1);
assert_eq!(
recent[0].document_id.as_deref(),
Some("local-md:docs~2FPlan.md")
);
assert_eq!(recent[0].relative_path.as_deref(), Some("docs/Plan.md"));
assert_eq!(recent[0].title, "Plan");
}
#[tokio::test]
async fn document_shell_returns_page_aggregate_snapshot() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-shell")
.and_then(|value| value.to_str().ok()),
Some("document")
);
assert!(response
.headers()
.get_all("set-cookie")
.iter()
.any(|value| value
.to_str()
.unwrap_or_default()
.contains("mnote_recent_page_id=doc_1")));
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS;
assert!(html.contains("data-testid=\"wolai-sidebar\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\""));
assert!(html.contains("data-testid=\"wolai-topbar\""));
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
assert!(html.contains("星标置顶"));
assert!(html.contains("我的页面"));
assert!(html.contains("垃圾箱"));
assert!(html.contains("模板中心"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
assert!(html.contains("aria-label=\"页面标题\""));
assert!(html.contains("data-page-title-input=\"true\""));
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
assert!(html.contains("mnote.document_title_controller.v1"));
assert!(html.contains("const currentTarget = resolveTitleTarget(input);"));
assert!(html.contains("documentId: currentTarget.documentId"));
assert!(html.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title"#
));
assert!(html.contains("const fileTreePageTitle = (value) => {"));
assert!(html.contains(
"return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;"
));
assert!(html.contains(
r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"#
));
assert!(runtime.contains("row.getAttribute('data-row-id') === `doc:${documentId}`"));
assert!(!runtime.contains("row.getAttribute('data-row-id') === `index:${documentId}`"));
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
assert!(html.contains("data-mnote-action=\"open-page-settings\""));
assert!(html.contains("data-mnote-action=\"open-page-ai-pi-lab\""));
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
assert!(html.contains("data-testid=\"mnote-document-workspace\""));
assert!(html.contains("data-document-pane=\"true\""));
assert!(html.contains("data-pane-role=\"primary\""));
assert!(html.contains("data-document-pane-resizer=\"true\""));
assert!(html.contains("data-mnote-main-tab-strip"));
assert!(html.contains("class=\"mnote-main-tab-badge\""));
assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title"));
assert!(runtime.contains("pageTab.setAttribute('data-document-id', documentId);"));
assert!(html.contains("data-mnote-tab-badge-kind"));
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
assert!(runtime.contains("document-resource-tab-runtime.js"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("后台任务"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-tab"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-clear-completed"));
assert!(resource_runtime.contains("role=\"progressbar\""));
assert!(resource_runtime.contains("knowledgeRagTaskCategory(job)"));
assert!(resource_runtime.contains("knowledgeRagTaskProgress(job)"));
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
assert!(runtime.contains("getOpenEditorsSnapshot"));
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
assert!(resource_runtime.contains("data-mnote-tab-strip-bound"));
assert!(runtime.contains("currentWebShellDocumentId"));
assert!(resource_runtime.contains(
"const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();"
));
assert!(resource_runtime.contains(
"if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;"
));
assert!(!html
.contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })"));
let slash_runtime = DOCUMENT_SLASH_POSITION_RUNTIME_JS;
assert!(slash_runtime.contains("positionSlashMenuForRoot"));
assert!(slash_runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');"));
assert!(runtime.contains("installGlobalSlashMenuPositioning();"));
assert!(slash_runtime.contains("data-mnote-slash-positioned', 'host'"));
assert!(slash_runtime.contains(
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
));
assert!(slash_runtime.contains("const setSlashMenuInactive = (menu, inactive) =>"));
assert!(slash_runtime.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
assert!(slash_runtime.contains("data-mnote-slash-inactive"));
assert!(
slash_runtime.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)")
);
assert!(!runtime.contains(
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
));
assert!(slash_runtime.contains("data-mnote-side-target-unsupported') !== 'true'"));
assert!(
runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
);
assert!(html.contains("data-mnote-resource-tab-host"));
assert!(runtime.contains("openPrimaryMindmap"));
assert!(runtime.contains("openResourceInActiveTab"));
assert!(html.contains(
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
));
assert!(html.contains(
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
));
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
assert!(resource_runtime.contains("normalizeEvidenceLocatorInput"));
assert!(resource_runtime.contains("applyEvidenceLocatorToEntry"));
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
assert!(runtime.contains("sourceMapPath: String(url.searchParams.get('sourceMapPath')"));
assert!(runtime.contains("blockId: String(url.searchParams.get('blockId')"));
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
assert!(sidebar_runtime.contains("data-evidence-locator"));
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
assert!(mindmap_runtime.contains("data-mnote-object-identity"));
assert!(runtime.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
assert!(html.contains("/api/tree/events"));
assert!(html.contains("/api/realtime/ws"));
let session_runtime = DOCUMENT_SESSION_RUNTIME_JS;
assert!(session_runtime.contains("syncPageAggregateScript(session, nextAggregate);"));
let conversion_runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
assert!(conversion_runtime.contains(
"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!(
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"));
assert!(conversion_runtime
.contains("class: mergeClassNames(mark.attrs.class, attachmentClass)"));
assert!(session_runtime
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
assert!(session_runtime.contains(
"const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
));
assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1"));
assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()"));
assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);"));
assert!(session_runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(session_runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("local_compat_fallback"));
assert!(runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Data')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Any')"));
assert!(!runtime.contains("mnote-web-document-shell"));
// Resource open resolver contract
assert!(resource_runtime.contains("normalizeResourceTabKind"));
assert!(resource_runtime.contains("data-resource-tab-error"));
assert!(resource_runtime.contains("resourceTabCloseGuardReason"));
assert!(resource_runtime.contains("closeResourceTab"));
assert!(resource_runtime.contains("resourceTabRegistry.delete(key)"));
assert!(runtime.contains("data-testid=\"mnote-secondary-editor-tab-host\""));
assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\""));
assert!(resource_runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)"));
assert!(runtime.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'"));
assert!(resource_runtime.contains("data-mnote-editor-kind=\"resource\""));
assert!(resource_runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);"));
assert!(resource_runtime.contains("paneRole,"));
assert!(resource_runtime.contains("markIntendedSlashRoot(entry);"));
assert!(resource_runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);"));
assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("openInlinePdfResourceTab"));
assert!(resource_runtime.contains("data-mnote-inline-pdf-viewer"));
assert!(resource_runtime.contains("refreshExistingPdfResourceTab(existing, input);"));
assert!(resource_runtime.contains("releaseInlinePdfResource"));
assert!(
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(resource_runtime.contains(
"if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);"
));
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
assert!(resource_runtime.contains("mnote:active-resource-tab-changed"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("activeResourceWidthPreference(options)"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote:active-resource-tab-changed"));
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_snapshot_contract() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-page-aggregate-owner")
.and_then(|value| value.to_str().ok()),
Some("compat-join")
);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
assert_eq!(payload["result"]["projectionVersion"], 1);
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
assert_eq!(
payload["result"]["body"]["projectionSource"],
"editorDocument"
);
assert_eq!(
payload["result"]["body"]["blockDocument"]["rootBlockIds"][0],
"editor_1"
);
assert_eq!(
payload["result"]["body"]["blockDocument"]["blocks"][0]["text"],
"来自 editorDocument 的正文"
);
}
#[tokio::test]
async fn mnote_browser_runtime_assets_are_cacheable() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let cache_control = response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.expect("cache-control");
assert_ne!(cache_control, "no-store");
assert!(
cache_control.contains("max-age"),
"runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
);
}
#[tokio::test]
async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let response = app()
.oneshot(
Request::builder()
.uri("/api/mnote-browser-runtime/tree-live-controller.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert_eq!(response.status(), StatusCode::OK);
let cache_control = response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.expect("cache-control");
assert_eq!(
cache_control, "no-store",
"dev:hot 下 browser runtime 不能缓存,否则热更新后仍会执行旧 JS"
);
}
#[tokio::test]
async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let response = app()
.oneshot(
Request::builder()
.uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let js = String::from_utf8(body.to_vec()).expect("utf8");
assert!(
js.contains("from './sidebar-workspace-runtime.js?devHot="),
"dev:hot 下 browser runtime 的静态子模块 import 必须跟随同一个 cache buster"
);
assert!(
js.contains("from './sidebar-filetree-command-runtime.js?devHot="),
"文件树命令 runtime 子模块也不能复用旧缓存"
);
}
#[tokio::test]
async fn editor_runtime_preload_links_use_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let html = super::render_editor_runtime_preload_links();
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert!(
html.contains("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js?devHot=")
);
assert!(html.contains(
"/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm?devHot="
));
}
#[test]
fn document_editor_adapter_propagates_dev_hot_to_island_runtime() {
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("new URL(import.meta.url)"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)"));
}
#[tokio::test]
async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let js = String::from_utf8(body.to_vec()).expect("utf8");
assert!(
js.contains("from './snippets/leptos-tiptap-")
&& js.contains("/bridge_runtime.js?devHot="),
"dev:hot 下 wasm-bindgen snippets 子模块不能继续使用旧缓存"
);
assert!(
js.contains("from \"./snippets/mnote-leptos-tiptap-spike-")
&& js.contains("/inline0.js?devHot="),
"无分号的 wasm-bindgen import 也必须带 cache buster"
);
}
#[tokio::test]
async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let js = String::from_utf8(body.to_vec()).expect("utf8");
assert!(
js.contains("inline0.js?devHot="),
"dev:hot 下 island 必须加载带 cache buster 的页面块导航 bridge"
);
}
#[tokio::test]
async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let js = String::from_utf8(body.to_vec()).expect("utf8");
assert!(js.contains("simple-mind-map"));
assert!(js.contains("createMindmapParagraphNodeView"));
}
#[tokio::test]
async fn leptos_tiptap_runtime_assets_are_cacheable() {
let manifest_response = app()
.clone()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/manifest.json")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(manifest_response.status(), StatusCode::OK);
let manifest_cache_control = manifest_response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.expect("manifest cache-control");
assert_ne!(manifest_cache_control, "no-store");
assert!(
manifest_cache_control.contains("max-age"),
"leptos-tiptap manifest 应允许浏览器缓存,避免每次重新发现 runtime 入口"
);
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let cache_control = response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.expect("cache-control");
assert_ne!(cache_control, "no-store");
assert!(
cache_control.contains("max-age"),
"leptos-tiptap runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
);
}
#[tokio::test]
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
assert_eq!(
response
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("convex_query_retired")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("legacy-cloud-retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
assert!(payload.get("schema").is_none());
assert!(payload.get("result").is_none());
}
#[tokio::test]
async fn document_shell_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
assert!(!text.contains("mnote.page_aggregate.v1"));
assert!(!text.contains("data-mnote-dev-fixture"));
assert!(!text.contains("data-page-aggregate-snapshot"));
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
let root =
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
std::fs::write(
root.join("Local Aggregate").join("Local Aggregate.md"),
"# Local Heading\n正文内容\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(
payload["result"]["identity"]["documentId"],
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
);
// 本地 Markdown 标题来自文件名;正文 H1 只作为正文内容。
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
assert_eq!(payload["result"]["body"]["revision"], 0);
assert!(payload["result"]["body"]["content"]
.to_string()
.contains("Local Heading"));
}
#[tokio::test]
async fn page_aggregate_endpoint_allows_control_plane_granted_local_folder_read_access() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-grant-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Grant Root")).expect("create local page bundle");
std::fs::write(
root.join("Grant Root").join("Grant Root.md"),
"# Grant Heading\n授权正文\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
init_local_workspace(&root, "owner_user");
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
let context = request_context("user_test", "user");
let aggregate = super::build_page_aggregate_snapshot(
&state,
&context,
"local-md:Grant~20Root~2FGrant~20Root.md",
None,
Some("local_folder"),
Some(&root_uri),
)
.await
.expect("control-plane read grant can open local page aggregate");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(
aggregate.identity.document_id,
"local-md:Grant~20Root~2FGrant~20Root.md"
);
assert_eq!(aggregate.head.title, "Grant Root");
assert!(aggregate.body.content.to_string().contains("Grant Heading"));
}
#[tokio::test]
async fn local_page_aggregate_marks_attachment_refs_authorization_from_control_plane_grants() {
let root = temp_root("mnote-local-page-aggregate-attachment-auth-root");
let allowed_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-allowed");
let denied_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-denied");
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("local.pdf"), b"local").expect("local pdf");
let allowed_external = allowed_external_root.join("allowed.pdf");
let denied_external = denied_external_root.join("denied.pdf");
std::fs::write(&allowed_external, b"allowed").expect("allowed external");
std::fs::write(&denied_external, b"denied").expect("denied external");
std::fs::write(
root.join("Page").join("Page.md"),
format!(
"[本页](./local.pdf)\n[授权外部](file://{})\n[未授权外部](file://{})\n",
allowed_external.display(),
denied_external.display()
),
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
let allowed_root_uri = format!("file://{}", allowed_external_root.display());
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
init_local_workspace(&root, "owner_user");
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
grant_local_workspace_read_access(&state, "user_other", &root_uri, &root);
grant_local_workspace_read_access(
&state,
"user_test",
&allowed_root_uri,
&allowed_external_root,
);
let context = request_context("user_test", "user");
let aggregate = super::build_page_aggregate_snapshot(
&state,
&context,
"local-md:Page~2FPage.md",
None,
Some("local_folder"),
Some(&root_uri),
)
.await
.expect("aggregate with attachment refs");
let refs = aggregate.body.attachment_refs.as_array().expect("refs");
let by_label = |label: &str| {
refs.iter()
.find(|item| item["label"].as_str() == Some(label))
.expect("attachment ref")
};
assert_eq!(by_label("本页")["authorized"], true);
assert_eq!(by_label("授权外部")["authorized"], true);
assert_eq!(by_label("未授权外部")["authorized"], false);
let other_context = request_context("user_other", "user");
let other_aggregate = super::build_page_aggregate_snapshot(
&state,
&other_context,
"local-md:Page~2FPage.md",
None,
Some("local_folder"),
Some(&root_uri),
)
.await
.expect("other user aggregate with attachment refs");
let other_refs = other_aggregate
.body
.attachment_refs
.as_array()
.expect("other refs");
let other_allowed_external = other_refs
.iter()
.find(|item| item["label"].as_str() == Some("授权外部"))
.expect("other allowed external ref");
assert_eq!(other_allowed_external["authorized"], false);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&allowed_external_root);
let _ = std::fs::remove_dir_all(&denied_external_root);
}
#[tokio::test]
async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-runtime-asset-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Cold Start")).expect("create local page bundle");
std::fs::write(
root.join("Cold Start").join("Cold Start.md"),
"# Cold Start\n\nEditor cold start target.\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let app = app();
let aggregate_app = app.clone();
let aggregate_uri = format!(
"/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}"
);
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.store(400, std::sync::atomic::Ordering::SeqCst);
struct ResetLocalAggregateBlock;
impl Drop for ResetLocalAggregateBlock {
fn drop(&mut self) {
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
.store(0, std::sync::atomic::Ordering::SeqCst);
}
}
let _reset_block = ResetLocalAggregateBlock;
let aggregate_task = tokio::spawn(async move {
aggregate_app
.oneshot(
Request::builder()
.uri(aggregate_uri)
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("aggregate request"),
)
.await
.expect("aggregate response")
});
let started = Instant::now();
tokio::task::yield_now().await;
let asset_response = app
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("asset request"),
)
.await
.expect("asset response");
let asset_elapsed = started.elapsed();
let aggregate_response = aggregate_task.await.expect("aggregate task");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(asset_response.status(), StatusCode::OK);
assert_eq!(aggregate_response.status(), StatusCode::OK);
assert!(
asset_elapsed < Duration::from_millis(150),
"leptos-tiptap runtime asset 不应被本地 Page Aggregate 冷构建阻塞,实际等待 {asset_elapsed:?}"
);
}
#[tokio::test]
async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header(
) {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-ui-pref-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(root.join("README.md"), "# Readme Heading\n正文\n").expect("write local md");
let root_uri = format!("file://{}", root.display());
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(&root_uri)
.expect("workspace id");
state
.control_plane()
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "user_test".into(),
workspace_id: Some(workspace_id),
source_kind: Some("local_folder".into()),
scope_kind: "source_family".into(),
scope_id: "external_local_folder".into(),
key: "hideTitleHeader".into(),
value_json: "false".into(),
})
.expect("upsert title header preference");
let context = request_context("user_test", "user");
let aggregate = super::build_page_aggregate_snapshot(
&state,
&context,
"local-md:README.md",
None,
Some("local_folder"),
Some(&root_uri),
)
.await
.expect("local page aggregate");
let _ = std::fs::remove_dir_all(&root);
assert!(!aggregate.layout.page_options.hide_title_header);
}
#[tokio::test]
async fn ui_preferences_api_updates_and_returns_effective_page_options() {
let app = app();
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/ui/preferences")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"workspaceId": "ws_demo",
"sourceKind": "convex_workspace",
"documentId": "doc_1",
"updates": {
"showHeadingNumbers": true,
"layoutDensity": "compact"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["pageOptions"]["showHeadingNumbers"], true);
assert_eq!(payload["result"]["pageOptions"]["layoutDensity"], "compact");
assert_eq!(payload["result"]["sources"]["showHeadingNumbers"], "global");
assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace");
}
#[tokio::test]
async fn tree_view_state_api_is_user_scoped_and_tree_scope_isolated() {
let app = app();
let put_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/tree/view-state")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "alice")
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"userId": "mallory",
"workspaceId": "default",
"sourceKind": "local_folder",
"treeKind": "filetree",
"rootUri": "file:///mnt/Data1T/mnote",
"scope": "design",
"state": {
"schemaVersion": 1,
"treeKind": "filetree",
"rootUri": "file:///mnt/Data1T/mnote",
"scope": "design",
"expandedRelativePaths": ["design/05-editor-mainline"],
"expandedIds": [],
"selectedId": "local:folder:design/05-editor-mainline",
"focusedId": "",
"activeId": "",
"scrollTop": 12
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(put_response.status(), StatusCode::OK);
let put_body = to_bytes(put_response.into_body(), usize::MAX)
.await
.expect("body");
let put_payload: Value = serde_json::from_slice(&put_body).expect("json");
assert_eq!(put_payload["result"]["userId"], "alice");
assert_ne!(put_payload["result"]["workspaceId"], "default");
assert!(put_payload["result"]["scopeId"]
.as_str()
.expect("scope id")
.starts_with("filetree:"));
let get_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
.header("x-mnote-actor-id", "alice")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(get_response.status(), StatusCode::OK);
let get_body = to_bytes(get_response.into_body(), usize::MAX)
.await
.expect("body");
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
assert_eq!(get_payload["result"]["source"], "control-plane");
assert_eq!(
get_payload["result"]["state"]["expandedRelativePaths"][0],
"design/05-editor-mainline"
);
let page_tree_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=pagetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
.header("x-mnote-actor-id", "alice")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(page_tree_response.status(), StatusCode::OK);
let page_tree_body = to_bytes(page_tree_response.into_body(), usize::MAX)
.await
.expect("body");
let page_tree_payload: Value = serde_json::from_slice(&page_tree_body).expect("json");
assert_eq!(page_tree_payload["result"]["source"], "default");
assert_eq!(
page_tree_payload["result"]["state"]["expandedRelativePaths"]
.as_array()
.expect("array")
.len(),
0
);
let bob_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
.header("x-mnote-actor-id", "bob")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(bob_response.status(), StatusCode::OK);
let bob_body = to_bytes(bob_response.into_body(), usize::MAX)
.await
.expect("body");
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("json");
assert_eq!(bob_payload["result"]["source"], "default");
}
#[tokio::test]
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
let root =
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Local Shell")).expect("create local page bundle");
std::fs::create_dir_all(root.join("docs").join("Child Page"))
.expect("create local child bundle");
std::fs::write(
root.join("Local Shell").join("Local Shell.md"),
"# Local Shell\n正文\n",
)
.expect("write root md");
std::fs::write(
root.join("docs").join("Child Page").join("Child Page.md"),
"# Child Page\n",
)
.expect("write child md");
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("Local Shell"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
assert!(html.contains("Child Page"));
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind="));
assert!(html.contains("data-page-openable=\"false\""));
assert!(html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
assert!(html.contains("/api/mnote-browser-runtime/filetree-runtime.js"));
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
let session_runtime = DOCUMENT_SESSION_RUNTIME_JS;
assert!(session_runtime.contains("refreshSessionFromExternalFileChange"));
assert!(session_runtime.contains("refreshSessionFromExternalChange"));
assert!(session_runtime.contains("treeExternalConflictMessage"));
assert!(session_runtime.contains("tree:delta"));
assert!(session_runtime.contains("tree:resync"));
assert!(session_runtime.contains("mnote-web-tree-live"));
assert!(session_runtime.contains("refreshMindmapRuntimesFromTreePayload"));
assert!(session_runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__"));
assert!(session_runtime.contains("/api/local-folder/events"));
assert!(session_runtime.contains("localFolderEventChannelKey"));
assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);"));
assert!(session_runtime.contains(
"if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;"
));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime.contains("if (!response.ok) {"));
assert!(session_runtime.contains(
"if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found'))"
));
assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange"));
assert!(session_runtime.contains("kind.includes('Modify(Name')"));
assert!(session_runtime.contains("targetSession.views.size === 0"));
assert!(session_runtime.contains("session.views.size === 0"));
assert!(session_runtime.contains("targetSession.saving"));
assert!(session_runtime.contains("lastSelfSaveSignalAt"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("clearSessionConflictSurface(view.session);"));
assert!(session_runtime.contains("command: 'replaceContent'"));
assert!(session_runtime.contains("external-change-conflict"));
assert!(session_runtime.contains("mnote-editor-conflict-panel"));
assert!(session_runtime.contains("mnote-conflict-accept-disk"));
assert!(session_runtime.contains("mnote-conflict-keep-current"));
assert!(session_runtime.contains("mnote-conflict-open-diff"));
assert!(session_runtime.contains("mnote-conflict-merge-text"));
assert!(session_runtime.contains("mnote-conflict-merge-save"));
assert!(session_runtime
.contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)"));
assert!(session_runtime.contains("agent run"));
assert!(!session_runtime.contains(
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
));
}
#[tokio::test]
async fn document_shell_local_folder_filetree_scope_renders_scoped_page_tree() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-scoped-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("design").join("done")).expect("create design done");
std::fs::write(root.join("Home.md"), "# Home\n").expect("write home");
std::fs::write(
root.join("design").join("done").join("Target.md"),
"# Target\n",
)
.expect("write target");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:design~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains(r#"data-node-id="local-md:design~2Fdone~2FTarget.md""#));
assert!(
!html.contains(r#"data-node-id="local-md:Home.md""#),
"文档页带 fileTreeScope 时 PageTree 不应回退到 workspace root 全量扫描"
);
}
#[tokio::test]
async fn document_shell_local_folder_filetree_scope_reveals_active_file_parent_chain() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-scoped-filetree-reveal-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("design").join("07-ai").join("done"))
.expect("create design done");
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
.expect("create unrelated scope sibling");
std::fs::write(
root.join("design")
.join("07-ai")
.join("done")
.join("Target.md"),
"# Target\n",
)
.expect("write target");
std::fs::write(
root.join("design")
.join("05-editor-mainline")
.join("Other.md"),
"# Other\n",
)
.expect("write unrelated page");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:design~2F07-ai~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(
html.contains(r#"data-local-relative-path="design/07-ai""#),
"scoped FileTree 应保留 active 文档父级 07-ai"
);
assert!(
html.contains(r#"data-local-relative-path="design/07-ai/done""#),
"scoped FileTree 应只 reveal active 文档命中的 done 父链"
);
assert!(
html.contains(r#"data-local-relative-path="design/07-ai/done/Target.md""#),
"active Markdown 文件应在 scoped FileTree 首屏可见"
);
assert!(
!html.contains(r#"data-local-relative-path="design/05-editor-mainline/Other.md""#),
"不相关 sibling 目录不应被 reveal 扫入 scoped FileTree"
);
}
#[test]
fn document_conflict_panel_runtime_contains_dom_helpers() {
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
include_str!("../../browser/document-conflict-panel-runtime.js");
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function clearSessionConflictSurface"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function createSessionConflictPanel"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-editor-conflict-panel"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-accept-disk"));
assert!(
DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-open-diff")
);
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onAcceptDisk"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onKeepCurrent"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onOpenDiff"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("function populateSessionConflictDiffPanel"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-current-text"));
assert!(
DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-disk-text")
);
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-merge-use-current"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-merge-use-disk"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("currentMergeText"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseCurrent"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseDisk"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onSaveMerge"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function mountSessionConflictPanel"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("runSessionConflictAction: runSessionConflictAction"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function acceptDiskVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function keepCurrentEditorVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function writeMergedConflictResult"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("keepCurrentEditorVersion: keepCurrentEditorVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("writeMergedConflictResult: writeMergedConflictResult"));
}
#[test]
fn document_editor_adapter_runtime_contains_host_contracts() {
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("PANES_BOOTSTRAP_ID"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("ROOT_SELECTOR"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("BRIDGE_PROTOCOL"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-mindmap-host-runtime.js"));
assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("replacePrimaryPaneMindmap"));
assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("openMindmapResourceTab"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js"));
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane"));
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-resource-tab-runtime.js"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("openResourceInActiveTab"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("buildOpenEditorsSnapshot"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("bindMainEditorTabStrip"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-session-runtime.js"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("navigateLocalMarkdownDeletedFallback"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("localFolderNavigationFallbackUrl"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("window.location.assign"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-folder:resource-changed"));
assert!(
DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("data-mnote-local-ocr-event-stream-retired")
);
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("local_ocr.job.updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:knowledge-rag-job-updated"));
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-ocr-job-updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS
.contains("data-mnote-resource-watch-ready', 'event-bus'"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("syncPageAggregateScript({ pageAggregateScriptId"));
assert!(
DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("data-mnote-page-options-local-write-at")
);
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("mnote:page-aggregate-synced"));
assert!(
DOCUMENT_SESSION_RUNTIME_JS.contains("syncPageAggregateScript(session, nextAggregate)")
);
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js"));
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition"));
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition"));
assert!(
DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js")
);
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("enhanceEditorAttachmentLinksSoon"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("window.__mnoteEnhanceEditorAttachmentLinks"));
assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap"));
}
#[tokio::test]
async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() {
let config = AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
};
let headers = HeaderMap::new();
let context = RequestContext::from_http_parts(
&Method::GET,
&"/documents/doc_1?workspaceId=ws_demo"
.parse::<Uri>()
.expect("uri"),
&headers,
);
let sidebar_html =
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
let filetree_html =
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
let workspace_projection = super::load_workspace_shell_projection(
None,
&config,
&context,
"ws_demo",
Some("doc_1"),
"个人空间",
)
.await;
assert!(sidebar_html.is_none());
assert!(filetree_html.is_none());
assert!(workspace_projection.degraded);
assert!(workspace_projection.my_page_items.is_empty());
assert!(!workspace_projection.dev_fixture);
}
#[tokio::test]
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-media-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
std::fs::write(
root.join("docs").join("blocks.md"),
"---\ntitle: Complex Title\n---\n[Spec](assets/spec.pdf)\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("Spec"));
assert!(html.contains("assets/spec.pdf"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
}
#[tokio::test]
async fn document_shell_renders_secondary_pane_contract_when_query_present() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("data-has-secondary-pane=\"true\""));
assert!(html.contains("data-pane-role=\"secondary\""));
assert!(html.contains("data-mnote-pane-close=\"secondary\""));
assert!(html.contains("__MNOTE_SECONDARY_PAGE_AGGREGATE__"));
assert!(html.contains("__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__"));
assert!(html.contains("\"paneRole\":\"secondary\""));
assert!(html.contains("\"secondaryRequested\":true"));
assert!(html.contains("\"secondaryInvalid\":false"));
}
#[tokio::test]
async fn document_shell_bootstrap_preserves_inline_mark_conversion() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
assert!(runtime.contains("legacyInlineContentToTiptap"));
assert!(runtime.contains("legacyStylesToTiptapMarks"));
assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
assert!(runtime.contains("const sourcePath = firstNonEmptyText("));
assert!(runtime.contains("block?.props?.sourcePath"));
assert!(runtime.contains("marks.push({ type: 'bold' })"));
assert!(runtime.contains("marks.push({ type: 'italic' })"));
assert!(runtime.contains("marks.push({ type: 'underline' })"));
assert!(runtime.contains("marks.push({ type: 'strike' })"));
assert!(runtime.contains("marks.push({ type: 'code' })"));
assert!(runtime.contains("marks.push({ type: 'link', attrs: { href } })"));
assert!(runtime.contains("styles.link = href"));
assert!(runtime.contains("contentNodes.map((node) => {"));
assert!(runtime.contains("payload: { type: 'text', text"));
assert!(runtime.contains("typeof payload.text === 'string'"));
assert!(runtime.contains("payload.type === 'hard_break'"));
assert!(runtime.contains("typeof body?.fileVersion === 'string'"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("expectedFileVersion: expectedFileVersion"));
assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
assert!(runtime.contains("blockType: 'mindmap'"));
assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)"));
assert!(runtime.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)"));
assert!(runtime.contains("mnoteBlockType: 'mindmap'"));
assert!(runtime.contains("block.blockType === 'mindmap'"));
assert!(runtime.contains("content: block.blockType === 'mindmap'"));
assert!(runtime.contains("? ''"));
assert!(!runtime.contains(
"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"));
}
}