2026-04-29 12:24:44 +08:00
|
|
|
|
use crate::app::{AppConfig, AppState};
|
|
|
|
|
|
use crate::context::RequestContext;
|
2026-05-21 17:15:06 +08:00
|
|
|
|
use crate::document_buffer_store::{self};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
|
use crate::page_aggregate::PageAggregate;
|
|
|
|
|
|
use crate::routes::documents::{
|
2026-05-29 11:13:05 +08:00
|
|
|
|
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
|
|
|
|
|
DocumentMetaQuery,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
};
|
2026-05-22 17:45:22 +08:00
|
|
|
|
use crate::routes::gateway::default_workspace_name_for_context;
|
2026-05-08 00:41:03 +08:00
|
|
|
|
use crate::routes::local_folder_source::{
|
2026-05-23 23:38:42 +08:00
|
|
|
|
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
2026-05-27 11:31:12 +08:00
|
|
|
|
load_local_folder_file_tree_children_snapshot,
|
2026-05-26 12:30:01 +08:00
|
|
|
|
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
resolve_local_markdown_page_aggregate,
|
|
|
|
|
|
};
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use crate::routes::query_support::execute_runtime_query_against_data;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use crate::routes::snapshot_support::{
|
2026-05-29 11:13:05 +08:00
|
|
|
|
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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::{
|
2026-05-29 11:13:05 +08:00
|
|
|
|
render_initial_filetree_html, FileTreeInitialRenderInput,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
};
|
2026-05-29 11:13:05 +08:00
|
|
|
|
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
use crate::workspace_shell::{
|
2026-05-29 11:13:05 +08:00
|
|
|
|
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
|
|
|
|
|
WorkspaceShellProjection,
|
2026-04-29 14:36:24 +08:00
|
|
|
|
};
|
|
|
|
|
|
use axum::body::Body;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use axum::extract::{Extension, Path, Query, State};
|
2026-05-29 11:13:05 +08:00
|
|
|
|
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use axum::response::{Html, IntoResponse, Response};
|
2026-05-29 11:13:05 +08:00
|
|
|
|
use axum::Json;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
use control_plane::UpsertNavigationRecentInput;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use core_protocol::KernelProjectionKind;
|
|
|
|
|
|
use serde::Deserialize;
|
2026-05-29 11:13:05 +08:00
|
|
|
|
use serde_json::{json, Value};
|
2026-05-14 18:24:30 +08:00
|
|
|
|
use std::fs;
|
2026-04-29 14:36:24 +08:00
|
|
|
|
use std::path::{Component, Path as FsPath, PathBuf};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
|
|
|
|
|
std::sync::atomic::AtomicU64::new(0);
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentShellQuery {
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
pub source_kind: Option<String>,
|
|
|
|
|
|
pub root_uri: Option<String>,
|
2026-05-26 14:34:47 +08:00
|
|
|
|
pub tree_view: Option<String>,
|
2026-05-27 11:31:12 +08:00
|
|
|
|
pub file_tree_scope: Option<String>,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
pub secondary_document_id: Option<String>,
|
|
|
|
|
|
pub secondary_source_kind: Option<String>,
|
|
|
|
|
|
pub secondary_root_uri: Option<String>,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn document_page_shell(
|
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
uri: Uri,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
Path(document_id): Path<String>,
|
|
|
|
|
|
Query(query): Query<DocumentShellQuery>,
|
|
|
|
|
|
) -> Result<Response, WebError> {
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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("/")
|
|
|
|
|
|
)
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let primary_source_kind = normalize_source_kind(query.source_kind.as_deref());
|
|
|
|
|
|
let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref());
|
2026-05-28 22:01:44 +08:00
|
|
|
|
let aggregate = match build_page_aggregate_snapshot(
|
2026-04-29 12:24:44 +08:00
|
|
|
|
&state,
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&document_id,
|
|
|
|
|
|
query.workspace_id.as_deref(),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
primary_source_kind,
|
|
|
|
|
|
primary_root_uri,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
)
|
2026-05-28 22:01:44 +08:00
|
|
|
|
.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),
|
|
|
|
|
|
};
|
2026-05-21 17:15:06 +08:00
|
|
|
|
|
|
|
|
|
|
// 初始化 BufferStore:local_folder 文档打开时记录 file_version
|
|
|
|
|
|
if primary_source_kind == Some("local_folder") {
|
|
|
|
|
|
if let Some(root_uri) = primary_root_uri {
|
2026-05-28 22:01:44 +08:00
|
|
|
|
let relative_path = local_markdown_relative_path_from_document_id(&document_id);
|
2026-05-21 17:15:06 +08:00
|
|
|
|
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);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
record_navigation_recent_page(
|
|
|
|
|
|
&state,
|
|
|
|
|
|
&context,
|
|
|
|
|
|
root_uri,
|
|
|
|
|
|
&relative_path,
|
|
|
|
|
|
&document_id,
|
|
|
|
|
|
aggregate.head.title.as_str(),
|
|
|
|
|
|
Some(&aggregate.identity.workspace_id),
|
|
|
|
|
|
);
|
2026-05-21 17:15:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let title = aggregate.head.title.as_str();
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let workspace_id = aggregate.identity.workspace_id.clone();
|
2026-05-09 06:24:50 +08:00
|
|
|
|
let requested_secondary_document_id =
|
|
|
|
|
|
normalize_optional_owned(query.secondary_document_id.as_deref());
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let secondary_source_kind = normalize_source_kind(
|
2026-05-09 06:24:50 +08:00
|
|
|
|
query
|
|
|
|
|
|
.secondary_source_kind
|
2026-05-08 23:15:00 +08:00
|
|
|
|
.as_deref()
|
|
|
|
|
|
.or(primary_source_kind),
|
|
|
|
|
|
);
|
2026-05-09 06:24:50 +08:00
|
|
|
|
let secondary_root_uri =
|
|
|
|
|
|
normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri));
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let mut secondary_requested = false;
|
|
|
|
|
|
let mut secondary_invalid = false;
|
2026-05-09 06:24:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
}
|
2026-05-09 06:24:50 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
2026-05-22 17:45:22 +08:00
|
|
|
|
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let mut workspace_projection = load_workspace_shell_projection(
|
2026-05-27 11:31:12 +08:00
|
|
|
|
Some(&state),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
state.config(),
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&workspace_id,
|
|
|
|
|
|
Some(&document_id),
|
|
|
|
|
|
&default_workspace_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
apply_active_page(&mut workspace_projection, Some(&document_id));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
let is_local_folder = query
|
|
|
|
|
|
.source_kind
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
== Some("local_folder");
|
2026-05-26 14:34:47 +08:00
|
|
|
|
let requests_filetree_first = query
|
|
|
|
|
|
.tree_view
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.is_some_and(|value| value == "filetree");
|
2026-05-27 11:31:12 +08:00
|
|
|
|
let file_tree_scope = query
|
|
|
|
|
|
.file_tree_scope
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
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(root_uri, Some(&document_id)).unwrap_or_default(),
|
2026-05-27 11:31:12 +08:00
|
|
|
|
render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
|
|
|
|
|
|
.unwrap_or_default(),
|
2026-05-08 00:41:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(
|
|
|
|
|
|
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap_or_default(),
|
2026-05-16 07:11:06 +08:00
|
|
|
|
load_file_tree_html(
|
|
|
|
|
|
state.config(),
|
|
|
|
|
|
&context,
|
|
|
|
|
|
&workspace_id,
|
|
|
|
|
|
Some(&document_id),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap_or_default(),
|
2026-05-08 00:41:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
|
|
|
|
|
&workspace_projection,
|
|
|
|
|
|
Some(sidebar_tree_html.as_str()),
|
|
|
|
|
|
Some(file_tree_html.as_str()),
|
2026-05-26 14:34:47 +08:00
|
|
|
|
if requests_filetree_first {
|
|
|
|
|
|
Some("filetree")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
},
|
2026-05-27 11:31:12 +08:00
|
|
|
|
file_tree_scope,
|
2026-04-29 14:36:24 +08:00
|
|
|
|
);
|
|
|
|
|
|
let workspace_name = workspace_projection.workspace_name.clone();
|
|
|
|
|
|
let page_subtree_json =
|
|
|
|
|
|
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
|
2026-05-06 21:44:20 +08:00
|
|
|
|
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
|
|
|
|
|
|
.unwrap_or_else(|_| "null".to_string());
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
2026-05-09 06:24:50 +08:00
|
|
|
|
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())
|
|
|
|
|
|
});
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let body_content = crate::ssr::render_view(leptos::view! {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
<DocumentPage
|
|
|
|
|
|
title={title.to_string()}
|
|
|
|
|
|
document_id={document_id.clone()}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
workspace_id={workspace_id.clone()}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
sidebar_tree_html={sidebar_tree_html}
|
|
|
|
|
|
workspace_name={workspace_name}
|
|
|
|
|
|
workspace_sidebar_html={workspace_sidebar_html}
|
|
|
|
|
|
page_subtree_json={page_subtree_json}
|
2026-05-06 21:44:20 +08:00
|
|
|
|
page_options_json={page_options_json}
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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()}
|
2026-05-24 03:01:34 +08:00
|
|
|
|
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"))}
|
2026-05-19 08:07:17 +08:00
|
|
|
|
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
/>
|
2026-04-29 12:24:44 +08:00
|
|
|
|
});
|
2026-05-14 18:24:30 +08:00
|
|
|
|
let hermes_settings_config_script = render_hermes_settings_config_script();
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let html = format!(
|
|
|
|
|
|
r#"<!doctype html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<title>{}</title>
|
2026-05-27 11:31:12 +08:00
|
|
|
|
{}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
<style>{}</style>
|
|
|
|
|
|
</head>
|
2026-05-20 10:43:38 +08:00
|
|
|
|
<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="{}">
|
2026-04-29 12:24:44 +08:00
|
|
|
|
{}
|
|
|
|
|
|
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
2026-04-29 14:36:24 +08:00
|
|
|
|
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
2026-05-08 23:15:00 +08:00
|
|
|
|
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
|
|
|
|
|
{}
|
|
|
|
|
|
{}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
{}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
{}
|
2026-05-14 18:24:30 +08:00
|
|
|
|
{}
|
2026-05-25 01:13:08 +08:00
|
|
|
|
{}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>"#,
|
|
|
|
|
|
escape_html(title),
|
2026-05-27 11:31:12 +08:00
|
|
|
|
render_editor_runtime_preload_links(),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
crate::ssr::MNOTE_CSS,
|
|
|
|
|
|
escape_html(&document_id),
|
2026-05-28 22:01:44 +08:00
|
|
|
|
escape_html(primary_source_kind.unwrap_or("local_folder")),
|
2026-05-20 10:43:38 +08:00
|
|
|
|
escape_html(primary_root_uri.unwrap_or("")),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
secondary_requested,
|
|
|
|
|
|
secondary_invalid,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
body_content,
|
|
|
|
|
|
escape_script_json(&snapshot_json),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
escape_script_json(&bootstrap_json),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
escape_script_json(&panes_bootstrap_json),
|
2026-05-14 18:24:30 +08:00
|
|
|
|
hermes_settings_config_script,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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(),
|
2026-04-30 05:46:36 +08:00
|
|
|
|
render_document_title_controller_script(),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
render_editor_island_adapter_script(),
|
2026-05-29 11:13:05 +08:00
|
|
|
|
format!(
|
|
|
|
|
|
r#"<script type="module" src="{}"></script>"#,
|
|
|
|
|
|
mnote_browser_runtime_src("document-conflict-panel-runtime.js")
|
|
|
|
|
|
),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
);
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn build_editor_bootstrap_json(
|
|
|
|
|
|
aggregate: &PageAggregate,
|
|
|
|
|
|
context: &RequestContext,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
source_kind: Option<&str>,
|
|
|
|
|
|
root_uri: Option<&str>,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
) -> String {
|
|
|
|
|
|
build_editor_bootstrap_json_with_ids(
|
|
|
|
|
|
aggregate,
|
|
|
|
|
|
context,
|
|
|
|
|
|
source_kind,
|
|
|
|
|
|
root_uri,
|
|
|
|
|
|
"__MNOTE_PAGE_AGGREGATE__",
|
|
|
|
|
|
"primary",
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 18:24:30 +08:00
|
|
|
|
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)
|
2026-05-16 07:11:06 +08:00
|
|
|
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
|
|
|
|
|
.filter(|value| !value.is_empty()) else {
|
2026-05-14 18:24:30 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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,
|
2026-04-30 16:18:54 +08:00
|
|
|
|
) -> String {
|
2026-05-19 08:07:17 +08:00
|
|
|
|
let normalized_source_kind = source_kind
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
2026-05-28 22:01:44 +08:00
|
|
|
|
.unwrap_or("local_folder");
|
2026-05-19 08:07:17 +08:00
|
|
|
|
let save_endpoint = if normalized_source_kind == "local_folder" {
|
|
|
|
|
|
"/api/page-body/write"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"/api/documents/save"
|
|
|
|
|
|
};
|
2026-04-29 14:36:24 +08:00
|
|
|
|
serde_json::to_string(&json!({
|
|
|
|
|
|
"schema": "mnote.editor_bootstrap.v1",
|
|
|
|
|
|
"documentId": aggregate.identity.document_id,
|
|
|
|
|
|
"workspaceId": aggregate.identity.workspace_id,
|
2026-05-08 23:15:00 +08:00
|
|
|
|
"paneRole": pane_role,
|
2026-05-19 08:07:17 +08:00
|
|
|
|
"sourceKind": normalized_source_kind,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
"rootUri": root_uri
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
.unwrap_or(""),
|
2026-05-08 23:15:00 +08:00
|
|
|
|
"pageAggregateScriptId": page_aggregate_script_id,
|
2026-05-19 08:07:17 +08:00
|
|
|
|
"saveEndpoint": save_endpoint,
|
2026-04-30 05:46:36 +08:00
|
|
|
|
"titleEndpoint": "/api/documents/title",
|
2026-04-29 14:36:24 +08:00
|
|
|
|
"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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 22:43:16 +08:00
|
|
|
|
pub(crate) fn build_document_panes_bootstrap_json(
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn render_document_title_controller_script() -> &'static str {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
r#"<script>
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
const CONTRACT = 'mnote.document_title_controller.v1';
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const inputs = Array.from(document.querySelectorAll('[data-page-title-input="true"]')).filter((node) => node instanceof HTMLTextAreaElement);
|
|
|
|
|
|
if (!inputs.length) return;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
|
|
|
|
|
|
const cssEscape = (value) => {
|
|
|
|
|
|
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
|
|
|
|
|
return String(value).replace(/["\\]/g, '\\$&');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const autosize = (input) => {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
input.style.height = 'auto';
|
|
|
|
|
|
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
const setStatus = (input, status, message) => {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
|
const fileTreePageTitle = (value) => {
|
|
|
|
|
|
const normalized = String(value || '无标题').trim() || '无标题';
|
|
|
|
|
|
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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;
|
2026-05-21 05:40:06 +08:00
|
|
|
|
const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title');
|
|
|
|
|
|
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-30 05:46:36 +08:00
|
|
|
|
const current = document.querySelector('.wolai-breadcrumb-current');
|
2026-05-08 23:15:00 +08:00
|
|
|
|
if (current instanceof HTMLElement && isPrimaryDocument) {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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);
|
2026-05-06 21:44:20 +08:00
|
|
|
|
const escapedDocRowId = cssEscape(`doc:${documentId}`);
|
|
|
|
|
|
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
2026-05-16 07:11:06 +08:00
|
|
|
|
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title));
|
2026-04-30 06:58:17 +08:00
|
|
|
|
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);
|
2026-04-30 05:46:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
inputs.forEach((input) => {
|
|
|
|
|
|
input.setAttribute('data-title-controller', CONTRACT);
|
|
|
|
|
|
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
|
2026-05-11 13:16:34 +08:00
|
|
|
|
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() || '无标题');
|
2026-05-08 23:15:00 +08:00
|
|
|
|
let saving = false;
|
|
|
|
|
|
|
|
|
|
|
|
const saveTitle = async () => {
|
2026-04-30 05:46:36 +08:00
|
|
|
|
const title = input.value.trim() || '无标题';
|
2026-05-11 13:16:34 +08:00
|
|
|
|
const currentTarget = resolveTitleTarget(input);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
autosize(input);
|
2026-05-11 13:16:34 +08:00
|
|
|
|
if (!currentTarget.documentId || saving || title === readLastSavedTitle()) {
|
|
|
|
|
|
updateVisibleTitle(input, title, currentTarget.documentId);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
setStatus(input, 'saved');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
saving = true;
|
2026-05-08 23:15:00 +08:00
|
|
|
|
setStatus(input, 'saving');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(endpoint, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
2026-05-11 13:16:34 +08:00
|
|
|
|
documentId: currentTarget.documentId,
|
|
|
|
|
|
workspaceId: currentTarget.workspaceId || null,
|
|
|
|
|
|
sourceKind: currentTarget.sourceKind || undefined,
|
|
|
|
|
|
rootUri: currentTarget.rootUri || undefined,
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
2026-05-20 10:43:38 +08:00
|
|
|
|
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);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
setStatus(input, 'saved');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
window.dispatchEvent(new CustomEvent('tree:title-updated', {
|
2026-05-20 10:43:38 +08:00
|
|
|
|
detail: {
|
|
|
|
|
|
documentId: nextDocumentId || currentTarget.documentId,
|
|
|
|
|
|
previousDocumentId,
|
|
|
|
|
|
workspaceId: currentTarget.workspaceId || null,
|
|
|
|
|
|
title: nextTitle,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
},
|
2026-04-30 05:46:36 +08:00
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
2026-05-08 23:15:00 +08:00
|
|
|
|
setStatus(input, 'error', error instanceof Error ? error.message : String(error));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
saving = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
input.addEventListener('input', () => {
|
|
|
|
|
|
autosize(input);
|
2026-05-11 13:16:34 +08:00
|
|
|
|
setStatus(input, (input.value.trim() || '无标题') === readLastSavedTitle() ? 'saved' : 'dirty');
|
2026-05-08 23:15:00 +08:00
|
|
|
|
});
|
|
|
|
|
|
input.addEventListener('keydown', (event) => {
|
|
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
input.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
input.addEventListener('blur', () => { void saveTitle(); });
|
|
|
|
|
|
autosize(input);
|
2026-05-11 13:16:34 +08:00
|
|
|
|
updateVisibleTitle(input, readLastSavedTitle(), resolveTitleTarget(input).documentId);
|
2026-05-08 23:15:00 +08:00
|
|
|
|
setStatus(input, 'saved');
|
2026-04-30 05:46:36 +08:00
|
|
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>"#
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 11:13:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 11:13:05 +08:00
|
|
|
|
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")
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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 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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
fn runtime_asset_cache_control() -> &'static str {
|
2026-05-29 11:13:05 +08:00
|
|
|
|
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()),
|
|
|
|
|
|
}
|
2026-05-27 11:31:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-02 06:25:26 +08:00
|
|
|
|
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()))
|
2026-05-24 21:03:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct PdfPreviewQuery {
|
|
|
|
|
|
file_url: Option<String>,
|
|
|
|
|
|
file_name: 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>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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-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}">
|
|
|
|
|
|
<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 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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
await window.docx.renderAsync(buffer, viewer, null, {{
|
|
|
|
|
|
className: 'mnote-docx',
|
|
|
|
|
|
inWrapper: true,
|
|
|
|
|
|
breakPages: true,
|
|
|
|
|
|
renderHeaders: true,
|
|
|
|
|
|
renderFooters: true
|
|
|
|
|
|
}});
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
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 打开。');
|
|
|
|
|
|
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),
|
|
|
|
|
|
);
|
|
|
|
|
|
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 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-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">
|
|
|
|
|
|
<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';
|
|
|
|
|
|
|
|
|
|
|
|
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) {{
|
|
|
|
|
|
const page = await pdf.getPage(pageNumber);
|
|
|
|
|
|
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, window.devicePixelRatio || 1);
|
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
|
canvas.className = 'mnote-pdf-page';
|
|
|
|
|
|
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 (viewer) viewer.append(canvas);
|
|
|
|
|
|
await page.render({{
|
|
|
|
|
|
canvasContext: context,
|
|
|
|
|
|
viewport,
|
|
|
|
|
|
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
|
|
|
|
|
|
}}).promise;
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
|
|
|
|
async function main() {{
|
|
|
|
|
|
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 }}).promise;
|
|
|
|
|
|
if (viewer) viewer.replaceChildren();
|
|
|
|
|
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {{
|
|
|
|
|
|
setStatus(pageNumber + ' / ' + pdf.numPages);
|
|
|
|
|
|
await renderPage(pdf, pageNumber);
|
|
|
|
|
|
}}
|
|
|
|
|
|
setStatus(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),
|
|
|
|
|
|
);
|
|
|
|
|
|
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}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 21:03:33 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-24 21:03:33 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-24 21:03:33 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
2026-05-24 21:35:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 22:42:53 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-24 22:42:53 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 00:22:08 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 17:36:17 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 17:36:17 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 17:36:17 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
2026-05-25 22:53:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 04:01:52 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 04:01:52 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 04:01:52 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 04:17:52 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 04:17:52 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 04:17:52 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 22:53:15 +08:00
|
|
|
|
pub async fn sidebar_shell_runtime_asset() -> Response {
|
|
|
|
|
|
const JS: &str = include_str!("../../browser/sidebar-shell-runtime.js");
|
2026-05-26 01:31:28 +08:00
|
|
|
|
Response::builder()
|
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
"application/javascript; charset=utf-8",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 01:31:28 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 01:31:28 +08:00
|
|
|
|
.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");
|
2026-05-26 01:51:07 +08:00
|
|
|
|
Response::builder()
|
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
"application/javascript; charset=utf-8",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 01:51:07 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 01:51:07 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:35:08 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:35:08 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:35:08 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 01:51:07 +08:00
|
|
|
|
pub async fn sidebar_tree_live_apply_runtime_asset() -> Response {
|
|
|
|
|
|
const JS: &str = include_str!("../../browser/sidebar-tree-live-apply-runtime.js");
|
2026-05-26 01:58:00 +08:00
|
|
|
|
Response::builder()
|
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
"application/javascript; charset=utf-8",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 01:58:00 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 01:58:00 +08:00
|
|
|
|
.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");
|
2026-05-26 02:05:36 +08:00
|
|
|
|
Response::builder()
|
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
"application/javascript; charset=utf-8",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:05:36 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:05:36 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:20:46 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:20:46 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:20:46 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:27:08 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:27:08 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:27:08 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:05:36 +08:00
|
|
|
|
pub async fn sidebar_attachment_open_runtime_asset() -> Response {
|
|
|
|
|
|
const JS: &str = include_str!("../../browser/sidebar-attachment-open-runtime.js");
|
2026-05-25 22:53:15 +08:00
|
|
|
|
Response::builder()
|
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
"application/javascript; charset=utf-8",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 22:53:15 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 22:53:15 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
2026-05-25 17:36:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 17:36:17 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 17:36:17 +08:00
|
|
|
|
.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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 17:36:17 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 17:36:17 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 00:22:08 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 00:22:08 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 00:22:08 +08:00
|
|
|
|
.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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 00:22:08 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 00:37:47 +08:00
|
|
|
|
.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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 00:37:47 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-24 22:42:53 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 21:35:21 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-24 21:35:21 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-24 21:35:21 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
2026-05-25 00:03:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 00:03:12 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 00:03:12 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
2026-05-25 01:13:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 03:51:06 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 03:51:06 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 03:51:06 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:42:07 +08:00
|
|
|
|
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)
|
2026-05-26 02:48:30 +08:00
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
"application/javascript; charset=utf-8",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:48:30 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:48:30 +08:00
|
|
|
|
.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)
|
2026-05-26 02:42:07 +08:00
|
|
|
|
.header(
|
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
|
"application/javascript; charset=utf-8",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:42:07 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:42:07 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:46:02 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:46:02 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:46:02 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:53:12 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:53:12 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:53:12 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 03:35:10 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 03:35:10 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 03:35:10 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 03:42:03 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 03:42:03 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 03:42:03 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:56:16 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 02:56:16 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 02:56:16 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 03:02:15 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 03:02:15 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 03:02:15 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 01:13:08 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-25 01:13:08 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-25 01:13:08 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
2026-05-02 06:25:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:47:51 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 00:47:51 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 00:47:51 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 01:03:04 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 01:03:04 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 01:03:04 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 01:15:28 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 01:15:28 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 01:23:27 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 01:23:27 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:54:54 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 00:54:54 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 00:54:54 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:44:07 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 00:44:07 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 00:44:07 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:35:35 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-05-26 00:35:35 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(browser_runtime_js_body(JS))
|
2026-05-26 00:35:35 +08:00
|
|
|
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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"
|
|
|
|
|
|
],
|
|
|
|
|
|
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
|
|
|
|
|
|
});
|
|
|
|
|
|
let mut response = Json(manifest).into_response();
|
|
|
|
|
|
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
|
2026-05-27 11:31:12 +08:00
|
|
|
|
response.headers_mut().insert(
|
|
|
|
|
|
header::CACHE_CONTROL,
|
|
|
|
|
|
HeaderValue::from_static(runtime_asset_cache_control()),
|
|
|
|
|
|
);
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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 = std::fs::read(&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),
|
|
|
|
|
|
)
|
2026-05-27 11:31:12 +08:00
|
|
|
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
2026-04-29 14:36:24 +08:00
|
|
|
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
2026-05-29 11:13:05 +08:00
|
|
|
|
.body(runtime_asset_body(&asset_path, bytes))
|
2026-04-29 14:36:24 +08:00
|
|
|
|
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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(),
|
2026-05-08 00:41:03 +08:00
|
|
|
|
query.source_kind.as_deref(),
|
|
|
|
|
|
query.root_uri.as_deref(),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
2026-04-30 05:46:36 +08:00
|
|
|
|
let projection_owner = aggregate.source_label();
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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");
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
Ok(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) async fn build_page_aggregate_snapshot(
|
2026-04-29 12:24:44 +08:00
|
|
|
|
state: &AppState,
|
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
|
document_id: &str,
|
|
|
|
|
|
workspace_id: Option<&str>,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
source_kind: Option<&str>,
|
|
|
|
|
|
root_uri: Option<&str>,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
) -> Result<PageAggregate, WebError> {
|
2026-05-08 00:41:03 +08:00
|
|
|
|
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")
|
|
|
|
|
|
})?;
|
2026-05-23 23:38:42 +08:00
|
|
|
|
ensure_local_workspace_read_access_with_state(state, context, root_uri)
|
2026-05-19 08:07:17 +08:00
|
|
|
|
.map_err(|error| error.with_context(context))?;
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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
|
2026-05-27 12:53:55 +08:00
|
|
|
|
.map_err(|error| {
|
|
|
|
|
|
WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}"))
|
|
|
|
|
|
})??;
|
2026-05-29 11:13:05 +08:00
|
|
|
|
annotate_local_attachment_refs_authorization(state, context, &mut aggregate)?;
|
2026-05-27 11:31:12 +08:00
|
|
|
|
super::ui_preferences::apply_effective_page_preferences(
|
|
|
|
|
|
state,
|
|
|
|
|
|
context,
|
|
|
|
|
|
&mut aggregate,
|
|
|
|
|
|
source_kind,
|
|
|
|
|
|
Some(root_uri_for_preferences.as_str()),
|
|
|
|
|
|
)?;
|
|
|
|
|
|
return Ok(aggregate);
|
2026-05-08 00:41:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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?;
|
|
|
|
|
|
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}),
|
|
|
|
|
|
)?;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
2026-04-30 06:58:17 +08:00
|
|
|
|
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
|
|
|
|
|
|
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
|
|
|
|
|
|
})
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 11:13:05 +08:00
|
|
|
|
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!("SQLite 控制面附件授权解析失败: {error}"))
|
|
|
|
|
|
})?;
|
|
|
|
|
|
object.insert(
|
|
|
|
|
|
"authorized".to_string(),
|
|
|
|
|
|
Value::Bool(access.permission != "none"),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn escape_html(value: &str) -> String {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
value
|
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 16:18:54 +08:00
|
|
|
|
pub(crate) fn escape_script_json(value: &str) -> String {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
value.replace("</script", "<\\/script")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) async fn load_workspace_shell_projection(
|
2026-05-27 11:31:12 +08:00
|
|
|
|
state: Option<&AppState>,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-05-27 11:31:12 +08:00
|
|
|
|
let mut dataset = match load_projection_snapshot(config, context, &spec).await {
|
2026-05-14 05:52:08 +08:00
|
|
|
|
Ok(snapshot) => snapshot.dataset,
|
|
|
|
|
|
Err(_) if config.allow_dev_fixtures => {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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 }],
|
2026-05-14 05:52:08 +08:00
|
|
|
|
"documents": documents,
|
|
|
|
|
|
"dev_fixture": true
|
2026-04-29 12:24:44 +08:00
|
|
|
|
})
|
2026-05-14 05:52:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
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"
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
2026-05-27 11:31:12 +08:00
|
|
|
|
if let Some(state) = state {
|
|
|
|
|
|
attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset);
|
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
|
|
|
|
|
|
build_workspace_shell_projection(
|
|
|
|
|
|
&dataset,
|
|
|
|
|
|
workspace_id,
|
|
|
|
|
|
active_document_id,
|
|
|
|
|
|
default_workspace_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
/// 加载侧栏页面树 HTML(SSR)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
2026-05-28 22:01:44 +08:00
|
|
|
|
/// 如果加载失败(如 cloud/compat source 不可用),返回空字符串,侧栏静默降级为无树状态。
|
|
|
|
|
|
/// 当 allow_dev_fixtures 启用且 cloud/compat source 不可用时,使用内建示例数据展示页面树。
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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 {
|
2026-05-14 05:52:08 +08:00
|
|
|
|
Ok(snapshot) => Some((snapshot.projection, false)),
|
2026-04-29 12:24:44 +08:00
|
|
|
|
Err(_) if config.allow_dev_fixtures => {
|
2026-04-29 14:36:24 +08:00
|
|
|
|
// 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 }
|
|
|
|
|
|
])
|
|
|
|
|
|
});
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let dev_dataset = serde_json::json!({
|
|
|
|
|
|
"active_workspace_id": workspace_id,
|
2026-04-29 14:36:24 +08:00
|
|
|
|
"documents": documents,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
"trashed_documents": [],
|
|
|
|
|
|
"media_assets": [],
|
|
|
|
|
|
"trashed_media_assets": [],
|
|
|
|
|
|
"mindmap_assets": [],
|
|
|
|
|
|
"trashed_mindmap_assets": [],
|
|
|
|
|
|
"table_assets": [],
|
|
|
|
|
|
"trashed_table_assets": [],
|
|
|
|
|
|
"mindmap_docs": [],
|
|
|
|
|
|
"mindmap_asset_children": {}
|
|
|
|
|
|
});
|
2026-05-14 05:52:08 +08:00
|
|
|
|
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.map(|projection| (projection, true))
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
Err(_) => None,
|
|
|
|
|
|
};
|
2026-05-14 05:52:08 +08:00
|
|
|
|
result.map(|(projection, dev_fixture)| {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let rows = collect_page_tree_render_rows(&projection);
|
2026-05-14 05:52:08 +08:00
|
|
|
|
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
2026-04-29 12:24:44 +08:00
|
|
|
|
rows,
|
|
|
|
|
|
active_node_id: active_document_id.map(ToOwned::to_owned),
|
|
|
|
|
|
focused_node_id: None,
|
2026-05-14 05:52:08 +08:00
|
|
|
|
});
|
|
|
|
|
|
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
|
2026-04-29 12:24:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
|
/// 加载文件树 HTML(SSR)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 文件树与页面树共用同一份 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>,
|
2026-05-16 07:11:06 +08:00
|
|
|
|
active_row_id: Option<&str>,
|
2026-04-29 14:36:24 +08:00
|
|
|
|
) -> 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 {
|
2026-05-14 05:52:08 +08:00
|
|
|
|
Ok(snapshot) => Some((snapshot.projection, false)),
|
2026-04-29 14:36:24 +08:00
|
|
|
|
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": {}
|
|
|
|
|
|
});
|
2026-05-14 05:52:08 +08:00
|
|
|
|
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.map(|projection| (projection, true))
|
2026-04-29 14:36:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
Err(_) => None,
|
|
|
|
|
|
};
|
2026-05-14 05:52:08 +08:00
|
|
|
|
result.map(|(projection, dev_fixture)| {
|
2026-05-16 07:11:06 +08:00
|
|
|
|
let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id);
|
2026-05-14 05:52:08 +08:00
|
|
|
|
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
|
|
|
|
|
|
mark_dev_fixture_html(html, dev_fixture, "file-tree")
|
2026-04-29 14:36:24 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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}"#
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
pub(crate) fn render_local_sidebar_tree_html(
|
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
|
) -> Result<String, WebError> {
|
|
|
|
|
|
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
2026-05-27 12:53:55 +08:00
|
|
|
|
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 {
|
2026-05-08 00:41:03 +08:00
|
|
|
|
let rows = collect_page_tree_render_rows(&snapshot.projection);
|
2026-05-27 12:53:55 +08:00
|
|
|
|
render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
2026-05-08 00:41:03 +08:00
|
|
|
|
rows,
|
|
|
|
|
|
active_node_id: active_document_id.map(ToOwned::to_owned),
|
|
|
|
|
|
focused_node_id: None,
|
2026-05-27 12:53:55 +08:00
|
|
|
|
})
|
2026-05-08 00:41:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn render_local_file_tree_html(
|
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
|
active_document_id: Option<&str>,
|
2026-05-21 14:39:38 +08:00
|
|
|
|
active_row_id: Option<&str>,
|
2026-05-08 00:41:03 +08:00
|
|
|
|
) -> Result<String, WebError> {
|
2026-05-27 11:31:12 +08:00
|
|
|
|
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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(root_uri, scope)?
|
|
|
|
|
|
} else {
|
|
|
|
|
|
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
|
|
|
|
|
|
};
|
2026-05-21 15:45:11 +08:00
|
|
|
|
let rows =
|
|
|
|
|
|
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
|
2026-05-08 00:41:03 +08:00
|
|
|
|
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
|
|
|
|
|
rows,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
#[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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
2026-05-29 11:13:05 +08:00
|
|
|
|
use crate::app::{build_app, AppConfig, AppState};
|
2026-05-14 05:52:08 +08:00
|
|
|
|
use crate::context::RequestContext;
|
2026-05-29 11:13:05 +08:00
|
|
|
|
use axum::body::{to_bytes, Body};
|
|
|
|
|
|
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
2026-05-27 11:31:12 +08:00
|
|
|
|
use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use serde_json::Value;
|
2026-05-27 11:31:12 +08:00
|
|
|
|
use std::time::{Duration, Instant};
|
2026-04-29 12:24:44 +08:00
|
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
2026-05-26 00:35:35 +08:00
|
|
|
|
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/document-editor-adapter-runtime.js");
|
2026-05-26 01:03:04 +08:00
|
|
|
|
const DOCUMENT_MINDMAP_HOST_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/document-mindmap-host-runtime.js");
|
2026-05-26 00:47:51 +08:00
|
|
|
|
const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js");
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const DOCUMENT_RESOURCE_TAB_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/document-resource-tab-runtime.js");
|
2026-05-26 01:23:27 +08:00
|
|
|
|
const DOCUMENT_SESSION_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/document-session-runtime.js");
|
2026-05-26 00:54:54 +08:00
|
|
|
|
const DOCUMENT_SLASH_POSITION_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/document-slash-position-runtime.js");
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/sidebar-page-settings-runtime.js");
|
2026-05-26 00:44:07 +08:00
|
|
|
|
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/document-tiptap-conversion-runtime.js");
|
2026-05-26 00:35:35 +08:00
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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,
|
2026-05-16 22:03:14 +08:00
|
|
|
|
enable_editor_actor: true,
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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": []}],
|
2026-05-19 08:07:17 +08:00
|
|
|
|
"editorDocument": {
|
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
|
"rootBlockIds": ["editor_1"],
|
|
|
|
|
|
"blocks": [{
|
|
|
|
|
|
"blockId": "editor_1",
|
|
|
|
|
|
"blockType": "paragraph",
|
|
|
|
|
|
"contentNodes": [{
|
|
|
|
|
|
"payload": {"type": "text", "text": "来自 editorDocument 的正文"},
|
|
|
|
|
|
"attrs": {}
|
|
|
|
|
|
}],
|
|
|
|
|
|
"childBlockIds": []
|
|
|
|
|
|
}]
|
|
|
|
|
|
},
|
2026-04-29 12:24:44 +08:00
|
|
|
|
"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(),
|
|
|
|
|
|
}))
|
2026-05-19 08:07:17 +08:00
|
|
|
|
.layer(axum::middleware::from_fn(inject_test_actor))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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,
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
|
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");
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 23:38:42 +08:00
|
|
|
|
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 sqlite 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 sqlite read");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
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(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
#[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_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,
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn document_shell_returns_page_aggregate_snapshot() {
|
|
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/documents/doc_1?workspaceId=ws_demo")
|
2026-05-28 22:01:44 +08:00
|
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-04-29 12:24:44 +08:00
|
|
|
|
.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")
|
|
|
|
|
|
);
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get_all("set-cookie")
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|value| value
|
|
|
|
|
|
.to_str()
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
.contains("mnote_recent_page_id=doc_1")));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.expect("body");
|
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
2026-05-26 00:35:35 +08:00
|
|
|
|
let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS;
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert!(html.contains("data-testid=\"wolai-sidebar\""));
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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\""));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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\""));
|
2026-04-29 14:36:24 +08:00
|
|
|
|
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
|
|
|
|
|
|
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
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"));
|
2026-05-11 13:16:34 +08:00
|
|
|
|
assert!(html.contains("const currentTarget = resolveTitleTarget(input);"));
|
|
|
|
|
|
assert!(html.contains("documentId: currentTarget.documentId"));
|
2026-05-06 21:44:20 +08:00
|
|
|
|
assert!(html.contains(
|
2026-05-25 23:34:03 +08:00
|
|
|
|
r#".tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title"#
|
2026-05-06 21:44:20 +08:00
|
|
|
|
));
|
2026-05-16 07:11:06 +08:00
|
|
|
|
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)"#
|
|
|
|
|
|
));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("row.getAttribute('data-row-id') === `doc:${documentId}`"));
|
|
|
|
|
|
assert!(!runtime.contains("row.getAttribute('data-row-id') === `index:${documentId}`"));
|
2026-04-30 06:58:17 +08:00
|
|
|
|
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
|
2026-05-06 21:44:20 +08:00
|
|
|
|
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\""));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
|
2026-05-08 23:15:00 +08:00
|
|
|
|
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\""));
|
2026-05-20 14:20:48 +08:00
|
|
|
|
assert!(html.contains("data-mnote-main-tab-strip"));
|
2026-05-20 17:47:09 +08:00
|
|
|
|
assert!(html.contains("class=\"mnote-main-tab-badge\""));
|
|
|
|
|
|
assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
|
2026-05-21 05:40:06 +08:00
|
|
|
|
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("pageTab.setAttribute('data-document-id', documentId);"));
|
2026-05-20 17:47:09 +08:00
|
|
|
|
assert!(html.contains("data-mnote-tab-badge-kind"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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("mnote.open_editors_snapshot.v1"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("getOpenEditorsSnapshot"));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
|
|
|
|
|
|
assert!(resource_runtime.contains("data-mnote-tab-strip-bound"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("currentWebShellDocumentId"));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(resource_runtime.contains(
|
2026-05-23 23:38:42 +08:00
|
|
|
|
"const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();"
|
|
|
|
|
|
));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(resource_runtime.contains(
|
2026-05-23 23:38:42 +08:00
|
|
|
|
"if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;"
|
|
|
|
|
|
));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(!html
|
|
|
|
|
|
.contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })"));
|
2026-05-26 00:54:54 +08:00
|
|
|
|
let slash_runtime = DOCUMENT_SLASH_POSITION_RUNTIME_JS;
|
|
|
|
|
|
assert!(slash_runtime.contains("positionSlashMenuForRoot"));
|
|
|
|
|
|
assert!(slash_runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("installGlobalSlashMenuPositioning();"));
|
2026-05-26 00:54:54 +08:00
|
|
|
|
assert!(slash_runtime.contains("data-mnote-slash-positioned', 'host'"));
|
|
|
|
|
|
assert!(slash_runtime.contains(
|
2026-05-21 05:40:06 +08:00
|
|
|
|
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
|
|
|
|
|
|
));
|
2026-05-26 00:54:54 +08:00
|
|
|
|
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)")
|
|
|
|
|
|
);
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(!runtime.contains(
|
2026-05-21 05:40:06 +08:00
|
|
|
|
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
|
|
|
|
|
|
));
|
2026-05-26 00:54:54 +08:00
|
|
|
|
assert!(slash_runtime.contains("data-mnote-side-target-unsupported') !== 'true'"));
|
2026-05-20 17:47:09 +08:00
|
|
|
|
assert!(
|
2026-05-26 00:35:35 +08:00
|
|
|
|
runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
|
2026-05-20 17:47:09 +08:00
|
|
|
|
);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(html.contains("data-mnote-resource-tab-host"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("openPrimaryMindmap"));
|
|
|
|
|
|
assert!(runtime.contains("openResourceInActiveTab"));
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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>"#
|
|
|
|
|
|
));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
|
|
|
|
|
|
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
|
2026-05-26 01:03:04 +08:00
|
|
|
|
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"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
|
|
|
|
|
|
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
|
|
|
|
|
assert!(html.contains("/api/tree/events"));
|
2026-05-25 23:34:03 +08:00
|
|
|
|
assert!(html.contains("/api/realtime/ws"));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
let session_runtime = DOCUMENT_SESSION_RUNTIME_JS;
|
|
|
|
|
|
assert!(session_runtime.contains("syncPageAggregateScript(session, nextAggregate);"));
|
2026-05-26 00:44:07 +08:00
|
|
|
|
let conversion_runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
|
|
|
|
|
|
assert!(conversion_runtime.contains(
|
2026-05-20 10:43:38 +08:00
|
|
|
|
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
|
|
|
|
|
|
));
|
2026-05-26 00:44:07 +08:00
|
|
|
|
assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document"));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")
|
|
|
|
|
|
);
|
2026-05-26 09:44:35 +08:00
|
|
|
|
assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref"));
|
|
|
|
|
|
assert!(conversion_runtime.contains("mnote-uploaded-attachment-code"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(conversion_runtime
|
|
|
|
|
|
.contains("class: mergeClassNames(mark.attrs.class, attachmentClass)"));
|
|
|
|
|
|
assert!(session_runtime
|
|
|
|
|
|
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
assert!(session_runtime.contains(
|
2026-05-20 10:43:38 +08:00
|
|
|
|
"const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
|
|
|
|
|
|
));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1"));
|
|
|
|
|
|
assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()"));
|
|
|
|
|
|
assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);"));
|
|
|
|
|
|
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')"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(!runtime.contains("mnote-web-document-shell"));
|
2026-05-20 20:13:24 +08:00
|
|
|
|
|
|
|
|
|
|
// Resource open resolver contract
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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)"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("data-testid=\"mnote-secondary-editor-tab-host\""));
|
2026-05-23 23:38:42 +08:00
|
|
|
|
assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\""));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(resource_runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'"));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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)"));
|
2026-05-21 05:40:06 +08:00
|
|
|
|
assert!(
|
2026-05-26 01:15:28 +08:00
|
|
|
|
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
|
2026-05-21 05:40:06 +08:00
|
|
|
|
);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(resource_runtime
|
|
|
|
|
|
.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);"));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("activeResourceWidthPreference(options)"));
|
2026-05-28 22:01:44 +08:00
|
|
|
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote:active-resource-tab-changed"));
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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);
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-mnote-page-aggregate-owner")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
2026-05-14 05:52:08 +08:00
|
|
|
|
Some("compat-join")
|
2026-04-30 05:46:36 +08:00
|
|
|
|
);
|
2026-05-08 00:41:03 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get(header::CACHE_CONTROL)
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("no-store")
|
|
|
|
|
|
);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
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");
|
2026-05-14 05:52:08 +08:00
|
|
|
|
assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
|
2026-04-30 05:46:36 +08:00
|
|
|
|
assert_eq!(payload["result"]["projectionVersion"], 1);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
|
|
|
|
|
assert_eq!(payload["result"]["body"]["revision"], 7);
|
2026-05-19 08:07:17 +08:00
|
|
|
|
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 的正文"
|
|
|
|
|
|
);
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
#[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 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 11:13:05 +08:00
|
|
|
|
#[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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
#[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 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:03:14 +08:00
|
|
|
|
#[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()),
|
2026-05-28 22:01:44 +08:00
|
|
|
|
Some("convex_retired")
|
2026-05-16 22:03:14 +08:00
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-error-phase")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("query_send")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response
|
|
|
|
|
|
.headers()
|
|
|
|
|
|
.get("x-upstream-service")
|
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
|
Some("convex")
|
|
|
|
|
|
);
|
|
|
|
|
|
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);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
assert_eq!(payload["code"], "convex_retired");
|
2026-05-16 22:03:14 +08:00
|
|
|
|
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")
|
2026-05-28 22:01:44 +08:00
|
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-05-16 22:03:14 +08:00
|
|
|
|
.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()),
|
2026-05-28 22:01:44 +08:00
|
|
|
|
Some("convex_retired")
|
2026-05-16 22:03:14 +08:00
|
|
|
|
);
|
|
|
|
|
|
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);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
assert_eq!(payload["code"], "convex_retired");
|
2026-05-16 22:03:14 +08:00
|
|
|
|
assert!(!text.contains("mnote.page_aggregate.v1"));
|
|
|
|
|
|
assert!(!text.contains("data-mnote-dev-fixture"));
|
|
|
|
|
|
assert!(!text.contains("data-page-aggregate-snapshot"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
#[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);
|
2026-05-20 10:43:38 +08:00
|
|
|
|
std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
std::fs::write(
|
2026-05-20 10:43:38 +08:00
|
|
|
|
root.join("Local Aggregate").join("Local Aggregate.md"),
|
|
|
|
|
|
"# Local Heading\n正文内容\n",
|
2026-05-08 00:41:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
.expect("write local md");
|
|
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri(format!(
|
2026-05-20 10:43:38 +08:00
|
|
|
|
"/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
|
2026-05-08 00:41:03 +08:00
|
|
|
|
))
|
2026-05-19 08:07:17 +08:00
|
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-05-08 00:41:03 +08:00
|
|
|
|
.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"],
|
2026-05-20 10:43:38 +08:00
|
|
|
|
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
|
2026-05-08 00:41:03 +08:00
|
|
|
|
);
|
2026-05-24 01:49:51 +08:00
|
|
|
|
// 本地 Markdown 标题来自文件名;正文 H1 只作为正文内容。
|
|
|
|
|
|
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
|
|
|
|
|
|
assert_eq!(payload["result"]["body"]["revision"], 0);
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(payload["result"]["body"]["content"]
|
|
|
|
|
|
.to_string()
|
|
|
|
|
|
.contains("Local Heading"));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 23:38:42 +08:00
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn page_aggregate_endpoint_allows_sqlite_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,
|
|
|
|
|
|
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("sqlite 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"
|
|
|
|
|
|
);
|
2026-05-25 23:34:03 +08:00
|
|
|
|
assert_eq!(aggregate.head.title, "Grant Root");
|
|
|
|
|
|
assert!(aggregate.body.content.to_string().contains("Grant Heading"));
|
2026-05-23 23:38:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 11:13:05 +08:00
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_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,
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 11:31:12 +08:00
|
|
|
|
#[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}"
|
|
|
|
|
|
);
|
2026-05-27 12:53:55 +08:00
|
|
|
|
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.store(400, std::sync::atomic::Ordering::SeqCst);
|
2026-05-27 11:31:12 +08:00
|
|
|
|
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_sqlite_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,
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 16:32:05 +08:00
|
|
|
|
#[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");
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(put_payload["result"]["scopeId"]
|
|
|
|
|
|
.as_str()
|
|
|
|
|
|
.expect("scope id")
|
|
|
|
|
|
.starts_with("filetree:"));
|
2026-05-27 16:32:05 +08:00
|
|
|
|
|
|
|
|
|
|
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"], "sqlite");
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
#[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);
|
2026-05-20 10:43:38 +08:00
|
|
|
|
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");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
|
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri(format!(
|
2026-05-20 10:43:38 +08:00
|
|
|
|
"/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
2026-05-08 00:41:03 +08:00
|
|
|
|
))
|
2026-05-19 08:07:17 +08:00
|
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-05-08 00:41:03 +08:00
|
|
|
|
.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\""));
|
2026-05-19 08:07:17 +08:00
|
|
|
|
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
assert!(html.contains("Child Page"));
|
|
|
|
|
|
assert!(html.contains("asset.png"));
|
2026-05-28 22:01:44 +08:00
|
|
|
|
assert!(html.contains("data-row-kind="));
|
2026-05-11 13:16:34 +08:00
|
|
|
|
assert!(html.contains("data-page-openable=\"false\""));
|
2026-05-25 23:34:03 +08:00
|
|
|
|
assert!(html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
|
|
|
|
|
|
assert!(html.contains("/api/mnote-browser-runtime/filetree-runtime.js"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);"));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
assert!(session_runtime.contains("if (!documentId) return;"));
|
|
|
|
|
|
assert!(session_runtime.contains("new EventSource(url.toString())"));
|
|
|
|
|
|
assert!(session_runtime.contains("localFolderEventRegistry"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(session_runtime
|
|
|
|
|
|
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
|
|
|
|
|
|
.contains("clearSessionConflictSurface(view.session);"));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(session_runtime
|
|
|
|
|
|
.contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)"));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
assert!(session_runtime.contains("agent run"));
|
|
|
|
|
|
assert!(!session_runtime.contains(
|
2026-05-09 06:24:50 +08:00
|
|
|
|
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
|
|
|
|
|
));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 01:35:19 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn document_conflict_panel_runtime_contains_dom_helpers() {
|
|
|
|
|
|
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
|
|
|
|
|
|
include_str!("../../browser/document-conflict-panel-runtime.js");
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function clearSessionConflictSurface"));
|
2026-05-25 01:35:19 +08:00
|
|
|
|
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function createSessionConflictPanel"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
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"));
|
2026-05-25 01:35:19 +08:00
|
|
|
|
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"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
|
|
|
|
|
|
.contains("function populateSessionConflictDiffPanel"));
|
|
|
|
|
|
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
|
|
|
|
|
|
.contains("data-testid', 'mnote-conflict-current-text"));
|
2026-05-25 02:44:19 +08:00
|
|
|
|
assert!(
|
|
|
|
|
|
DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-disk-text")
|
|
|
|
|
|
);
|
2026-05-29 11:13:05 +08:00
|
|
|
|
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"));
|
2026-05-25 02:44:19 +08:00
|
|
|
|
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"));
|
2026-05-25 08:38:05 +08:00
|
|
|
|
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function mountSessionConflictPanel"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
|
|
|
|
|
|
.contains("runSessionConflictAction: runSessionConflictAction"));
|
2026-05-25 08:38:05 +08:00
|
|
|
|
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction"));
|
2026-05-25 17:36:17 +08:00
|
|
|
|
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"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
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"));
|
2026-05-25 01:35:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:35:35 +08:00
|
|
|
|
#[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"));
|
2026-05-26 01:03:04 +08:00
|
|
|
|
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"));
|
2026-05-26 00:47:51 +08:00
|
|
|
|
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"));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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"));
|
2026-05-26 01:23:27 +08:00
|
|
|
|
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"));
|
2026-05-26 16:47:03 +08:00
|
|
|
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
|
|
|
|
|
|
.contains("syncPageAggregateScript({ pageAggregateScriptId"));
|
2026-05-26 17:24:05 +08:00
|
|
|
|
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)")
|
|
|
|
|
|
);
|
2026-05-26 00:54:54 +08:00
|
|
|
|
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"));
|
2026-05-26 00:44:07 +08:00
|
|
|
|
assert!(
|
|
|
|
|
|
DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js")
|
|
|
|
|
|
);
|
2026-05-26 09:44:35 +08:00
|
|
|
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("enhanceEditorAttachmentLinksSoon"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
|
|
|
|
|
|
.contains("window.__mnoteEnhanceEditorAttachmentLinks"));
|
2026-05-26 00:44:07 +08:00
|
|
|
|
assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument"));
|
|
|
|
|
|
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 05:52:08 +08:00
|
|
|
|
#[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,
|
2026-05-16 22:03:14 +08:00
|
|
|
|
enable_editor_actor: true,
|
2026-05-14 05:52:08 +08:00
|
|
|
|
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 =
|
2026-05-16 07:11:06 +08:00
|
|
|
|
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
|
2026-05-14 05:52:08 +08:00
|
|
|
|
let workspace_projection = super::load_workspace_shell_projection(
|
2026-05-27 11:31:12 +08:00
|
|
|
|
None,
|
2026-05-14 05:52:08 +08:00
|
|
|
|
&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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 19:05:06 +08:00
|
|
|
|
#[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());
|
2026-05-19 08:07:17 +08:00
|
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-09 19:05:06 +08:00
|
|
|
|
let response = app()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri(format!(
|
|
|
|
|
|
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
|
|
|
|
|
|
))
|
2026-05-19 08:07:17 +08:00
|
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-05-09 19:05:06 +08:00
|
|
|
|
.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"));
|
2026-05-20 10:43:38 +08:00
|
|
|
|
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
|
|
|
|
|
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
2026-05-09 19:05:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
|
#[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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
#[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");
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
|
2026-05-26 00:44:07 +08:00
|
|
|
|
let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
|
2026-05-26 00:35:35 +08:00
|
|
|
|
assert!(runtime.contains("legacyInlineContentToTiptap"));
|
|
|
|
|
|
assert!(runtime.contains("legacyStylesToTiptapMarks"));
|
|
|
|
|
|
assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
|
|
|
|
|
|
assert!(runtime.contains("firstNonEmptyText(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'"));
|
2026-05-29 11:13:05 +08:00
|
|
|
|
assert!(DOCUMENT_SESSION_RUNTIME_JS
|
|
|
|
|
|
.contains("expectedFileVersion: session.conflictDetectionKey"));
|
2026-05-26 00:35:35 +08:00
|
|
|
|
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(
|
2026-05-08 00:41:03 +08:00
|
|
|
|
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
|
}
|