fix local markdown attachment regressions

This commit is contained in:
lix-2026
2026-05-29 11:13:05 +08:00
parent 1109e3c0d8
commit cbe789e034
63 changed files with 3249 additions and 1502 deletions
+38 -1
View File
@@ -4,12 +4,16 @@ use crate::editor_actor::EditorRuntimeActor;
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
use crate::middleware::request_context::inject_request_context;
use crate::routes::build_router;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use std::env;
use std::fs;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
#[derive(Debug, Clone)]
pub struct AppConfig {
@@ -192,10 +196,43 @@ fn open_control_plane_store() -> SqliteControlPlaneStore {
pub fn build_app(state: AppState) -> Router {
build_router(state)
.layer(TraceLayer::new_for_http())
.layer(TraceLayer::new_for_http().on_failure(()))
.layer(axum::middleware::from_fn(log_failed_response))
.layer(axum::middleware::from_fn(inject_request_context))
}
async fn log_failed_response(request: Request, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let response = next.run(request).await;
let status = response.status();
if status.is_server_error() {
let error_code = response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok())
.unwrap_or("");
if error_code == "convex_retired" {
warn!(
method = %method,
uri = %uri,
status = %status,
error_code = %error_code,
"退役 Convex 兼容路径被请求"
);
} else {
error!(
method = %method,
uri = %uri,
status = %status,
error_code = %error_code,
"mnote-web 请求返回服务端错误"
);
}
}
response
}
#[cfg(test)]
mod tests {
use super::*;
@@ -556,7 +556,10 @@ mod tests {
Some("save:op:abc".into()),
)
.expect("保存后应更新 buffer");
assert_eq!(saved.last_write_intent_id.as_deref(), Some("intent:editor:abc"));
assert_eq!(
saved.last_write_intent_id.as_deref(),
Some("intent:editor:abc")
);
assert_eq!(saved.last_save_operation_id.as_deref(), Some("save:op:abc"));
let outcome = store
@@ -566,7 +569,10 @@ mod tests {
Some("external-editor".into()),
)
.expect("watcher 回声应返回 outcome");
assert!(outcome.self_write_echo, "同版本 watcher 回声应标记为自写回声");
assert!(
outcome.self_write_echo,
"同版本 watcher 回声应标记为自写回声"
);
assert_eq!(
outcome.buffer.last_write_intent_id.as_deref(),
Some("intent:editor:abc")
@@ -5,7 +5,7 @@ use crate::routes::{
};
use notify::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -419,12 +419,12 @@ fn system_time_ms(time: SystemTime) -> u128 {
#[cfg(test)]
mod tests {
use super::{
LocalFolderWatcherRegistry, is_local_search_index_path,
refresh_local_search_index_for_event, should_emit_event_kind,
is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind,
LocalFolderWatcherRegistry,
};
use crate::document_buffer_store::BufferStore;
use notify::EventKind;
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
use notify::EventKind;
fn test_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!(
@@ -351,6 +351,7 @@ impl PageAggregateBuilder {
block_document: block_document.unwrap_or(Value::Null),
block_projection_version,
projection_source: "builder.content".into(),
attachment_refs: Value::Array(Vec::new()),
},
tree: PageTree {
page_subtree: self.page_subtree,
+3 -3
View File
@@ -4,12 +4,12 @@ use crate::error::WebError;
use crate::routes::query_support::{
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -152,7 +152,7 @@ pub async fn trace(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -2,16 +2,16 @@ use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::{
RetiredCloudCommandExecution, execute_retired_command_plan,
execute_retired_command_plan_with_artifacts,
execute_retired_command_plan, execute_retired_command_plan_with_artifacts,
RetiredCloudCommandExecution,
};
use bridge_runtime::{
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
RuntimeTargetWire, execute_runtime_input,
RuntimeTargetWire,
};
use serde_json::Value;
use serde_json::json;
use serde_json::Value;
pub fn runtime_context(
context: &RequestContext,
+1 -1
View File
@@ -56,7 +56,7 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
+5 -1
View File
@@ -1,6 +1,6 @@
use axum::Json;
use axum::http::{HeaderMap, HeaderValue};
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -34,6 +34,10 @@ pub fn dev_hot_reload_enabled() -> bool {
)
}
pub fn dev_hot_cache_buster() -> Option<&'static str> {
dev_hot_reload_enabled().then_some(DEV_HOT_BOOT_ID.as_str())
}
pub async fn hot_reload() -> impl IntoResponse {
let mut headers = HeaderMap::new();
headers.insert(
+22 -27
View File
@@ -13,15 +13,15 @@ use crate::routes::query_support::{
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
@@ -1036,10 +1036,9 @@ pub async fn title(
}),
preflight_data: None,
reason: Some("mnote-web page title update".into()),
refs: vec![
body.command_name
.unwrap_or_else(|| "page.head.updateTitle".into()),
],
refs: vec![body
.command_name
.unwrap_or_else(|| "page.head.updateTitle".into())],
dry_run: false,
validate_only: false,
};
@@ -1141,10 +1140,9 @@ pub async fn options(
}),
preflight_data: None,
reason: Some("mnote-web page layout update".into()),
refs: vec![
body.command_name
.unwrap_or_else(|| "page.layout.updateOptions".into()),
],
refs: vec![body
.command_name
.unwrap_or_else(|| "page.layout.updateOptions".into())],
dry_run: false,
validate_only: false,
};
@@ -1181,9 +1179,9 @@ pub async fn options(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
use axum::body::{to_bytes, Body};
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -1515,7 +1513,8 @@ mod tests {
"upsert_document"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"],
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
["title"],
"服务端页面(改名)"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
@@ -1796,19 +1795,15 @@ mod tests {
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
Some(stale_file_version.as_str())
);
assert!(
payload["details"]["conflict"]["currentDiskVersion"]
.as_str()
.unwrap_or("")
.starts_with("local-md:local-md:README.md:")
);
assert!(
payload["details"]["conflict"]["suggestedActions"]
.as_array()
.unwrap()
.iter()
.any(|action| action.as_str() == Some("merge"))
);
assert!(payload["details"]["conflict"]["currentDiskVersion"]
.as_str()
.unwrap_or("")
.starts_with("local-md:local-md:README.md:"));
assert!(payload["details"]["conflict"]["suggestedActions"]
.as_array()
.unwrap()
.iter()
.any(|action| action.as_str() == Some("merge")));
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("# External"));
assert!(!markdown.contains("# Editor"));
+6 -6
View File
@@ -2,14 +2,14 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::documents::{
DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
};
use axum::extract::{Extension, Json, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -1763,10 +1763,10 @@ pub async fn transform_runtime_snapshot(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
+38 -53
View File
@@ -25,17 +25,17 @@ use crate::workspace_shell::{
use axum::body::Body;
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderName, HeaderValue, Request, StatusCode, Uri, header};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use control_plane::{
AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, session_token_hash,
session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput,
};
use futures_util::{SinkExt, StreamExt};
use leptos::prelude::InnerHtmlAttribute;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
@@ -560,6 +560,7 @@ pub async fn root_entry(
active_page_title={active_page_title.clone()}
navigation_html={navigation_html.clone().unwrap_or_default()}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={active_source_kind.as_deref() == Some("local_folder")}
/>
})
};
@@ -622,7 +623,7 @@ pub async fn root_entry(
workspace_sidebar_html={workspace_sidebar_html.clone()}
page_subtree_json={page_subtree_json}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={active_source_kind.as_deref() != Some("local_folder")}
enable_tree_live={true}
/>
});
let body_extra = format!(
@@ -645,7 +646,7 @@ pub async fn root_entry(
let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") {
render_editor_runtime_preload_links()
} else {
""
String::new()
};
let mut response = Html(format!(
r#"<!doctype html>
@@ -2448,9 +2449,9 @@ fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> Strin
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
@@ -3019,7 +3020,7 @@ mod tests {
#[tokio::test]
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
let app_state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -3486,9 +3487,7 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
assert!(
html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)
);
assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
assert!(
!html.contains(r#"data-node-id="local-md:Home.md""#),
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
@@ -3672,14 +3671,12 @@ mod tests {
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert!(
response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("text/html")
);
assert!(response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("text/html"));
}
#[tokio::test]
@@ -3734,16 +3731,12 @@ mod tests {
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values.iter().any(|value| value.contains("mnote_session=")));
assert!(
values
.iter()
.any(|value| value.contains("mnote_actor_id=new-user"))
);
assert!(
values
.iter()
.any(|value| value.contains("mnote_actor_type=user"))
);
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_id=new-user")));
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_type=user")));
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
@@ -3822,12 +3815,10 @@ mod tests {
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "auth_signup_email_required");
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("注册账号时请填写邮箱")
);
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("注册账号时请填写邮箱"));
}
#[tokio::test]
@@ -3851,11 +3842,9 @@ mod tests {
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(
values
.iter()
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0"))
);
assert!(values
.iter()
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
}
#[tokio::test]
@@ -3961,16 +3950,12 @@ mod tests {
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(
!values
.iter()
.any(|value| value.contains("__convexAuthJWT="))
);
assert!(
!values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken="))
);
assert!(!values
.iter()
.any(|value| value.contains("__convexAuthJWT=")));
assert!(!values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=")));
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
}
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, State};
use axum::Json;
use serde::Serialize;
#[derive(Debug, Serialize)]
+11 -13
View File
@@ -1,15 +1,15 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::Json;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
execute_runtime_query, runtime_input_requests_result,
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
runtime_input_requests_result, RuntimeInput,
};
use serde::Serialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
@@ -150,10 +150,10 @@ fn stamp_ai_bridge_headers() -> HeaderMap {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -253,12 +253,10 @@ mod tests {
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert!(
payload["eventStreamEndpoint"]
.as_str()
.unwrap_or_default()
.contains("/api/hermes/events/")
);
assert!(payload["eventStreamEndpoint"]
.as_str()
.unwrap_or_default()
.contains("/api/hermes/events/"));
}
#[tokio::test]
@@ -4,15 +4,15 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::manifest;
use crate::transport::convex::{execute_retired_mutation_by_name, execute_retired_query_by_name};
use axum::Json;
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
use axum::Json;
use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput};
use futures_util::TryStreamExt;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::env;
use std::fs;
@@ -1049,7 +1049,7 @@ pub async fn resume_session(
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
if is_acp_session_query(&query) {
let result = get_acp_session(&state, &context, &session_id, &query).await?;
let mut payload = result.2.0;
let mut payload = result.2 .0;
payload["resumed"] = Value::Bool(true);
payload["resumeSource"] = payload
.get("persistence")
@@ -1573,8 +1573,8 @@ pub async fn create_run(
Some(&registration.profile),
)
.await?;
if let Some(runtime) = register_runtime_from_create_run_response(&registration, &result.2.0) {
let mut payload = result.2.0;
if let Some(runtime) = register_runtime_from_create_run_response(&registration, &result.2 .0) {
let mut payload = result.2 .0;
payload["runtime"] = runtime;
Ok((result.0, result.1, Json(payload)))
} else {
@@ -2230,7 +2230,11 @@ fn profile_home(profile: &str) -> PathBuf {
return home;
}
let candidate = home.join("profiles").join(profile);
if candidate.exists() { candidate } else { home }
if candidate.exists() {
candidate
} else {
home
}
}
fn profile_config_path(profile: &str) -> PathBuf {
@@ -6325,8 +6329,8 @@ fn stamp_client_headers_into(headers: &mut HeaderMap) {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::Request;
use axum::routing::{get, post};
use control_plane::{DirectoryGrantInput, UpsertAiRuntimeRunInput, UpsertUserInput};
@@ -6379,16 +6383,12 @@ mod tests {
let files = changed.as_array().expect("changed files");
assert_eq!(files.len(), 2);
assert!(
files
.iter()
.any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" })
);
assert!(
files
.iter()
.any(|file| { file["path"] == "b.md" && file["changeType"] == "added" })
);
assert!(files
.iter()
.any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" }));
assert!(files
.iter()
.any(|file| { file["path"] == "b.md" && file["changeType"] == "added" }));
assert!(!files.iter().any(|file| {
file["path"]
.as_str()
@@ -6424,11 +6424,9 @@ mod tests {
assert!(files.iter().any(|file| {
file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified"
}));
assert!(
files.iter().any(|file| {
file["path"] == "office/a.docx" && file["changeType"] == "modified"
})
);
assert!(files
.iter()
.any(|file| { file["path"] == "office/a.docx" && file["changeType"] == "modified" }));
let _ = std::fs::remove_dir_all(&root);
}
@@ -8469,10 +8467,8 @@ mod tests {
let instructions = body["instructions"].as_str().expect("instructions");
assert!(instructions.contains("\"sourceKind\":\"local_folder\""));
assert!(instructions.contains("\"fileReference\""));
assert!(
instructions
.contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\"")
);
assert!(instructions
.contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\""));
assert!(instructions.contains("\"aiAccessScope\""));
assert!(instructions.contains("\"allowedRoots\""));
assert!(instructions.contains("\"editorTarget\""));
@@ -8493,10 +8489,8 @@ mod tests {
assert!(
!instructions.contains("恶意 runTargetSnapshot.editorTarget 正文不应进入 instructions")
);
assert!(
!instructions
.contains("恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions")
);
assert!(!instructions
.contains("恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions"));
assert!(!instructions.contains("\"contextBlocks\""));
assert!(!instructions.contains("\"pageXml\""));
assert!(!instructions.contains("\"pageText\""));
@@ -8595,11 +8589,10 @@ mod tests {
env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str),
Some("/mnt/Data1T/Mnote_data/users/user_1/我的空间")
);
assert!(
!env.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
.unwrap()
.contains("/mnt/Data1T/Mnote_data/users/user_2")
);
assert!(!env
.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
.unwrap()
.contains("/mnt/Data1T/Mnote_data/users/user_2"));
let scope = serde_json::from_str::<serde_json::Value>(
env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"),
)
@@ -8669,12 +8662,10 @@ mod tests {
payload["allowedRoots"][0]["source"],
"sqlite_directory_grant"
);
assert!(
payload["allowedRoots"][0]["grantIds"]
.as_array()
.map(|items| !items.is_empty())
.unwrap_or(false)
);
assert!(payload["allowedRoots"][0]["grantIds"]
.as_array()
.map(|items| !items.is_empty())
.unwrap_or(false));
assert!(!payload.to_string().contains("file:///tmp/evil"));
}
+77 -117
View File
@@ -2,11 +2,11 @@ use super::hermes_client;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{ToolCallInput, artifact, block, doc, manifest, page, resource};
use axum::Json;
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use serde_json::{Value, json};
use axum::Json;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::fs::{self, File, OpenOptions};
@@ -827,10 +827,10 @@ fn stamp_tool_headers() -> HeaderMap {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::fs;
use std::sync::Mutex;
@@ -1112,56 +1112,36 @@ mod tests {
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.plan_update")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.replace")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.insert_after")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.delete")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.move_after")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.fetch")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.office.fetch_summary")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.office.propose_changes")
);
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.replace"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.insert_after"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.delete"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.block.move_after"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
assert!(tools
.iter()
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
let page_save = tools
.iter()
.find(|tool| tool["name"] == "mnote.page.save")
@@ -1209,20 +1189,16 @@ mod tests {
}
assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object());
assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
assert!(
markdown_edit["inputSchema"]["anyOf"]
.as_array()
.expect("anyOf")
.iter()
.any(|rule| rule["required"] == json!(["operations"]))
);
assert!(
markdown_edit["inputSchema"]["anyOf"]
.as_array()
.expect("anyOf")
.iter()
.any(|rule| rule["required"] == json!(["full_content"]))
);
assert!(markdown_edit["inputSchema"]["anyOf"]
.as_array()
.expect("anyOf")
.iter()
.any(|rule| rule["required"] == json!(["operations"])));
assert!(markdown_edit["inputSchema"]["anyOf"]
.as_array()
.expect("anyOf")
.iter()
.any(|rule| rule["required"] == json!(["full_content"])));
}
#[tokio::test]
@@ -1252,18 +1228,14 @@ mod tests {
.find(|tool| tool["name"] == "mnote.page.save")
.expect("page save tool");
assert!(
markdown_edit["description"]
.as_str()
.expect("description")
.contains("兼容")
);
assert!(
markdown_edit["description"]
.as_str()
.expect("description")
.contains("agent 原生 patch/diff")
);
assert!(markdown_edit["description"]
.as_str()
.expect("description")
.contains("兼容"));
assert!(markdown_edit["description"]
.as_str()
.expect("description")
.contains("agent 原生 patch/diff"));
assert_eq!(
page_save["annotations"]["requiresWritePermission"],
Value::Bool(true)
@@ -1454,12 +1426,10 @@ mod tests {
payload["result"]["objectIdentity"],
"resource:mindmap:local-md:README.md:mind_allowed"
);
assert!(
payload["result"]["markdownSummary"]
.as_str()
.unwrap_or_default()
.contains("中心主题")
);
assert!(payload["result"]["markdownSummary"]
.as_str()
.unwrap_or_default()
.contains("中心主题"));
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
let _ = fs::remove_dir_all(&root);
}
@@ -1795,12 +1765,10 @@ mod tests {
assert_eq!(payload["toolName"], "mnote.page.get");
assert_eq!(payload["toolCallId"], "call_1");
assert_eq!(payload["result"]["title"], "服务端页面");
assert!(
payload["result"]["bodySummary"]
.as_str()
.unwrap_or_default()
.contains("章节一")
);
assert!(payload["result"]["bodySummary"]
.as_str()
.unwrap_or_default()
.contains("章节一"));
assert_eq!(payload["audit"]["effect"], "read");
}
@@ -1846,12 +1814,10 @@ mod tests {
json!("heading_1")
);
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
assert!(
payload["result"]["blocks"][0]["revisionRef"]
.as_str()
.unwrap_or_default()
.starts_with("pageRev:7:block:heading_1:hash:")
);
assert!(payload["result"]["blocks"][0]["revisionRef"]
.as_str()
.unwrap_or_default()
.starts_with("pageRev:7:block:heading_1:hash:"));
}
#[tokio::test]
@@ -1978,12 +1944,10 @@ mod tests {
assert_eq!(payload["result"]["scope"], "selection");
assert_eq!(payload["result"]["format"], "page_xml");
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
assert!(
payload["result"]["content"]
.as_str()
.unwrap_or_default()
.contains("<block id=\"heading_1\"")
);
assert!(payload["result"]["content"]
.as_str()
.unwrap_or_default()
.contains("<block id=\"heading_1\""));
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
}
@@ -2246,12 +2210,10 @@ mod tests {
insert_payload["result"]["changedBlocks"][0]["op"],
json!("insert_after")
);
assert!(
insert_payload["result"]["changedBlocks"][0]["blockId"]
.as_str()
.unwrap_or_default()
.starts_with("ai_block_")
);
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
.as_str()
.unwrap_or_default()
.starts_with("ai_block_"));
let delete_response = app()
.oneshot(
@@ -2603,11 +2565,9 @@ mod tests {
.as_array()
.expect("insertedBlockIds");
assert_eq!(inserted_ids.len(), 2);
assert!(
inserted_ids
.iter()
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") })
);
assert!(inserted_ids
.iter()
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
assert_eq!(
payload["result"]["changedBlocks"]
.as_array()
+25 -31
View File
@@ -8,16 +8,16 @@ use crate::routes::local_folder_source::{
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
subtree_query,
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
#[cfg(test)]
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
@@ -264,9 +264,9 @@ pub async fn graph(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
use axum::body::{Body, to_bytes};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use std::time::{Duration, Instant};
@@ -409,19 +409,17 @@ mod tests {
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["projection"], "file_tree");
assert_eq!(payload["result"]["sourceKind"], "local_folder");
assert!(
payload["result"]["items"]
.as_array()
.expect("items")
.iter()
.any(|item| item["title"] == "本地页面"
&& item["rowKind"] == "folder"
&& item["documentId"]
.as_str()
.unwrap_or("")
.starts_with("local-md:")
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")
);
assert!(payload["result"]["items"]
.as_array()
.expect("items")
.iter()
.any(|item| item["title"] == "本地页面"
&& item["rowKind"] == "folder"
&& item["documentId"]
.as_str()
.unwrap_or("")
.starts_with("local-md:")
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面"));
let _ = std::fs::remove_dir_all(&root);
}
@@ -515,11 +513,9 @@ mod tests {
assert!(titles.contains(&"README.md"));
assert!(titles.contains(&"nested"));
assert!(!titles.contains(&"deep.md"));
assert!(
items
.iter()
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs"))
);
assert!(items
.iter()
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs")));
let _ = std::fs::remove_dir_all(&root);
}
@@ -680,13 +676,11 @@ mod tests {
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
"mindmap"
);
assert!(
item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand")
);
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand"));
}
#[tokio::test]
@@ -10,16 +10,16 @@ use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event as SseEvent, Sse};
use futures_util::StreamExt;
use futures_util::stream;
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::convert::Infallible;
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::error::RecvError;
use tokio::time::{MissedTickBehavior, interval, timeout};
use tokio::time::timeout;
type BoxedEventStream =
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
@@ -70,7 +70,12 @@ async fn build_document_events_stream(
.document_id
.as_deref()
.and_then(local_markdown_relative_path_from_document_id)
.or_else(|| query.resource_path.as_deref().and_then(normalize_resource_event_path));
.or_else(|| {
query
.resource_path
.as_deref()
.and_then(normalize_resource_event_path)
});
let subscription = state
.local_folder_watcher_registry()
.subscribe(&canonical_root)
@@ -147,26 +152,15 @@ async fn build_tree_live_stream(
&file_tree_snapshot,
);
let subscription = match state
let subscription = state
.local_folder_watcher_registry()
.subscribe(&canonical_root)
{
Ok(subscription) => subscription,
Err(error) => {
tracing::warn!(
error = %error,
root_uri = %root_uri,
"local_folder tree live watcher unavailable; falling back to revision polling"
);
let stream = build_tree_live_polling_stream(
root_uri,
workspace_id,
revision.revision,
initial_payload,
);
return Ok((HeaderMap::new(), stream));
}
};
.map_err(|error| {
WebError::internal(format!(
"local_folder tree live watcher unavailable: {error}"
))
.with_context(&context)
})?;
let stream = stream::unfold(
(Some(initial_payload), subscription, root_uri, workspace_id),
@@ -224,81 +218,6 @@ async fn build_tree_live_stream(
Ok((HeaderMap::new(), stream))
}
fn build_tree_live_polling_stream(
root_uri: String,
workspace_id: String,
initial_revision: String,
initial_payload: Value,
) -> BoxedEventStream {
let mut poll_interval = interval(Duration::from_millis(1_200));
poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
stream::unfold(
Some(TreeLivePollingState {
root_uri,
workspace_id,
current_revision: initial_revision,
initial_payload: Some(initial_payload),
poll_interval,
}),
|state| async move {
let mut state = state?;
if let Some(payload) = state.initial_payload.take() {
return Some((Ok(stream_event("snapshot", &payload)), Some(state)));
}
loop {
state.poll_interval.tick().await;
let Some((next_revision, resync_payload)) = tree_live_polling_resync_payload(
&state.root_uri,
&state.workspace_id,
&state.current_revision,
) else {
continue;
};
state.current_revision = next_revision;
return Some((Ok(stream_event("resync", &resync_payload)), Some(state)));
}
},
)
.boxed()
}
struct TreeLivePollingState {
root_uri: String,
workspace_id: String,
current_revision: String,
initial_payload: Option<Value>,
poll_interval: tokio::time::Interval,
}
fn tree_live_polling_resync_payload(
root_uri: &str,
workspace_id: &str,
current_revision: &str,
) -> Option<(String, Value)> {
let next_revision = local_folder_watch_revision(root_uri).ok()?;
if next_revision.revision == current_revision {
return None;
}
let resync_payload = rebuild_tree_resync_payload(root_uri, workspace_id)?;
Some((next_revision.revision, resync_payload))
}
fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Value> {
let revision = local_folder_watch_revision(root_uri).ok()?;
let sidebar_snapshot = load_local_folder_page_tree_snapshot(root_uri).ok()?;
let file_tree_snapshot = load_local_folder_file_tree_snapshot(root_uri).ok()?;
Some(build_tree_snapshot_payload(
root_uri,
workspace_id,
&revision.revision,
"resync",
&sidebar_snapshot,
&file_tree_snapshot,
))
}
fn parent_relative_path_for_watch_path(relative_path: &str) -> String {
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
if normalized.is_empty() || normalized == "." {
@@ -472,7 +391,7 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
use axum::body::Body;
use axum::http::Request;
@@ -614,7 +533,10 @@ mod tests {
});
assert!(document_event_targets_relative_path(&payload, &expected));
assert!(!document_event_targets_relative_path(&other_payload, &expected));
assert!(!document_event_targets_relative_path(
&other_payload,
&expected
));
assert!(
normalize_resource_event_path("../escape.pdf").is_none(),
"resource watch path 不能越过 root"
@@ -669,40 +591,6 @@ mod tests {
assert!(payload["data"]["tree"].is_object());
}
#[test]
fn tree_live_polling_resync_payload_tracks_revision_changes() {
let root = test_root("tree-live-polling-resync");
std::fs::write(root.join("README.md"), "# Initial\n").expect("write initial");
let root_uri = format!("file://{}", root.display());
let workspace_id =
local_workspace_id_from_root_uri(&root_uri).expect("resolve local workspace id");
let initial_revision = local_folder_watch_revision(&root_uri)
.expect("initial revision")
.revision;
assert!(
tree_live_polling_resync_payload(&root_uri, &workspace_id, &initial_revision).is_none(),
"revision 未变化时不应发送 resync"
);
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs/new.md"), "# New\n").expect("write new markdown");
let (next_revision, payload) =
tree_live_polling_resync_payload(&root_uri, &workspace_id, &initial_revision)
.expect("revision change should build resync payload");
assert_ne!(next_revision, initial_revision);
assert_eq!(payload["kind"], "resync");
assert_eq!(payload["sourceKind"], "local_folder");
assert_eq!(payload["rootUri"], root_uri);
assert_eq!(payload["workspaceId"], workspace_id);
assert_eq!(payload["revision"], next_revision);
assert!(payload["data"]["dataset"]["kernel_sidebar_projection"].is_object());
assert!(payload["data"]["dataset"]["kernel_file_tree_projection"].is_object());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() {
let root = test_root("tree-live-watch-batch");
@@ -741,13 +629,11 @@ mod tests {
&& parent["reason"].as_str() == Some("child-watch")),
"watch batch 应声明 docs affected parent: {payload}"
);
assert!(
payload["eventKinds"]
.as_array()
.expect("event kinds")
.iter()
.any(|kind| kind.as_str() == Some("Modify(Data)"))
);
assert!(payload["eventKinds"]
.as_array()
.expect("event kinds")
.iter()
.any(|kind| kind.as_str() == Some("Modify(Data)")));
let _ = std::fs::remove_dir_all(root);
}
@@ -6,13 +6,13 @@ use crate::page_aggregate::{
PagePermissions, PageStats, PageTree,
};
use crate::routes::local_markdown_parser::{
file_stem_title, parse_markdown_page, split_frontmatter,
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_search_index;
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::Json;
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::project_legacy_content_to_block_document;
use control_plane::AppendAuditInput;
use control_plane::{
@@ -24,7 +24,7 @@ use core_protocol::{
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use serde_json::{json, Map, Value};
use std::cmp::Ordering;
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet};
@@ -55,8 +55,8 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
> = OnceLock::new();
fn local_page_tree_snapshot_cache()
-> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
fn local_page_tree_snapshot_cache(
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
}
@@ -2966,6 +2966,7 @@ pub fn resolve_local_markdown_page_aggregate(
"本地 rootUri 必须指向目录",
));
}
let root_source_uri = file_uri_for_path(&canonical_root);
let metadata = load_local_folder_metadata(&canonical_root)?;
let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
.ok_or_else(|| {
@@ -2995,6 +2996,20 @@ pub fn resolve_local_markdown_page_aggregate(
&parsed.body,
&attachment_paths,
);
let attachment_refs = parse_markdown_attachment_refs(
&parsed.body,
&markdown_file.path.display().to_string(),
&root_source_uri,
)
.into_iter()
.map(|mut attachment_ref| {
if let Some(path) = attachment_ref.resolved_absolute_path.as_deref() {
let resolved_path = Path::new(path);
attachment_ref.authorized = Some(resolved_path.starts_with(&canonical_root));
}
attachment_ref
})
.collect::<Vec<_>>();
let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
let page_subtree = markdown_page_subtree(document_id, &title, &content);
let workspace_id = local_workspace_id(&canonical_root);
@@ -3063,6 +3078,7 @@ pub fn resolve_local_markdown_page_aggregate(
block_document,
block_projection_version: 1,
projection_source: "local_markdown.content".into(),
attachment_refs: serde_json::to_value(attachment_refs).unwrap_or_else(|_| json!([])),
},
tree: PageTree { page_subtree },
stats: PageStats {
@@ -3927,14 +3943,7 @@ pub(crate) fn write_local_markdown_asset(
));
}
let page_resource_dir =
markdown_page_resource_directory(&markdown_file.path).ok_or_else(|| {
WebError::bad_request_code(
"local_asset_upload_bad_markdown_path",
"无法解析本地页面资源目录",
)
})?;
let asset_dir = page_resource_dir;
let asset_dir = markdown_dir.to_path_buf();
if !asset_dir.starts_with(&canonical_root) {
return Err(WebError::bad_request_code(
"local_folder_root_escape",
@@ -3965,6 +3974,24 @@ pub(crate) fn write_local_markdown_asset(
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?;
let markdown_href = markdown_href_for_relative_path(&markdown_relative_path);
let mut attachment_ref = parse_markdown_attachment_refs(
&format!(
"[{}]({})",
target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(&sanitized_name),
markdown_href
),
&markdown_file.path.display().to_string(),
&file_uri_for_path(&canonical_root),
)
.into_iter()
.next();
if let Some(ref mut value) = attachment_ref {
value.authorized = Some(true);
}
let asset_type = local_upload_asset_type(kind, &file.content_type);
let mut uploaded_assets = metadata.uploaded_assets;
uploaded_assets.insert(
@@ -3997,6 +4024,8 @@ pub(crate) fn write_local_markdown_asset(
"rootUri": file_uri_for_path(&canonical_root),
"rootRelativePath": root_relative_path,
"markdownRelativePath": markdown_relative_path,
"markdownHref": markdown_href,
"attachmentRef": attachment_ref,
}))
}
@@ -7063,7 +7092,11 @@ fn parent_key_for_relative_path(relative_path: &str) -> String {
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
if value.is_empty() { None } else { Some(value) }
if value.is_empty() {
None
} else {
Some(value)
}
})
.map(|value| normalize_file_order_parent_key(&value))
.unwrap_or_else(|| ".".to_string())
@@ -8130,6 +8163,15 @@ fn normalize_markdown_relative_asset_path(
.join("/"))
}
fn markdown_href_for_relative_path(relative_path: &str) -> String {
let trimmed = relative_path.trim().replace('\\', "/");
if trimmed.starts_with("./") || trimmed.starts_with("../") {
trimmed
} else {
format!("./{trimmed}")
}
}
fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str {
let normalized_kind = kind.trim().to_ascii_lowercase();
if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") {
@@ -8384,6 +8426,7 @@ fn editor_blocks_to_markdown_with_rewrite(
lines.push(text);
} else {
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
.map(|value| markdown_href_for_relative_path(&value))
.unwrap_or_else(|| src.to_string());
lines.push(format!(
"![{}]({})",
@@ -8412,9 +8455,7 @@ fn editor_blocks_to_markdown_with_rewrite(
if url.is_empty() {
lines.push(text);
} else {
let url = rewrite_local_open_url_to_markdown_relative(url, local_file_context)
.unwrap_or_else(|| url.to_string());
let label = if name.is_empty() { url.as_str() } else { name };
let label = if name.is_empty() { url } else { name };
lines.push(format!("[{}]({})", label, markdown_link_target(&url)));
}
}
@@ -9041,7 +9082,7 @@ fn inline_styles_from_object(object: &Map<String, Value>) -> Value {
fn markdown_text_with_styles(
text: &str,
styles: &Value,
local_file_context: Option<(&Path, &Path)>,
_local_file_context: Option<(&Path, &Path)>,
) -> String {
let mut value = escape_markdown_inline_text(text);
let link = styles
@@ -9078,8 +9119,6 @@ fn markdown_text_with_styles(
value = format!("~~{value}~~");
}
if let Some(href) = link {
let href =
rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href);
value = format!("[{}]({href})", value.replace(']', r"\]"));
}
value
@@ -9224,10 +9263,6 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
#[cfg(test)]
mod tests {
use super::{
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
SyncConflictReportRequest, SyncPendingChangeRequest,
add_sqlite_local_access_grant_for_context,
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
@@ -9246,15 +9281,18 @@ mod tests {
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
write_sync_conflict_report,
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
};
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, Method, StatusCode};
use axum::Json;
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
@@ -9526,25 +9564,28 @@ mod tests {
"tableCell"
);
assert_eq!(
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]["text"],
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]
["text"],
""
);
assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["text"],
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
["text"],
"A"
);
assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["marks"]
[0]["type"],
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
["marks"][0]["type"],
"code"
);
assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["text"],
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
["text"],
"B"
);
assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["marks"]
[0]["type"],
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
["marks"][0]["type"],
"bold"
);
}
@@ -9647,12 +9688,10 @@ mod tests {
let body = serde_json::to_value(&aggregate.body).expect("body json");
assert_eq!(body["fileVersion"], body["conflictDetectionKey"]);
assert!(
body["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:")
);
assert!(body["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:"));
let _ = std::fs::remove_dir_all(&root);
}
@@ -9674,12 +9713,10 @@ mod tests {
.expect("save");
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
assert!(
result["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:")
);
assert!(result["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:"));
let _ = std::fs::remove_dir_all(&root);
}
@@ -9842,7 +9879,7 @@ fn main() {}
}
#[test]
fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() {
fn local_markdown_save_does_not_migrate_runtime_open_url_inline_link() {
let root = temp_root("mnote-local-uploaded-md-inline-link");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
@@ -9888,28 +9925,18 @@ fn main() {}
)
.expect("save inline attachment link");
assert_eq!(asset["sourcePath"], "notes.md");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[notes.md](notes.md)"));
assert!(!saved.contains("/api/local-folder/files/open"));
let aggregate =
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
let media = aggregate
.body
.content
.as_array()
.expect("blocks")
.iter()
.find(|block| block["type"].as_str() == Some("media"))
.expect("uploaded md inline link should reload as media block");
assert_eq!(media["props"]["sourcePath"], "notes.md");
assert_eq!(asset["sourcePath"], "notes.md");
assert!(
saved.contains("/api/local-folder/files/open"),
"开发态不再把旧 runtime open URL 自动迁移为标准 href"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() {
fn local_markdown_save_does_not_migrate_relative_runtime_open_url() {
let root = temp_root("mnote-local-uploaded-md-relative-open-link");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
@@ -9956,7 +9983,10 @@ fn main() {}
.expect("save relative open url");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[notes.md](notes.md)"));
assert!(
saved.contains("/api/local-folder/files/open"),
"开发态不再把旧 runtime open URL 自动迁移为标准 href"
);
let _ = std::fs::remove_dir_all(&root);
}
@@ -10800,12 +10830,10 @@ fn main() {}
assert_eq!(created["grant"]["ownerUserId"], "user_owner");
assert_eq!(created["grant"]["targetUserId"], "user_target");
assert_eq!(created["grant"]["permission"], "write");
assert!(
created["grant"]["shareId"]
.as_str()
.unwrap_or_default()
.starts_with("folder_")
);
assert!(created["grant"]["shareId"]
.as_str()
.unwrap_or_default()
.starts_with("folder_"));
let outside_error = create_user_share_grant(
Extension(request_context("user_owner", "user")),
@@ -11075,13 +11103,11 @@ fn main() {}
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].id, link_id);
assert_ne!(stored[0].token_hash, "visible-token");
assert!(
state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve share link")
.is_some()
);
assert!(state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve share link")
.is_some());
let (_, Json(listed)) = get_share_links(
State(state.clone()),
@@ -11114,13 +11140,11 @@ fn main() {}
.expect("share revoked broadcast delta");
assert_eq!(revoked_delta["kind"], "control_plane_event");
assert_eq!(revoked_delta["eventType"], "control.share.revoked");
assert!(
state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve revoked share link")
.is_none()
);
assert!(state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve revoked share link")
.is_none());
assert_eq!(
state
.control_plane()
@@ -11324,13 +11348,11 @@ fn main() {}
payload["workspace"]["manifest"]["ownerId"],
"user@example.com"
);
assert!(
payload["workspace"]["manifest"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value.as_str() == Some("markdown_edit"))
);
assert!(payload["workspace"]["manifest"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value.as_str() == Some("markdown_edit")));
ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri)
.expect("owner can access managed workspace");
@@ -11581,12 +11603,11 @@ fn main() {}
execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None)
.expect("delete loose markdown");
assert!(!root.join("Renamed.md").exists());
assert!(
root.join(".mnote")
.join("trash")
.join("Renamed.md")
.is_file()
);
assert!(root
.join(".mnote")
.join("trash")
.join("Renamed.md")
.is_file());
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
let restored =
@@ -13098,6 +13119,11 @@ fn main() {}
assert_eq!(asset["uploadIntent"], "editor.markdown.attach");
assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png");
assert_eq!(asset["markdownRelativePath"], "photo-1.png");
assert_eq!(asset["markdownHref"], "./photo-1.png");
assert_eq!(asset["attachmentRef"]["rawHref"], "./photo-1.png");
assert_eq!(asset["attachmentRef"]["kind"], "pageLocal");
assert_eq!(asset["attachmentRef"]["openKind"], "image");
assert_eq!(asset["attachmentRef"]["authorized"], true);
assert_eq!(
asset["ownerDocumentId"],
"local-md:docs~2FREADME~2FREADME.md"
@@ -13122,6 +13148,8 @@ fn main() {}
assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach");
assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md");
assert_eq!(markdown_asset["markdownRelativePath"], "notes.md");
assert_eq!(markdown_asset["markdownHref"], "./notes.md");
assert_eq!(markdown_asset["attachmentRef"]["openKind"], "text");
assert_eq!(
markdown_asset["ownerDocumentId"],
"local-md:docs~2FREADME~2FREADME.md"
@@ -13155,6 +13183,31 @@ fn main() {}
.expect("uploaded asset index");
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
std::fs::write(root.join("docs").join("Loose.md"), "# Loose\n").expect("write loose md");
let loose_asset = write_local_markdown_asset(
&root_uri,
"local-md:docs~2FLoose.md",
"attachment",
LocalUploadFile {
name: "loose.pdf".to_string(),
content_type: "application/pdf".to_string(),
bytes: b"%PDF-1.4\n".to_vec(),
},
)
.expect("upload loose markdown asset");
assert_eq!(loose_asset["sourcePath"], "loose.pdf");
assert_eq!(loose_asset["rootRelativePath"], "docs/loose.pdf");
assert_eq!(loose_asset["markdownRelativePath"], "loose.pdf");
assert_eq!(loose_asset["markdownHref"], "./loose.pdf");
assert!(
root.join("docs").join("loose.pdf").is_file(),
"非 bundle Markdown 上传应写入 md 同目录"
);
assert!(
!root.join("docs").join("Loose").join("loose.pdf").exists(),
"非 bundle Markdown 上传不应写入同名子目录"
);
let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README")
.expect("file tree");
let items = snapshot.projection["items"].as_array().expect("items");
@@ -1,7 +1,10 @@
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{Arena, Options, parse_document};
use serde_json::{Map, Value, json};
use comrak::{parse_document, Arena, Options};
use serde::Serialize;
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
@@ -78,6 +81,34 @@ struct MarkdownInlineStyles {
link: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentSourceRange {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentRef {
pub ref_id: String,
pub owner_document_path: String,
pub owner_root_uri: String,
pub raw_href: String,
pub normalized_href: String,
pub label: String,
pub kind: String,
pub resolved_uri: Option<String>,
pub resolved_absolute_path: Option<String>,
pub relative_path: Option<String>,
pub ext: Option<String>,
pub content_type: Option<String>,
pub exists: Option<bool>,
pub authorized: Option<bool>,
pub open_kind: String,
pub source_range: Option<AttachmentSourceRange>,
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
let (_frontmatter, body) = split_frontmatter(markdown);
let body_owned = body.to_string();
@@ -101,6 +132,28 @@ pub fn markdown_to_blocks_with_attachment_paths(
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
}
pub fn parse_markdown_attachment_refs(
markdown: &str,
owner_document_path: &str,
owner_root_uri: &str,
) -> Vec<AttachmentRef> {
let arena = Arena::new();
let options = markdown_options();
let root = parse_document(&arena, markdown, &options);
let mut refs = Vec::new();
let mut cursor = 0usize;
collect_attachment_refs_from_ast(
root,
markdown,
owner_document_path,
owner_root_uri,
&mut cursor,
&mut refs,
);
collect_html_embed_attachment_refs(markdown, owner_document_path, owner_root_uri, &mut refs);
refs
}
fn markdown_options() -> Options<'static> {
let mut options = Options::default();
options.extension.table = true;
@@ -159,6 +212,390 @@ fn parse_markdown_attachment_link_with_paths(
Some((name.to_string(), target.to_string()))
}
fn collect_attachment_refs_from_ast<'a>(
node: &'a AstNode<'a>,
markdown: &str,
owner_document_path: &str,
owner_root_uri: &str,
cursor: &mut usize,
refs: &mut Vec<AttachmentRef>,
) {
match &node.data.borrow().value {
NodeValue::Link(link) => {
let raw_href = link.url.trim();
if should_collect_attachment_href(raw_href) {
let label = collect_plain_text(node);
let source_range = find_href_source_range(markdown, raw_href, cursor);
refs.push(build_attachment_ref(
owner_document_path,
owner_root_uri,
raw_href,
label.trim(),
source_range,
));
}
}
NodeValue::Image(link) => {
let raw_href = link.url.trim();
if should_collect_attachment_href(raw_href) {
let label = collect_plain_text(node);
let source_range = find_href_source_range(markdown, raw_href, cursor);
refs.push(build_attachment_ref(
owner_document_path,
owner_root_uri,
raw_href,
label.trim(),
source_range,
));
}
}
_ => {}
}
for child in node.children() {
collect_attachment_refs_from_ast(
child,
markdown,
owner_document_path,
owner_root_uri,
cursor,
refs,
);
}
}
fn collect_html_embed_attachment_refs(
markdown: &str,
owner_document_path: &str,
owner_root_uri: &str,
refs: &mut Vec<AttachmentRef>,
) {
let lower = markdown.to_ascii_lowercase();
let mut offset = 0usize;
while let Some(relative_start) = lower[offset..].find("<embed") {
let start = offset + relative_start;
let Some(relative_end) = lower[start..].find('>') else {
break;
};
let end = start + relative_end + 1;
let fragment = &markdown[start..end];
if let Some((raw_href, value_start, value_end)) =
extract_html_attr(fragment, "src").or_else(|| extract_html_attr(fragment, "href"))
{
let source_range = Some(AttachmentSourceRange {
start: start + value_start,
end: start + value_end,
});
refs.push(build_attachment_ref(
owner_document_path,
owner_root_uri,
raw_href,
raw_href,
source_range,
));
}
offset = end;
}
}
fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usize, usize)> {
let lower = fragment.to_ascii_lowercase();
let needle = format!("{name}=");
let attr_start = lower.find(&needle)? + needle.len();
let bytes = fragment.as_bytes();
let quote = *bytes.get(attr_start)?;
if quote != b'"' && quote != b'\'' {
return None;
}
let value_start = attr_start + 1;
let value_end = fragment[value_start..]
.find(quote as char)
.map(|index| value_start + index)?;
Some((&fragment[value_start..value_end], value_start, value_end))
}
fn should_collect_attachment_href(raw_href: &str) -> bool {
let trimmed = raw_href.trim();
!trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:")
}
fn build_attachment_ref(
owner_document_path: &str,
owner_root_uri: &str,
raw_href: &str,
label: &str,
source_range: Option<AttachmentSourceRange>,
) -> AttachmentRef {
let raw_href = raw_href.trim();
let normalized_href = normalize_attachment_href(raw_href);
let owner_document = Path::new(owner_document_path);
let owner_dir = owner_document.parent();
let (kind, resolved_absolute_path, relative_path) =
resolve_attachment_path(&normalized_href, owner_dir, owner_root_uri);
let resolved_uri = resolved_absolute_path
.as_ref()
.map(|path| file_uri_for_attachment_path(Path::new(path)))
.or_else(|| {
if is_remote_href(&normalized_href) {
Some(normalized_href.clone())
} else {
None
}
});
let ext = attachment_extension(&normalized_href, resolved_absolute_path.as_deref());
let content_type = ext
.as_deref()
.and_then(attachment_content_type)
.map(str::to_string);
let exists = resolved_absolute_path
.as_ref()
.map(|path| Path::new(path).exists());
let open_kind = attachment_open_kind(ext.as_deref()).to_string();
let label = if label.trim().is_empty() {
resolved_absolute_path
.as_ref()
.and_then(|path| Path::new(path).file_name())
.and_then(|name| name.to_str())
.or_else(|| {
Path::new(&normalized_href)
.file_name()
.and_then(|name| name.to_str())
})
.unwrap_or(raw_href)
.to_string()
} else {
label.trim().to_string()
};
AttachmentRef {
ref_id: attachment_ref_id(owner_document_path, raw_href, &source_range),
owner_document_path: owner_document_path.to_string(),
owner_root_uri: owner_root_uri.to_string(),
raw_href: raw_href.to_string(),
normalized_href,
label,
kind,
resolved_uri,
resolved_absolute_path,
relative_path,
ext,
content_type,
exists,
authorized: None,
open_kind,
source_range,
}
}
fn resolve_attachment_path(
href: &str,
owner_dir: Option<&Path>,
owner_root_uri: &str,
) -> (String, Option<String>, Option<String>) {
if is_remote_href(href) {
return ("remoteUrl".to_string(), None, None);
}
if let Some(path) = href.strip_prefix("file://").and_then(file_uri_path) {
return (
"externalFile".to_string(),
Some(normalize_path_string(&path)),
None,
);
}
let href_path = Path::new(href);
if href_path.is_absolute() {
return (
"externalFile".to_string(),
Some(normalize_path_string(href_path)),
None,
);
}
let decoded_href = percent_decode_lossy(href);
if decoded_href.starts_with("../") || decoded_href.contains("/../") {
return ("unknown".to_string(), None, None);
}
let relative_path = decoded_href
.strip_prefix("./")
.unwrap_or(&decoded_href)
.replace('\\', "/");
let resolved = owner_dir.map(|dir| normalize_path_string(&dir.join(&decoded_href)));
let root_path = owner_root_path(owner_root_uri);
let relative_to_root = resolved
.as_deref()
.and_then(|path| {
root_path
.as_ref()
.and_then(|root| relative_to_root_path(path, root))
})
.or(Some(relative_path));
("pageLocal".to_string(), resolved, relative_to_root)
}
fn normalize_attachment_href(raw_href: &str) -> String {
let trimmed = raw_href
.trim()
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_href.trim())
.trim();
if Path::new(trimmed).is_absolute() {
file_uri_for_attachment_path(Path::new(trimmed))
} else {
trimmed.to_string()
}
}
fn file_uri_path(value: &str) -> Option<PathBuf> {
let path = if let Some(rest) = value.strip_prefix("localhost/") {
format!("/{rest}")
} else if value.starts_with('/') {
value.to_string()
} else {
format!("/{value}")
};
Some(PathBuf::from(percent_decode_lossy(&path)))
}
fn owner_root_path(root_uri: &str) -> Option<PathBuf> {
root_uri
.strip_prefix("file://")
.and_then(file_uri_path)
.or_else(|| {
let path = Path::new(root_uri);
if path.is_absolute() {
Some(path.to_path_buf())
} else {
None
}
})
}
fn relative_to_root_path(path: &str, root: &Path) -> Option<String> {
Path::new(path)
.strip_prefix(root)
.ok()
.map(|value| value.to_string_lossy().replace('\\', "/"))
}
fn find_href_source_range(
markdown: &str,
raw_href: &str,
cursor: &mut usize,
) -> Option<AttachmentSourceRange> {
let raw_href = raw_href.trim();
let start = markdown
.get(*cursor..)
.and_then(|tail| tail.find(raw_href).map(|index| *cursor + index))
.or_else(|| markdown.find(raw_href))?;
let end = start + raw_href.len();
*cursor = end;
Some(AttachmentSourceRange { start, end })
}
fn is_remote_href(href: &str) -> bool {
href.starts_with("http://") || href.starts_with("https://")
}
fn attachment_extension(href: &str, path: Option<&str>) -> Option<String> {
path.or(Some(href))
.and_then(|value| Path::new(value).extension())
.and_then(|value| value.to_str())
.map(|value| value.trim_start_matches('.').to_ascii_lowercase())
.filter(|value| !value.is_empty())
}
fn attachment_content_type(ext: &str) -> Option<&'static str> {
match ext {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
"svg" => Some("image/svg+xml"),
"pdf" => Some("application/pdf"),
"docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
"pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
"xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
"mp3" => Some("audio/mpeg"),
"wav" => Some("audio/wav"),
"mp4" => Some("video/mp4"),
"webm" => Some("video/webm"),
"md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml"
| "yaml" | "yml" => Some("text/plain"),
_ => None,
}
}
fn attachment_open_kind(ext: Option<&str>) -> &'static str {
match ext.unwrap_or_default() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "image",
"pdf" => "pdf",
"doc" | "docx" | "ppt" | "pptx" | "xls" | "xlsx" => "office",
"mp3" | "wav" | "ogg" | "m4a" => "audio",
"mp4" | "webm" | "mov" | "mkv" => "video",
"md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml"
| "yaml" | "yml" => "text",
"" => "unknown",
_ => "download",
}
}
fn normalize_path_string(path: &Path) -> String {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
_ => normalized.push(component.as_os_str()),
}
}
normalized.to_string_lossy().replace('\\', "/")
}
fn file_uri_for_attachment_path(path: &Path) -> String {
format!("file://{}", normalize_path_string(path))
}
fn percent_decode_lossy(value: &str) -> String {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let (Some(high), Some(low)) =
(hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
{
output.push(high * 16 + low);
index += 3;
continue;
}
}
output.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&output).into_owned()
}
fn hex_value(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn attachment_ref_id(
owner_document_path: &str,
raw_href: &str,
source_range: &Option<AttachmentSourceRange>,
) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
owner_document_path.hash(&mut hasher);
raw_href.hash(&mut hasher);
source_range.hash(&mut hasher);
format!("attachment:{:016x}", hasher.finish())
}
fn parse_markdown_ast_document(
markdown: &str,
attachment_paths: &BTreeSet<String>,
@@ -787,7 +1224,7 @@ pub(crate) fn file_stem_title(file_name: &str) -> String {
#[cfg(test)]
mod tests {
use super::{markdown_to_blocks, parse_markdown_page};
use super::{markdown_to_blocks, parse_markdown_attachment_refs, parse_markdown_page};
#[test]
fn markdown_image_parses_as_image_block() {
@@ -802,6 +1239,94 @@ mod tests {
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
"mnote-attachment-ref-parser-{}",
std::process::id()
));
let owner_dir = root.join("docs");
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
std::fs::write(owner_dir.join("同目录 文件.pdf"), b"pdf").expect("write relative file");
std::fs::write(owner_dir.join("figure.png"), b"png").expect("write image file");
let owner = owner_dir.join("page.md");
let external = root.join("external.docx");
std::fs::write(&external, b"docx").expect("write external file");
let markdown = format!(
"[同目录 PDF](./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf)\n\
![](./figure.png)\n\
[](file://{})\n\
[](https://example.com/paper.pdf)\n\
[]({})\n\
<embed src=\"./missing.pptx\">\n",
external.display(),
external.display()
);
let refs = parse_markdown_attachment_refs(
&markdown,
&owner.display().to_string(),
&format!("file://{}", root.display()),
);
assert_eq!(refs.len(), 6);
assert_eq!(
refs[0].raw_href,
"./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf"
);
assert_eq!(refs[0].kind, "pageLocal");
assert_eq!(refs[0].open_kind, "pdf");
assert_eq!(refs[0].exists, Some(true));
assert_eq!(
refs[0].relative_path,
Some("docs/同目录 文件.pdf".to_string())
);
assert_eq!(refs[1].open_kind, "image");
assert_eq!(refs[2].kind, "externalFile");
assert_eq!(refs[2].open_kind, "office");
assert_eq!(refs[3].kind, "remoteUrl");
assert_eq!(
refs[3].resolved_uri,
Some("https://example.com/paper.pdf".to_string())
);
assert_eq!(
refs[4].normalized_href,
format!("file://{}", external.display())
);
assert_eq!(refs[5].raw_href, "./missing.pptx");
assert_eq!(refs[5].open_kind, "office");
assert_eq!(refs[5].exists, Some(false));
assert!(refs.iter().all(|item| item.source_range.is_some()));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn markdown_attachment_refs_do_not_resolve_parent_directory_relative_paths() {
let root = std::env::temp_dir().join(format!(
"mnote-attachment-ref-parent-relative-{}",
std::process::id()
));
let owner_dir = root.join("docs");
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
let owner = owner_dir.join("page.md");
let refs = parse_markdown_attachment_refs(
"[上级目录](../assets/a.pdf)\n",
&owner.display().to_string(),
&format!("file://{}", root.display()),
);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].raw_href, "../assets/a.pdf");
assert_eq!(refs[0].kind, "unknown");
assert_eq!(refs[0].resolved_absolute_path, None);
assert_eq!(refs[0].relative_path, None);
assert_eq!(refs[0].exists, None);
let _ = std::fs::remove_dir_all(&root);
}
/// 固定空引用块行为:`>` 在 GFM AST 中产生 BlockQuote 节点,
/// collect_inline_children 返回空 vec → 输出 type=quote content=[]。
#[test]
@@ -4,7 +4,7 @@ use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -852,55 +852,41 @@ mod tests {
.iter()
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
.expect("home result");
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("docs/child.md"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx"))
);
assert!(
home["publicPath"]
.as_str()
.is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
))
);
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("docs/child.md")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
assert!(home["publicPath"]
.as_str()
.is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
)));
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
let child_search = query_local_search_index(
@@ -929,12 +915,11 @@ mod tests {
Some("local-mdid:child-page")
);
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists()
);
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
let mindmap_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -946,19 +931,19 @@ mod tests {
false,
)
.expect("mindmap projection");
assert!(
mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"].as_str().is_some_and(|path| path
assert!(mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"]
.as_str()
.is_some_and(|path| path
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
&& item["publicPath"]
.as_str()
.is_some_and(|path| !path.starts_with("/tree?")))
);
&& item["publicPath"]
.as_str()
.is_some_and(|path| !path.starts_with("/tree?"))));
let office_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -970,14 +955,12 @@ mod tests {
false,
)
.expect("office projection");
assert!(
office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx"))
);
assert!(office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx")));
let _ = fs::remove_dir_all(&root);
}
@@ -1004,12 +987,10 @@ mod tests {
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md")
);
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let child = index
.documents
.iter()
@@ -1025,18 +1006,14 @@ mod tests {
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/child.md")
);
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md")
);
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/child.md"));
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let _ = fs::remove_dir_all(&root);
}
+6 -6
View File
@@ -7,20 +7,20 @@ use crate::transport::convex::{
persist_runtime_command_artifacts,
};
use axum::extract::{Multipart, Query, State};
use axum::http::{HeaderMap, header};
use axum::http::{header, HeaderMap};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
build_runtime_command_artifact_plan,
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeSourceWire, RuntimeTargetWire,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
@@ -11,15 +11,15 @@ use crate::routes::query_support::{
fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud,
resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeQueryEnvelopeWire, RuntimeSourceWire,
RuntimeTargetWire, apply_mindmap_kernel_commands_to_value,
apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
@@ -469,10 +469,10 @@ pub async fn apply_mindmap_command(
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::path::PathBuf;
use tower::util::ServiceExt;
@@ -250,12 +250,19 @@ fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn render_mindmap_standalone_bootstrap_script() -> &'static str {
fn render_mindmap_standalone_bootstrap_script() -> String {
r#"<script type="module">
(() => {
const DEV_HOT_BUSTER = "__MNOTE_DEV_HOT_BUSTER__";
const BOOTSTRAP_ID = '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__';
const MOUNT_ID = 'mnote-mindmap-island';
const withDevHot = (path) => {
const url = new URL(path, window.location.origin);
if (DEV_HOT_BUSTER) url.searchParams.set('devHot', DEV_HOT_BUSTER);
return url.toString();
};
const parseJsonScript = (id) => {
const node = document.getElementById(id);
if (!node) return null;
@@ -272,12 +279,12 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
return window.__mnoteLeptosTiptapRuntimePromise;
}
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
const manifestResponse = await fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'));
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
const manifest = await manifestResponse.json();
if (!manifest.entryAssetPath) throw new Error('island manifest entryAssetPath');
const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`;
const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined;
const entryUrl = withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`);
const wasmUrl = manifest.wasmAssetPath ? withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`) : undefined;
const runtime = await import(entryUrl);
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
throw new Error('island runtime ');
@@ -322,12 +329,16 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
}, { once: true });
})();
</script>"#
.replace(
"__MNOTE_DEV_HOT_BUSTER__",
crate::routes::dev_hot::dev_hot_cache_buster().unwrap_or(""),
)
}
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -493,6 +504,25 @@ mod tests {
assert!(!html.contains("next-app-router"));
}
#[test]
fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let script = super::render_mindmap_standalone_bootstrap_script();
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert!(script.contains("const DEV_HOT_BUSTER = \""));
assert!(script.contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))"));
assert!(
script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)")
);
assert!(
script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)")
);
}
#[tokio::test]
async fn mindmap_api_returns_same_adapter_contract_for_standalone_and_block() {
let response = app_with_mindmap_fixture()
+11 -12
View File
@@ -1,7 +1,7 @@
mod bridge;
pub(crate) mod command_support;
mod compat;
mod dev_hot;
pub(crate) mod dev_hot;
mod documents;
mod editor;
mod gateway;
@@ -42,9 +42,9 @@ pub(crate) use local_folder_source::{
pub(crate) use local_search_index::refresh_local_search_index_for_path;
use crate::app::AppState;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{any, delete, get, post, put};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone();
@@ -572,10 +572,10 @@ mod tests {
use super::build_router;
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use axum::Router;
use serde_json::{json, Value};
use std::fs;
use tower::ServiceExt;
@@ -977,7 +977,8 @@ mod tests {
"reasonix"
);
assert_eq!(
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]["folder"],
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
["folder"],
true
);
assert_eq!(
@@ -1000,12 +1001,10 @@ mod tests {
.await
.expect("bob body");
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("bob json");
assert!(
bob_payload["result"]["aiPreferences"]
.as_object()
.map(|value| value.is_empty())
.unwrap_or(false)
);
assert!(bob_payload["result"]["aiPreferences"]
.as_object()
.map(|value| value.is_empty())
.unwrap_or(false));
let _ = fs::remove_dir_all(&root);
}
@@ -3,12 +3,12 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state;
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Default, Deserialize)]
@@ -353,9 +353,9 @@ impl EmptyStringExt for String {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{header, Request, StatusCode};
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
use tower::ServiceExt;
+17 -24
View File
@@ -2,29 +2,29 @@ use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{
OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput, prepare_callback,
prepare_proxy_request, sign_config,
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
OnlyOfficeProxyPreparationInput,
};
use axum::Json;
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri, header};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use hyper::upgrade::Upgraded;
use hyper_util::rt::TokioIo;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::time::Duration;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
use tokio_tungstenite::tungstenite::protocol::Role;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::WebSocketStream;
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
@@ -1705,10 +1705,9 @@ mod tests {
fn stable_doc_key_uses_onlyoffice_safe_characters() {
let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
assert!(key.len() <= 128);
assert!(
key.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
);
assert!(key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
assert!(key.starts_with("asset_1_"));
}
@@ -1722,10 +1721,9 @@ mod tests {
);
assert!(key.len() <= 128);
assert!(key.starts_with("mnote_"));
assert!(
key.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
);
assert!(key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
assert!(!key.contains('/'));
assert!(!key.contains(':'));
assert!(!key.contains('重'));
@@ -2025,11 +2023,8 @@ mod tests {
let request = captured.await.expect("captured");
assert_eq!(payload["error"], 0);
assert!(
request.starts_with(
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
)
);
assert!(request
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
assert!(request.contains(r#""status":2"#));
assert!(request.contains(r#""key":"doc_key""#));
}
@@ -2084,10 +2079,8 @@ mod tests {
assert_eq!(payload["ok"], true);
assert_eq!(payload["via"], "forcesave");
assert!(
request
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")
);
assert!(request
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
}
#[tokio::test]
@@ -2,10 +2,10 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use axum::Json;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use serde_json::{Value, json};
use axum::Json;
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
@@ -587,12 +587,12 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{direct_block_edit_operations, extract_operations_from_model_text};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use std::sync::Mutex;
use tower::util::ServiceExt;
@@ -952,18 +952,14 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("已读取第一段:第一段")
);
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("测试123")
);
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("已读取第一段:第一段"));
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("测试123"));
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
@@ -3,9 +3,9 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::execute_retired_query_plan;
use bridge_runtime::{
BridgeContext, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, build_query_request,
execute_runtime_input, execute_runtime_query,
build_query_request, execute_runtime_input, execute_runtime_query, BridgeContext,
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire,
};
use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value;
@@ -11,15 +11,15 @@ use crate::transport::convex::{
execute_retired_mutation_by_name, execute_retired_query_by_name,
persist_runtime_command_artifacts,
};
use axum::Json;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire,
RuntimeTargetWire, build_runtime_command_artifact_plan,
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
@@ -1148,8 +1148,8 @@ pub async fn table_empty_trash(
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::Request;
use tower::util::ServiceExt;
@@ -1311,12 +1311,11 @@ mod tests {
);
assert_eq!(payload["result"]["sourceKind"], "local_folder");
assert!(!root.join("Page").join("map.mindmap.json").exists());
assert!(
root.join(".mnote")
.join("trash")
.join("map.mindmap.json")
.exists()
);
assert!(root
.join(".mnote")
.join("trash")
.join("map.mindmap.json")
.exists());
assert_eq!(
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
original_markdown,
@@ -1422,13 +1421,11 @@ mod tests {
purged_payload["result"]["canonicalCommand"],
"tree.resource.purge"
);
assert!(
!root
.join(".mnote")
.join("trash")
.join("map.mindmap.json")
.exists()
);
assert!(!root
.join(".mnote")
.join("trash")
.join("map.mindmap.json")
.exists());
let _ = std::fs::remove_dir_all(&root);
}
+35 -46
View File
@@ -8,13 +8,13 @@ use crate::routes::query_support::{
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name";
@@ -546,10 +546,10 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use tower::util::ServiceExt;
@@ -788,40 +788,31 @@ mod tests {
.expect("home result");
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists()
);
assert!(
payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
assert!(payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let _ = fs::remove_dir_all(&root);
}
@@ -930,13 +921,11 @@ mod tests {
backlinks_payload["meta"]["queryName"].as_str(),
Some("search.local_index.backlinks")
);
assert!(
backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
assert!(backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let tags_response = app()
.oneshot(
+5 -5
View File
@@ -1,10 +1,10 @@
use crate::app::AppState;
use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use axum::Json;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use control_plane::session_token_hash;
use serde::Serialize;
@@ -213,10 +213,10 @@ fn stamp_owner_header(headers: &mut HeaderMap) {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -2,12 +2,12 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::current_actor_id;
use axum::Json;
use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
};
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelNodeType, KernelProjectionKind};
use serde_json::{Value, json};
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub struct ProjectionSnapshotSpec<'a> {
+6 -6
View File
@@ -2,15 +2,15 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload,
build_stream_push_delta_hint, load_stream_overview, load_stream_snapshot,
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
StreamChangeKind, StreamSnapshotQuery,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::convert::Infallible;
use std::time::Duration;
use tokio::time::sleep;
@@ -340,9 +340,9 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::stream_support::StreamSnapshotQuery;
use axum::body::{Body, to_bytes};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use std::time::Duration;
use tokio::time::timeout;
@@ -5,13 +5,13 @@ use crate::routes::query_support::{
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
subtree_query,
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec,
};
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
"page.body.save",
@@ -701,9 +701,8 @@ pub async fn load_stream_snapshot(
#[cfg(test)]
mod tests {
use super::{
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor,
resolve_stream_scope,
resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
};
use serde_json::json;
+146 -146
View File
@@ -6,42 +6,42 @@ use crate::routes::command_support::{
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
};
use crate::routes::local_folder_source::{
LocalAccessMode, ensure_local_workspace_access_with_state,
ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_workspace_id_from_root_uri, LocalAccessMode,
};
use crate::routes::query_support::{
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::transport::convex::execute_retired_mutation_by_name;
use crate::tree_shell::filetree_renderer::{
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
};
use crate::tree_shell::picker_renderer::{
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
};
use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput,
};
use crate::tree_shell::runtime_api::{
TreeShellRuntimeRequest, TreeShellRuntimeResult,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
TreeShellRuntimeResult,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -850,6 +850,18 @@ fn build_tree_shell_html(
exclude_ids: &[String],
dataset: &Value,
) -> String {
let source_kind = projection
.get("sourceKind")
.and_then(Value::as_str)
.unwrap_or("convex_workspace");
let root_uri = projection
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or("");
let watch_revision = projection
.get("watchRevision")
.cloned()
.unwrap_or(Value::Null);
let renderer_input = build_tree_shell_renderer_input(
projection,
mode,
@@ -869,15 +881,9 @@ fn build_tree_shell_html(
"channel": channel,
"host": host,
"mode": mode,
"sourceKind": projection
.get("sourceKind")
.and_then(Value::as_str)
.unwrap_or("convex_workspace"),
"rootUri": projection
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or(""),
"localWatchRevision": projection.get("watchRevision").cloned().unwrap_or(Value::Null),
"sourceKind": source_kind,
"rootUri": root_uri,
"localWatchRevision": watch_revision.clone(),
"allowRootPick": allow_root_pick,
"excludeIds": exclude_ids,
"rendererInput": renderer_input,
@@ -892,6 +898,22 @@ fn build_tree_shell_html(
});
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into());
let tree_live_bootstrap = serde_json::json!({
"schema": "mnote.tree_live_bootstrap.v1",
"disabled": false,
"transport": if source_kind == "local_folder" { "local-folder-events" } else { "tree-live-ws" },
"endpoint": "/api/tree/events",
"wsEndpoint": "/api/realtime/ws",
"workspaceId": workspace_id,
"rootIds": root_node_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| vec![value])
.unwrap_or_default(),
"initialRevision": watch_revision,
});
let tree_live_bootstrap_json =
serde_json::to_string(&tree_live_bootstrap).unwrap_or_else(|_| "{}".into());
let initial_tree_html = match mode {
"page" => render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows: collect_page_tree_render_rows(projection),
@@ -1589,7 +1611,7 @@ fn build_tree_shell_html(
}
</style>
</head>
<body>
<body data-mnote-root-uri="__ROOT_URI__">
<main>
<section class="tree-card">
<div class="tree-card-header">
@@ -1606,7 +1628,9 @@ fn build_tree_shell_html(
</main>
<script id="tree-shell-state" type="application/json">__APP_STATE__</script>
<script type="module" src="/api/mnote-browser-runtime/tree-shell-runtime.js"></script>
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json">__TREE_LIVE_BOOTSTRAP__</script>
<script type="module" src="__TREE_SHELL_RUNTIME_SRC__"></script>
<script type="module" src="__TREE_LIVE_CONTROLLER_SRC__"></script>
</body>
</html>
"##;
@@ -1615,9 +1639,22 @@ fn build_tree_shell_html(
.replace("__WORKSPACE_ID__", &escape_html(workspace_id))
.replace("__ROOT_LABEL__", &escape_html(root_label))
.replace("__ACTIVE_LABEL__", &escape_html(active_label))
.replace("__ROOT_URI__", &escape_html(root_uri))
.replace("__PROJECTION_JSON__", &escape_html(&projection_json))
.replace("__INITIAL_TREE_HTML__", &initial_tree_html)
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
.replace(
"__TREE_SHELL_RUNTIME_SRC__",
&crate::routes::web_shell::mnote_browser_runtime_src("tree-shell-runtime.js"),
)
.replace(
"__TREE_LIVE_CONTROLLER_SRC__",
&crate::routes::web_shell::mnote_browser_runtime_src("tree-live-controller.js"),
)
.replace(
"__TREE_LIVE_BOOTSTRAP__",
&escape_inline_json(&tree_live_bootstrap_json),
)
}
fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
@@ -2584,10 +2621,10 @@ mod tests {
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
use super::{
TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest,
collect_filetree_render_rows, create_command_wire,
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
TreeCommandRequest, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG,
};
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
@@ -2752,20 +2789,14 @@ mod tests {
assert!(html.contains("data-rust-action=\"toggle\""));
assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")
);
assert!(
TREE_SHELL_RUNTIME_JS
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
@@ -2790,11 +2821,8 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("id=\"tree-shell-state\""));
assert!(
html.contains(
"type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""
)
);
assert!(html
.contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""));
assert!(
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML"
@@ -2829,23 +2857,17 @@ mod tests {
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")
);
assert!(
TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true"));
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
assert!(html.contains("tabindex=\""));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
}
@@ -2885,18 +2907,12 @@ mod tests {
assert!(
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
);
assert!(
TREE_SHELL_FILETREE_MENU_RUNTIME_JS
.contains("function buildFileTreeMenuTarget(context")
);
assert!(
TREE_SHELL_FILETREE_DND_RUNTIME_JS
.contains("function createTreeShellFileTreeDndRuntime(context)")
);
assert!(
TREE_SHELL_RENDER_RUNTIME_JS
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")
);
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
.contains("function buildFileTreeMenuTarget(context"));
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS
.contains("function createTreeShellFileTreeDndRuntime(context)"));
assert!(TREE_SHELL_RENDER_RUNTIME_JS
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
assert!(
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")
);
@@ -2904,10 +2920,8 @@ mod tests {
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null"));
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
}
#[tokio::test]
@@ -3065,8 +3079,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert!(TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("/api/mnote-browser-runtime/tree-live-controller.js"));
assert!(html.contains("data-mnote-root-uri="));
assert!(TREE_SHELL_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
assert!(TREE_SHELL_RUNTIME_JS.contains("refreshLocalFolderSnapshot"));
assert!(!TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
assert!(!TREE_SHELL_RUNTIME_JS.contains("window.location.reload"));
}
@@ -3120,8 +3142,8 @@ mod tests {
}
#[tokio::test]
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint()
{
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint(
) {
let root =
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
@@ -3236,12 +3258,11 @@ mod tests {
String::from_utf8_lossy(&move_body)
);
assert!(!root.join("重命名页面").exists());
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
assert!(root
.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
let moved_document_id = move_payload["result"]["documentId"]
.as_str()
@@ -3281,12 +3302,11 @@ mod tests {
.as_str()
.expect("copied document id")
.to_string();
assert!(
root.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists()
);
assert!(root
.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists());
let folder_response = app()
.oneshot(
@@ -3319,13 +3339,12 @@ mod tests {
.expect("response");
assert_eq!(delete_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("重命名页面").exists());
assert!(
root.join(".mnote")
.join("trash")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
assert!(root
.join(".mnote")
.join("trash")
.join("重命名页面")
.join("重命名页面.md")
.exists());
assert!(root.join(".mnote").join("trash-index.json").exists());
assert!(!root.join(".mnote").join("page-ids.json").exists());
@@ -3352,12 +3371,11 @@ mod tests {
"{}",
String::from_utf8_lossy(&restore_body)
);
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
assert!(root
.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
assert_eq!(
restore_payload["result"]["documentId"].as_str(),
@@ -3573,12 +3591,10 @@ mod tests {
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "local_folder_root_escape");
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("root")
);
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("root"));
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
assert_eq!(
headers
@@ -3747,10 +3763,8 @@ mod tests {
assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(
filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
assert!(filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
@@ -3778,10 +3792,8 @@ mod tests {
assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(
picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
assert!(picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
}
#[tokio::test]
@@ -4197,13 +4209,11 @@ mod tests {
Some("convex://workspace/ws_demo")
);
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
assert!(
create_wire
.source
.capabilities
.iter()
.any(|capability| capability == "execute-command")
);
assert!(create_wire
.source
.capabilities
.iter()
.any(|capability| capability == "execute-command"));
let rename_wire = create_command_wire(
&context,
@@ -4395,31 +4405,21 @@ mod tests {
#[test]
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
assert!(
aliases
.iter()
.all(|entry| entry.compat_command.starts_with("documents."))
);
assert!(
aliases
.iter()
.all(|entry| entry.preferred_command.starts_with("tree."))
);
assert!(
aliases
.iter()
.all(|entry| entry.source_kind == "convex_workspace")
);
assert!(
aliases
.iter()
.all(|entry| entry.retained_for.contains("legacy cloud"))
);
assert!(
aliases
.iter()
.all(|entry| entry.retirement_condition.contains("emit tree."))
);
assert!(aliases
.iter()
.all(|entry| entry.compat_command.starts_with("documents.")));
assert!(aliases
.iter()
.all(|entry| entry.preferred_command.starts_with("tree.")));
assert!(aliases
.iter()
.all(|entry| entry.source_kind == "convex_workspace"));
assert!(aliases
.iter()
.all(|entry| entry.retained_for.contains("legacy cloud")));
assert!(aliases
.iter()
.all(|entry| entry.retirement_condition.contains("emit tree.")));
assert!(aliases.iter().any(|entry| {
entry.compat_command == "documents.delete"
&& entry.preferred_command == "tree.node.archive"
@@ -3,12 +3,12 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
@@ -265,13 +265,11 @@ fn normalize_state(
}
object.insert(
"scrollTop".to_string(),
json!(
object
.get("scrollTop")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0)
),
json!(object
.get("scrollTop")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0)),
);
if !object.contains_key("updatedAtMs") {
object.insert("updatedAtMs".to_string(), json!(0));
@@ -6,13 +6,13 @@ use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::{
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{Map, json};
use serde_json::{json, Map};
use std::collections::BTreeMap;
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2,13 +2,13 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{
StreamSnapshotQuery, build_stream_push_delta_hint, load_stream_snapshot,
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::response::Response;
use futures_util::StreamExt;
use serde_json::{Value, json};
use serde_json::{json, Value};
use tokio::sync::broadcast::error::RecvError;
pub async fn socket(
+3 -1
View File
@@ -33,6 +33,9 @@ pub fn HomePage(
/// 是否显示管理员授权入口
#[prop(optional)]
show_admin_access_policy: bool,
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
) -> impl IntoView {
let active_page_id = active_page_id.unwrap_or_default();
let active_page_title = active_page_title
@@ -50,7 +53,6 @@ pub fn HomePage(
.as_deref()
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
let enable_tree_live = false;
view! {
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
{move || if has_active_page {
+55 -12
View File
@@ -24,6 +24,15 @@ const SIDEBAR_TREE_JS: &str = r##"
"##;
fn 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
}
}
/// MNOTE Wolai 风格页面布局
///
/// 包含左侧栏 + 内容区的双栏布局。
@@ -152,17 +161,17 @@ pub fn PageLayout(
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
<script type="module" src="/api/mnote-browser-runtime/resource-open-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/local-upload-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-selection-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-context-menu-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-dnd-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-keyboard-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/sidebar-shell-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/sidebar-tree-runtime.js"></script>
<script type="module" src={browser_runtime_src("resource-open-runtime.js")}></script>
<script type="module" src={browser_runtime_src("local-upload-runtime.js")}></script>
<script type="module" src={browser_runtime_src("filetree-runtime.js")}></script>
<script type="module" src={browser_runtime_src("filetree-selection-runtime.js")}></script>
<script type="module" src={browser_runtime_src("filetree-context-menu-runtime.js")}></script>
<script type="module" src={browser_runtime_src("filetree-dnd-runtime.js")}></script>
<script type="module" src={browser_runtime_src("filetree-keyboard-runtime.js")}></script>
<script type="module" src={browser_runtime_src("sidebar-shell-runtime.js")}></script>
<script type="module" src={browser_runtime_src("sidebar-tree-runtime.js")}></script>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
<script type="module" src="/api/mnote-browser-runtime/tree-live-controller.js"></script>
<script type="module" src={browser_runtime_src("tree-live-controller.js")}></script>
</aside>
<div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div>
<div class="mnote-main">
@@ -385,6 +394,29 @@ mod tests {
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
}
#[test]
fn page_layout_adds_dev_hot_cache_buster_to_browser_runtime_scripts() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let html = crate::ssr::render_view(leptos::view! {
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
<main>"正文"</main>
</super::PageLayout>
});
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert!(
html.contains("/api/mnote-browser-runtime/tree-live-controller.js?devHot="),
"dev:hot 下 tree live controller URL 必须带 cache buster,避免浏览器继续执行旧 WS/SSE 逻辑"
);
assert!(
html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js?devHot="),
"dev:hot 下 sidebar runtime URL 也必须带 cache buster"
);
}
#[test]
fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts"));
@@ -587,12 +619,11 @@ mod tests {
}
#[test]
fn sidebar_tree_runtime_polls_local_folder_without_browser_reload() {
fn sidebar_tree_runtime_uses_local_folder_events_without_browser_reload() {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("startLocalFolderSidebarWatch"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot"));
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentWorkspaceId()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var rootUri = currentRootUri();"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
@@ -604,6 +635,18 @@ mod tests {
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
);
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-disabled")
);
assert!(
TREE_LIVE_CONTROLLER_JS.contains("body.getAttribute('data-mnote-source-kind')"),
"根入口由 SSR body 暴露 local_folder 时,tree live 不能误走 retired Convex WS/SSE"
);
assert!(
TREE_LIVE_CONTROLLER_JS.contains("bootstrap.transport === 'local-folder-events'"),
"本地文件夹 bootstrap transport 应直接选择 /api/local-folder/events"
);
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("fetch(window.location.href, { headers: { accept: 'text/html' } })"));
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
+16 -2
View File
@@ -2011,12 +2011,20 @@ body {
text-decoration: none !important;
}
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"] {
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"],
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-missing {
color: #9f1239 !important;
background: #fff1f2 !important;
box-shadow: inset 0 0 0 1px #fecdd3 !important;
}
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-unauthorized="true"],
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-unauthorized {
color: #92400e !important;
background: #fffbeb !important;
box-shadow: inset 0 0 0 1px #fde68a !important;
}
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before,
.document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before {
content: "" !important;
@@ -2029,10 +2037,16 @@ body {
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12) !important;
}
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before {
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before,
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-missing::before {
background: #e11d48 !important;
}
.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-unauthorized="true"]::before,
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-unauthorized::before {
background: #d97706 !important;
}
.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-word::before {
background: #4f7df3;
}