chore: 收口 review 执行清单与 runtime 验证
- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录 - 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目 - 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑 - 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
@@ -72,6 +72,10 @@ impl WebError {
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn status(&self) -> StatusCode {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for WebError {
|
||||
|
||||
@@ -102,6 +102,14 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn stamp_documents_headers(headers: &mut HeaderMap) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
@@ -711,13 +719,16 @@ pub async fn title(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let result = execution.result;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
@@ -731,6 +742,8 @@ pub async fn title(
|
||||
"meta": {
|
||||
"commandName": "page.head.updateTitle",
|
||||
"canonicalCommand": "page.head.updateTitle",
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
@@ -799,13 +812,16 @@ pub async fn options(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result = execute_runtime_command_via_convex(
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let result = execution.result;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
@@ -819,6 +835,8 @@ pub async fn options(
|
||||
"meta": {
|
||||
"commandName": "page.layout.updateOptions",
|
||||
"canonicalCommand": "page.layout.updateOptions",
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
@@ -907,6 +925,14 @@ mod tests {
|
||||
"updated_at": "2026-04-18T09:45:00Z",
|
||||
"revision": 8,
|
||||
"conflict_detection_key": "doc_1:8"
|
||||
},
|
||||
"bridgeLogs:recordCommandLog": {
|
||||
"ok": true,
|
||||
"id": "clog_fixture"
|
||||
},
|
||||
"bridgeLogs:recordDomainEvent": {
|
||||
"ok": true,
|
||||
"id": "evt_fixture"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
@@ -1066,6 +1092,24 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["meta"]["commandName"], "page.head.updateTitle");
|
||||
assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle");
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["commandLog"]["commandName"],
|
||||
"page.head.updateTitle"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
|
||||
"tree.node.renamed"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
|
||||
"upsert_document"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
|
||||
["title"],
|
||||
"服务端页面(改名)"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1102,6 +1146,19 @@ mod tests {
|
||||
payload["meta"]["canonicalCommand"],
|
||||
"page.layout.updateOptions"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["commandLog"]["commandName"],
|
||||
"page.layout.updateOptions"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
|
||||
"page.layout.options_updated"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
|
||||
"resync_required"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -402,4 +402,51 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mindmap_command_apply_returns_object_artifacts_without_tree_resync() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/mindmap/doc_1/mind_1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"commandName": "mindmap.command.apply",
|
||||
"commands": [
|
||||
{
|
||||
"type": "updateText",
|
||||
"mindmapId": "mind_1",
|
||||
"nodeId": "root",
|
||||
"text": "KMIND 已更新"
|
||||
}
|
||||
],
|
||||
"projectionRevision": 1
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(payload["commandName"], "mindmap.command.apply");
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["eventType"],
|
||||
"mindmap.content.updated"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["artifacts"]["domainEvent"]["payload"]["streamDelta"],
|
||||
json!({
|
||||
"op": "noop"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::app::AppConfig;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, Method, Request, Uri};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
@@ -715,6 +716,8 @@ pub async fn proxy(
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Query(query): Query<OnlyOfficeCallbackQuery>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Response {
|
||||
@@ -728,10 +731,41 @@ pub async fn callback(
|
||||
status,
|
||||
"OnlyOffice callback received by mnote-web"
|
||||
);
|
||||
Json(json!({ "error": 0 })).into_response()
|
||||
match proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/callback",
|
||||
uri.query(),
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => (
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_unavailable",
|
||||
"message": error.message(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(error) => (
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_failed",
|
||||
"message": error.message(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forcesave(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Query(query): Query<OnlyOfficeForcesaveQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let asset_id = query
|
||||
@@ -750,13 +784,85 @@ pub async fn forcesave(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"via": "mnote-web-rust-noop",
|
||||
"assetId": asset_id,
|
||||
"key": key,
|
||||
}))
|
||||
.into_response())
|
||||
let response = proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/forcesave",
|
||||
uri.query(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED {
|
||||
return WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"),
|
||||
);
|
||||
}
|
||||
error
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option<String> {
|
||||
config
|
||||
.legacy_next_base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
async fn proxy_legacy_onlyoffice_json(
|
||||
config: &AppConfig,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> Result<Response, WebError> {
|
||||
let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| {
|
||||
WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
"OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链",
|
||||
)
|
||||
})?;
|
||||
let target = append_path_and_query(&base, path, query);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"))
|
||||
})?;
|
||||
let mut request = client
|
||||
.post(target)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
} else {
|
||||
request = request.body("{}");
|
||||
}
|
||||
let upstream = request.send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回请求失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = upstream.status();
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
|
||||
let bytes = upstream.bytes().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回响应读取失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, content_type);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
|
||||
@@ -987,6 +1093,13 @@ fn js_hash_abs(input: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[test]
|
||||
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
||||
@@ -1003,4 +1116,166 @@ mod tests {
|
||||
let candidates = onlyoffice_internal_candidates();
|
||||
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
|
||||
}
|
||||
|
||||
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
|
||||
AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url,
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_legacy_json_server(
|
||||
response_body: &'static str,
|
||||
) -> (String, oneshot::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let (tx, rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
let mut buffer = vec![0_u8; 8192];
|
||||
let read = stream.read(&mut buffer).await.expect("read");
|
||||
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
|
||||
let _ = tx.send(request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.expect("write");
|
||||
});
|
||||
(format!("http://{addr}"), rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_without_legacy_next_fails_explicitly() {
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
"/api/onlyoffice/callback?assetId=asset_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["error"], 1);
|
||||
assert_eq!(payload["degraded"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
|
||||
let response = callback(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"key": "doc_key",
|
||||
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
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.contains(r#""status":2"#));
|
||||
assert!(request.contains(r#""key":"doc_key""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() {
|
||||
let response = forcesave(
|
||||
State(test_state(None)),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeForcesaveQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
key: Some("doc_key".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map(IntoResponse::into_response)
|
||||
.unwrap_or_else(IntoResponse::into_response);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable");
|
||||
assert_ne!(payload["via"], "mnote-web-rust-noop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) =
|
||||
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#)
|
||||
.await;
|
||||
let response = forcesave(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeForcesaveQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
key: Some("doc_key".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,8 @@ pub async fn documents(
|
||||
"owner": "mnote-web",
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"queryName": "search.documents.query",
|
||||
"degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
|
||||
"degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
},
|
||||
@@ -239,12 +241,13 @@ async fn load_search_results_with_filters(
|
||||
.await
|
||||
{
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => execute_runtime_query_against_data(
|
||||
Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data(
|
||||
context,
|
||||
Some(workspace_id),
|
||||
runtime_query,
|
||||
fallback_search_dataset(workspace_id),
|
||||
),
|
||||
Err(error) => Ok(degraded_empty_search_projection(error.message())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +259,16 @@ fn empty_search_projection() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn degraded_empty_search_projection(reason: &str) -> Value {
|
||||
json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
"results": [],
|
||||
"degraded": true,
|
||||
"degradedReason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn fallback_search_dataset(workspace_id: &str) -> Value {
|
||||
json!({
|
||||
"documents": [
|
||||
@@ -413,6 +426,8 @@ mod tests {
|
||||
assert!(html.contains("search.documents.query"));
|
||||
assert!(html.contains("search-result"));
|
||||
assert!(html.contains("Rust Web 搜索结果"));
|
||||
assert!(html.contains("data-mnote-dev-fixture=\"true\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -467,4 +482,53 @@ mod tests {
|
||||
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
|
||||
assert_eq!(payload["projectionOwner"], "rust-kernel");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() {
|
||||
let app = build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/search/documents")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"query": "Hermes"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["results"], json!([]));
|
||||
assert_eq!(payload["meta"]["degraded"], true);
|
||||
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2814,13 +2814,17 @@ fn build_tree_shell_html(
|
||||
}
|
||||
if (sourceKind === "convex_workspace") {
|
||||
for (const item of deletableItems) {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
await sendCommand({
|
||||
action: "delete",
|
||||
workspaceId,
|
||||
documentId: getFileTreeRowDocumentId(item),
|
||||
documentId,
|
||||
});
|
||||
applyRemovedDocumentLocally(documentId);
|
||||
}
|
||||
if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) {
|
||||
scheduleRefresh();
|
||||
}
|
||||
scheduleRefresh();
|
||||
return true;
|
||||
}
|
||||
postToHost("tree.filetree.delete", {
|
||||
@@ -2986,6 +2990,113 @@ fn build_tree_shell_html(
|
||||
}, 80);
|
||||
};
|
||||
|
||||
const addTreeItemLocally = (item) => {
|
||||
if (!item?.nodeId || itemById.has(item.nodeId)) return false;
|
||||
normalizedItems.push(item);
|
||||
itemById.set(item.nodeId, item);
|
||||
fileTreeRowById.set(item.rowId, item);
|
||||
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
|
||||
if (parentId) {
|
||||
const bucket = childrenByParentId.get(parentId) || [];
|
||||
bucket.push(item);
|
||||
bucket.sort(compareItems);
|
||||
childrenByParentId.set(parentId, bucket);
|
||||
const parentItem = itemById.get(parentId);
|
||||
if (parentItem) parentItem.childCount = Math.max(parentItem.childCount || 0, bucket.length);
|
||||
expanded.add(parentId);
|
||||
} else {
|
||||
roots.push(item);
|
||||
roots.sort(compareItems);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyCreatedDocumentLocally = (result, parentId, title) => {
|
||||
const documentId =
|
||||
typeof result?.documentId === "string" && result.documentId.trim()
|
||||
? result.documentId.trim()
|
||||
: typeof result?.id === "string" && result.id.trim()
|
||||
? result.id.trim()
|
||||
: "";
|
||||
if (!documentId) return false;
|
||||
const createdAt = normalizeText(result?.updatedAt || result?.execution?.updated_at);
|
||||
const parentNodeId = parentId && itemById.has(parentId) ? parentId : null;
|
||||
const item = {
|
||||
rowId: `doc:${documentId}`,
|
||||
rowKind: "document",
|
||||
nodeId: documentId,
|
||||
parentNodeId,
|
||||
title: normalizeText(result?.title, title || "无标题"),
|
||||
depth: parentNodeId ? normalizeNumber(itemById.get(parentNodeId)?.depth, 0) + 1 : 0,
|
||||
childCount: 0,
|
||||
position: normalizeNumber(result?.sortOrder ?? result?.execution?.sort_order ?? Date.now()),
|
||||
expandedByDefault: true,
|
||||
iconHint: "page",
|
||||
capabilities: ["open", "rename", "delete", "move", "create"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId,
|
||||
assetId: "",
|
||||
assetKind: "",
|
||||
objectIdentity: { objectKind: "page", documentId, blockId: null, assetId: null },
|
||||
blockAssetRelation: null,
|
||||
},
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
if (!addTreeItemLocally(item)) return false;
|
||||
currentFocusedDocumentId = documentId;
|
||||
focusedNodeId = documentId;
|
||||
currentActiveDocumentId = documentId;
|
||||
renderTree();
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeTreeItemEverywhere = (item) => {
|
||||
if (!item) return;
|
||||
const itemIndex = normalizedItems.indexOf(item);
|
||||
if (itemIndex >= 0) normalizedItems.splice(itemIndex, 1);
|
||||
itemById.delete(item.nodeId);
|
||||
fileTreeRowById.delete(item.rowId);
|
||||
const rootIndex = roots.indexOf(item);
|
||||
if (rootIndex >= 0) roots.splice(rootIndex, 1);
|
||||
const bucket = item.parentNodeId ? childrenByParentId.get(item.parentNodeId) : null;
|
||||
if (bucket) {
|
||||
const bucketIndex = bucket.indexOf(item);
|
||||
if (bucketIndex >= 0) bucket.splice(bucketIndex, 1);
|
||||
if (bucket.length === 0) childrenByParentId.delete(item.parentNodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const applyRemovedDocumentLocally = (documentId) => {
|
||||
const normalizedDocumentId = normalizeText(documentId);
|
||||
if (!normalizedDocumentId) return false;
|
||||
const removedNodeIds = new Set([normalizedDocumentId]);
|
||||
let changed = false;
|
||||
let expandedDuringScan = true;
|
||||
while (expandedDuringScan) {
|
||||
expandedDuringScan = false;
|
||||
normalizedItems.forEach((item) => {
|
||||
if (item.parentNodeId && removedNodeIds.has(item.parentNodeId) && !removedNodeIds.has(item.nodeId)) {
|
||||
removedNodeIds.add(item.nodeId);
|
||||
expandedDuringScan = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
normalizedItems.slice().forEach((item) => {
|
||||
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
|
||||
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
|
||||
removeTreeItemEverywhere(item);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (!changed) return false;
|
||||
if (currentActiveDocumentId === normalizedDocumentId) currentActiveDocumentId = roots[0]?.nodeId || "";
|
||||
if (currentFocusedDocumentId === normalizedDocumentId) currentFocusedDocumentId = currentActiveDocumentId;
|
||||
focusedNodeId = resolveFocusedNodeIdFromHostState();
|
||||
renderTree();
|
||||
return true;
|
||||
};
|
||||
|
||||
if (sourceKind === "local_folder" && rootUri) {
|
||||
let localWatchRevision = initialLocalWatchRevision;
|
||||
let localWatchRefreshTimer = 0;
|
||||
@@ -4335,6 +4446,14 @@ fn build_tree_shell_html(
|
||||
payload: { documentId },
|
||||
});
|
||||
}
|
||||
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
|
||||
if (mode === "filetree") {
|
||||
beginInlineRename("filetree", `doc:${documentId}`);
|
||||
} else {
|
||||
beginInlineRename("page", documentId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleRefresh({
|
||||
renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
|
||||
});
|
||||
@@ -6688,6 +6807,10 @@ mod tests {
|
||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||
assert!(html.contains("hydrateInitialPageTree"));
|
||||
assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||
assert!(html.contains("applyCreatedDocumentLocally"));
|
||||
assert!(html.contains("applyRemovedDocumentLocally"));
|
||||
assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
||||
assert!(html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
|
||||
assert!(html.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(html.contains("页面已拖放到"));
|
||||
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
|
||||
|
||||
@@ -2437,10 +2437,9 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
query: None,
|
||||
max_results: None,
|
||||
};
|
||||
let dataset = load_projection_snapshot(config, context, &spec)
|
||||
.await
|
||||
.map(|snapshot| snapshot.dataset)
|
||||
.unwrap_or_else(|_| {
|
||||
let dataset = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => snapshot.dataset,
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
let documents = active_document_id
|
||||
.map(|document_id| {
|
||||
json!([{
|
||||
@@ -2457,9 +2456,19 @@ pub(crate) async fn load_workspace_shell_projection(
|
||||
"active_workspace_id": workspace_id,
|
||||
"active_page_id": active_document_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
||||
"documents": documents
|
||||
"documents": documents,
|
||||
"dev_fixture": true
|
||||
})
|
||||
});
|
||||
}
|
||||
Err(_) => json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"active_page_id": active_document_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
|
||||
"documents": [],
|
||||
"degraded": true,
|
||||
"degraded_reason": "projection_unavailable"
|
||||
}),
|
||||
};
|
||||
|
||||
build_workspace_shell_projection(
|
||||
&dataset,
|
||||
@@ -2489,7 +2498,7 @@ pub(crate) async fn load_sidebar_tree_html(
|
||||
max_results: None,
|
||||
};
|
||||
let result = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => Some(snapshot.projection),
|
||||
Ok(snapshot) => Some((snapshot.projection, false)),
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
|
||||
let documents = active_document_id
|
||||
@@ -2518,17 +2527,20 @@ pub(crate) async fn load_sidebar_tree_html(
|
||||
"mindmap_docs": [],
|
||||
"mindmap_asset_children": {}
|
||||
});
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
|
||||
.ok()
|
||||
.map(|projection| (projection, true))
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
result.map(|projection| {
|
||||
result.map(|(projection, dev_fixture)| {
|
||||
let rows = collect_page_tree_render_rows(&projection);
|
||||
render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
rows,
|
||||
active_node_id: active_document_id.map(ToOwned::to_owned),
|
||||
focused_node_id: None,
|
||||
})
|
||||
});
|
||||
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2550,7 +2562,7 @@ pub(crate) async fn load_file_tree_html(
|
||||
max_results: None,
|
||||
};
|
||||
let result = match load_projection_snapshot(config, context, &spec).await {
|
||||
Ok(snapshot) => Some(snapshot.projection),
|
||||
Ok(snapshot) => Some((snapshot.projection, false)),
|
||||
Err(_) if config.allow_dev_fixtures => {
|
||||
let documents = active_document_id
|
||||
.map(|document_id| {
|
||||
@@ -2577,16 +2589,28 @@ pub(crate) async fn load_file_tree_html(
|
||||
"mindmap_docs": [],
|
||||
"mindmap_asset_children": {}
|
||||
});
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
|
||||
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
|
||||
.ok()
|
||||
.map(|projection| (projection, true))
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
result.map(|projection| {
|
||||
result.map(|(projection, dev_fixture)| {
|
||||
let rows = collect_filetree_render_rows(&projection, active_document_id);
|
||||
render_initial_filetree_html(&FileTreeInitialRenderInput { rows })
|
||||
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
|
||||
mark_dev_fixture_html(html, dev_fixture, "file-tree")
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String {
|
||||
if !dev_fixture {
|
||||
return html;
|
||||
}
|
||||
format!(
|
||||
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="{kind}"></span>{html}"#
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_sidebar_tree_html(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
@@ -2614,8 +2638,9 @@ pub(crate) fn render_local_file_tree_html(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2701,6 +2726,9 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
|
||||
assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\""));
|
||||
assert!(html.contains("data-testid=\"wolai-topbar\""));
|
||||
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
|
||||
assert!(html.contains("星标置顶"));
|
||||
@@ -2755,7 +2783,7 @@ mod tests {
|
||||
.headers()
|
||||
.get("x-mnote-page-aggregate-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-kernel")
|
||||
Some("compat-join")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
@@ -2770,7 +2798,7 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(payload["result"]["source"], "KernelProjection");
|
||||
assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
|
||||
assert_eq!(payload["result"]["projectionVersion"], 1);
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
@@ -2893,6 +2921,56 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() {
|
||||
let config = AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
};
|
||||
let headers = HeaderMap::new();
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/documents/doc_1?workspaceId=ws_demo"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
let sidebar_html =
|
||||
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
|
||||
let filetree_html =
|
||||
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
|
||||
let workspace_projection = super::load_workspace_shell_projection(
|
||||
&config,
|
||||
&context,
|
||||
"ws_demo",
|
||||
Some("doc_1"),
|
||||
"个人空间",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(sidebar_html.is_none());
|
||||
assert!(filetree_html.is_none());
|
||||
assert!(workspace_projection.degraded);
|
||||
assert!(workspace_projection.my_page_items.is_empty());
|
||||
assert!(!workspace_projection.dev_fixture);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -77,6 +77,146 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {};
|
||||
}
|
||||
|
||||
function textFromUnknown(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' ');
|
||||
if (typeof value !== 'object') return '';
|
||||
var parts = [];
|
||||
['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
var text = textFromUnknown(value[key]);
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
});
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function readLocalEditorBlocks() {
|
||||
var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return [];
|
||||
return Array.from(editor.children).filter(function(node) {
|
||||
return node instanceof HTMLElement;
|
||||
}).map(function(node, index) {
|
||||
var tag = String(node.tagName || '').toUpperCase();
|
||||
var headingMatch = tag.match(/^H([1-6])$/);
|
||||
var type = headingMatch ? 'heading' : 'paragraph';
|
||||
var text = searchText(node.textContent || '');
|
||||
var id = searchText(node.getAttribute('data-id') || node.id || 'local-block-' + index);
|
||||
var props = headingMatch ? { level: Number(headingMatch[1]) } : {};
|
||||
return { id: id, type: type, props: props, content: text };
|
||||
}).filter(function(block) {
|
||||
return block.content || block.type === 'heading';
|
||||
});
|
||||
}
|
||||
|
||||
function buildPageAiLocalSubtree(blocks, title) {
|
||||
var documentId = currentDocumentId() || 'current-page';
|
||||
var rootNodeId = 'page:' + documentId;
|
||||
var headingCounters = [0, 0, 0, 0, 0, 0];
|
||||
var headingStack = [];
|
||||
var nodes = [{
|
||||
id: rootNodeId,
|
||||
nodeId: rootNodeId,
|
||||
nodeType: 'page',
|
||||
blockId: null,
|
||||
blockType: 'page',
|
||||
title: title || '',
|
||||
parentNodeId: null,
|
||||
headingLevel: null
|
||||
}];
|
||||
var outline = [];
|
||||
var evidence = [];
|
||||
blocks.forEach(function(block, index) {
|
||||
var level = block.type === 'heading' ? Math.max(1, Math.min(6, Number(block.props && block.props.level || 1))) : null;
|
||||
if (level != null) {
|
||||
while (headingStack.length && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
|
||||
}
|
||||
var parentNodeId = headingStack.length ? headingStack[headingStack.length - 1].nodeId : rootNodeId;
|
||||
var nodeId = 'local-node:' + String(block.id || index);
|
||||
var titleText = block.content || (block.type === 'heading' ? '未命名标题' : '');
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
nodeId: nodeId,
|
||||
nodeType: 'block',
|
||||
blockId: block.id,
|
||||
blockType: block.type,
|
||||
title: titleText,
|
||||
parentNodeId: parentNodeId,
|
||||
headingLevel: level
|
||||
});
|
||||
if (level != null) {
|
||||
headingCounters[level - 1] += 1;
|
||||
for (var i = level; i < headingCounters.length; i += 1) headingCounters[i] = 0;
|
||||
var numbering = headingCounters.slice(0, level).filter(function(count) { return count > 0; }).join('.');
|
||||
outline.push({
|
||||
id: 'local-outline:' + String(block.id || index),
|
||||
nodeId: nodeId,
|
||||
anchorBlockId: block.id,
|
||||
level: level,
|
||||
title: titleText,
|
||||
numbering: numbering
|
||||
});
|
||||
headingStack.push({ level: level, nodeId: nodeId });
|
||||
}
|
||||
if (titleText) {
|
||||
evidence.push({
|
||||
id: 'local-evidence:' + String(block.id || index),
|
||||
nodeId: nodeId,
|
||||
anchorBlockId: block.id,
|
||||
text: titleText,
|
||||
kind: block.type
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
projectionId: 'local-editor-dom:' + documentId,
|
||||
rootNode: {
|
||||
id: rootNodeId,
|
||||
documentId: documentId,
|
||||
title: title || '',
|
||||
nodeType: 'page'
|
||||
},
|
||||
subtree: {
|
||||
rootNodeId: rootNodeId,
|
||||
nodes: nodes
|
||||
},
|
||||
outline: outline,
|
||||
evidence: evidence,
|
||||
stats: {
|
||||
nodeCount: nodes.length,
|
||||
headingCount: outline.length,
|
||||
evidenceCount: evidence.length
|
||||
},
|
||||
source: 'local'
|
||||
};
|
||||
}
|
||||
|
||||
function currentPageAiContextSnapshot() {
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var serverContent = body.content || null;
|
||||
var serverText = searchText(textFromUnknown(serverContent));
|
||||
var localBlocks = readLocalEditorBlocks();
|
||||
var localText = searchText(localBlocks.map(function(block) { return block.content || ''; }).join(' '));
|
||||
var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
|
||||
if (localBlocks.length && localText && localText !== serverText) {
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: Object.assign({}, body, { content: localBlocks }),
|
||||
subtree: buildPageAiLocalSubtree(localBlocks, title),
|
||||
pageSubtreeSource: 'local'
|
||||
};
|
||||
}
|
||||
return {
|
||||
aggregate: aggregate,
|
||||
body: body,
|
||||
subtree: serverSubtree,
|
||||
pageSubtreeSource: serverSubtree ? 'server' : 'none'
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPageOptions() {
|
||||
return {
|
||||
wideLayout: false,
|
||||
@@ -844,6 +984,90 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
}
|
||||
|
||||
function documentIdFromDelta(data) {
|
||||
return String(data && (data.documentId || data.id || (data.document && data.document.id) || (data.node && data.node.id) || (data.args && data.args.documentId)) || '').trim();
|
||||
}
|
||||
|
||||
function parentIdFromDelta(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'parentId')) return data.parentId ? String(data.parentId).trim() : null;
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'parent_id')) return data.parent_id ? String(data.parent_id).trim() : null;
|
||||
if (data.args && Object.prototype.hasOwnProperty.call(data.args, 'parentId')) return data.args.parentId ? String(data.args.parentId).trim() : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function treeRootForMode(mode) {
|
||||
var id = mode === 'filetree' ? 'sidebar-file-tree-root' : 'sidebar-tree-root';
|
||||
return document.querySelector('#' + id + ' .tree-root');
|
||||
}
|
||||
|
||||
function rowSelectorForDocument(mode, documentId) {
|
||||
var escaped = cssEscape(documentId);
|
||||
if (mode === 'filetree') {
|
||||
return '.tree-row[data-shell-mode="filetree"][data-doc-id="' + escaped + '"], .tree-row[data-shell-mode="filetree"][data-document-id="' + escaped + '"]';
|
||||
}
|
||||
return '.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"]';
|
||||
}
|
||||
|
||||
function ensureTreeChildren(parentRow) {
|
||||
var parentNode = parentRow ? parentRow.closest('.tree-node') : null;
|
||||
if (!parentNode) return null;
|
||||
var children = parentNode.querySelector(':scope > .tree-children');
|
||||
if (!children) {
|
||||
children = document.createElement('ul');
|
||||
children.className = 'tree-children';
|
||||
parentNode.appendChild(children);
|
||||
}
|
||||
children.classList.remove('tree-children--collapsed');
|
||||
parentRow.setAttribute('aria-expanded', 'true');
|
||||
var toggle = parentRow.querySelector('[data-rust-action="toggle"]');
|
||||
if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
||||
return children;
|
||||
}
|
||||
|
||||
function removeDocumentRowForMode(mode, documentId) {
|
||||
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
||||
var node = row ? row.closest('.tree-node') : null;
|
||||
if (!node || !node.parentElement) return false;
|
||||
node.parentElement.removeChild(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveDocumentRowForMode(mode, documentId, parentId) {
|
||||
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
||||
var node = row ? row.closest('.tree-node') : null;
|
||||
var root = treeRootForMode(mode);
|
||||
if (!row || !node || !root) return false;
|
||||
var targetContainer = root;
|
||||
if (parentId) {
|
||||
var parentRow = document.querySelector(rowSelectorForDocument(mode, parentId));
|
||||
targetContainer = ensureTreeChildren(parentRow);
|
||||
if (!targetContainer) return false;
|
||||
row.setAttribute('data-parent-id', parentId);
|
||||
} else {
|
||||
row.removeAttribute('data-parent-id');
|
||||
}
|
||||
targetContainer.appendChild(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyMoveDocumentDelta(data) {
|
||||
var documentId = documentIdFromDelta(data);
|
||||
if (!documentId) return false;
|
||||
var parentId = parentIdFromDelta(data);
|
||||
var movedPage = moveDocumentRowForMode('page', documentId, parentId);
|
||||
var movedFile = moveDocumentRowForMode('filetree', documentId, parentId);
|
||||
return movedPage || movedFile;
|
||||
}
|
||||
|
||||
function applyRemoveDocumentDelta(data) {
|
||||
var documentId = documentIdFromDelta(data);
|
||||
if (!documentId) return false;
|
||||
var removedPage = removeDocumentRowForMode('page', documentId);
|
||||
var removedFile = removeDocumentRowForMode('filetree', documentId);
|
||||
return removedPage || removedFile;
|
||||
}
|
||||
|
||||
function readProjection(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
if (value.result && typeof value.result === 'object') return value.result;
|
||||
@@ -879,6 +1103,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
|
||||
}
|
||||
|
||||
function hasProjectionItems(projection) {
|
||||
var resolved = readProjection(projection);
|
||||
return Boolean(resolved && Array.isArray(resolved.items));
|
||||
}
|
||||
|
||||
function nodeIdOf(item) {
|
||||
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
|
||||
}
|
||||
@@ -1024,9 +1253,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function renderSidebarSnapshot(payload) {
|
||||
var renderedPage = renderPageProjection(payload);
|
||||
var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false;
|
||||
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
||||
var renderedFile = fileProjection ? renderFileProjection(fileProjection) : false;
|
||||
var renderedFile = fileProjection && hasProjectionItems(fileProjection) ? renderFileProjection(fileProjection) : false;
|
||||
return renderedPage || renderedFile;
|
||||
}
|
||||
|
||||
@@ -2805,9 +3034,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var prompt = searchText(text);
|
||||
if (!prompt) return;
|
||||
pageAiLoadSessions();
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var contextSnapshot = currentPageAiContextSnapshot();
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiBusy = true;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
@@ -2843,6 +3073,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
},
|
||||
subtree: subtree,
|
||||
outline: outline,
|
||||
pageSubtreeSource: contextSnapshot.pageSubtreeSource || 'none',
|
||||
evidence: null,
|
||||
pageOptions: currentPageOptions()
|
||||
},
|
||||
@@ -3890,6 +4121,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.addEventListener('tree:delta', function(event) {
|
||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||
var data = payload && (payload.data || payload.delta || payload);
|
||||
var documentPatch = data && (data.document || data.node);
|
||||
if (data && data.op === 'upsert_document' && documentPatch) {
|
||||
updateTitleEverywhere(documentPatch.id || documentPatch.documentId, documentPatch.title || '无标题');
|
||||
}
|
||||
if (data && data.op === 'move_document' && applyMoveDocumentDelta(data)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
||||
return;
|
||||
}
|
||||
if (data && data.op === 'remove_document' && applyRemoveDocumentDelta(data)) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
|
||||
return;
|
||||
}
|
||||
var documents = data && (data.upsertDocuments || data.upsert_documents);
|
||||
if (Array.isArray(documents)) {
|
||||
documents.forEach(function(doc) {
|
||||
|
||||
@@ -376,17 +376,27 @@ pub async fn execute_convex_query_by_name(
|
||||
|
||||
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
let mut args = plan.args_json.clone();
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
"tree.node.create"
|
||||
| "tree.node.rename"
|
||||
| "tree.node.archive"
|
||||
| "tree.node.restore"
|
||||
| "tree.subtree.move"
|
||||
| "documents.create"
|
||||
| "documents.title.update"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.move"
|
||||
) {
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if matches!(plan.command_name.as_str(), "mindmaps.put")
|
||||
|| matches!(plan.function_name.as_str(), "mindmaps:put")
|
||||
{
|
||||
if let Value::Object(map) = &mut args {
|
||||
// Rust plan 保留 tree domain event / stream hint 作为正式契约;
|
||||
// Convex mindmaps.put legacy validator 仍只接收真实写入字段。
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
// Rust plan 保留 tree domain event / stream hint 作为正式契约;
|
||||
// Convex mindmaps.put legacy validator 仍只接收真实写入字段。
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
@@ -397,11 +407,16 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
// Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。
|
||||
map.remove("editorDocument");
|
||||
map.remove("tiptapDocument");
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
"documents.options.update" | "page.layout.updateOptions"
|
||||
) {
|
||||
// documents:updateOptions 仍只接收页面设置字段;
|
||||
// tree stream hint 留在 Rust artifact plan 中持久化。
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if plan.command_name == "mindmap.command.apply" {
|
||||
if let Value::Object(map) = &mut args {
|
||||
@@ -418,6 +433,15 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
args
|
||||
}
|
||||
|
||||
fn strip_tree_artifact_fields(args: &mut Value) {
|
||||
if let Value::Object(map) = args {
|
||||
map.remove("streamDeltaHint");
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_convex_command_plan(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -900,6 +924,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_page_options_artifacts_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "page.layout.updateOptions".into(),
|
||||
command_id: "cmd_options_1".into(),
|
||||
function_name: "documents:updateOptions".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"options": {
|
||||
"showToc": false,
|
||||
"layoutDensity": "compact",
|
||||
},
|
||||
"streamDeltaHint": {"family": "tree"},
|
||||
"domainEventHint": {"eventType": "page.layout.options_updated"},
|
||||
"domainEventPlan": {"eventType": "page.layout.options_updated"},
|
||||
"domainEventPlans": [{"eventType": "page.layout.options_updated"}],
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
"options": {
|
||||
"showToc": false,
|
||||
"layoutDensity": "compact",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_tree_archive_artifacts_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "tree.node.archive".into(),
|
||||
command_id: "cmd_archive_1".into(),
|
||||
function_name: "documents:softDelete".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
|
||||
"domainEventHint": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlan": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlans": [{"eventType": "tree.node.archived"}],
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
|
||||
@@ -9,6 +9,9 @@ pub struct WorkspaceShellProjection {
|
||||
pub workspace_name: String,
|
||||
pub active_page_id: Option<String>,
|
||||
pub active_page_title: Option<String>,
|
||||
pub degraded: bool,
|
||||
pub degraded_reason: Option<String>,
|
||||
pub dev_fixture: bool,
|
||||
pub starred_items: Vec<WorkspaceShellItem>,
|
||||
pub my_page_items: Vec<WorkspaceShellItem>,
|
||||
pub bottom_entries: Vec<WorkspaceShellEntry>,
|
||||
@@ -88,6 +91,23 @@ pub fn build_workspace_shell_projection(
|
||||
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
|
||||
|
||||
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
|
||||
let degraded = dataset
|
||||
.get("degraded")
|
||||
.or_else(|| dataset.get("is_degraded"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let degraded_reason = dataset
|
||||
.get("degraded_reason")
|
||||
.or_else(|| dataset.get("degradedReason"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let dev_fixture = dataset
|
||||
.get("dev_fixture")
|
||||
.or_else(|| dataset.get("devFixture"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
WorkspaceShellProjection {
|
||||
schema: "mnote.workspace_shell.v1".into(),
|
||||
@@ -95,6 +115,9 @@ pub fn build_workspace_shell_projection(
|
||||
workspace_name,
|
||||
active_page_id,
|
||||
active_page_title,
|
||||
degraded,
|
||||
degraded_reason,
|
||||
dev_fixture,
|
||||
starred_items,
|
||||
my_page_items,
|
||||
bottom_entries: vec![
|
||||
@@ -278,6 +301,8 @@ mod tests {
|
||||
assert_eq!(projection.workspace_name, "开发用户 的工作区");
|
||||
assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
|
||||
assert_eq!(projection.active_page_title.as_deref(), Some("个人"));
|
||||
assert!(!projection.degraded);
|
||||
assert!(!projection.dev_fixture);
|
||||
assert_eq!(projection.starred_items.len(), 1);
|
||||
assert_eq!(projection.starred_items[0].title, "个人");
|
||||
assert_eq!(projection.my_page_items.len(), 2);
|
||||
@@ -368,6 +393,42 @@ mod tests {
|
||||
|
||||
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_shell_sidebar_html_marks_degraded_and_dev_fixture_states() {
|
||||
let degraded_dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": [],
|
||||
"degraded": true,
|
||||
"degraded_reason": "projection_unavailable"
|
||||
});
|
||||
let degraded_projection = build_workspace_shell_projection(
|
||||
°raded_dataset,
|
||||
"ws_demo",
|
||||
None,
|
||||
"开发用户 的工作区",
|
||||
);
|
||||
let degraded_html = render_workspace_shell_sidebar_html(°raded_projection, None, None);
|
||||
|
||||
assert!(degraded_projection.degraded);
|
||||
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
|
||||
assert!(degraded_html.contains(
|
||||
"data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\""
|
||||
));
|
||||
|
||||
let dev_dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
"documents": [],
|
||||
"dev_fixture": true
|
||||
});
|
||||
let dev_projection =
|
||||
build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区");
|
||||
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None);
|
||||
|
||||
assert!(dev_projection.dev_fixture);
|
||||
assert!(dev_html.contains("data-mnote-dev-fixture=\"true\""));
|
||||
assert!(dev_html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_workspace_shell_sidebar_html(
|
||||
@@ -434,9 +495,25 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let status_markers = format!(
|
||||
r#"{degraded_marker}{dev_fixture_marker}"#,
|
||||
degraded_marker = if projection.degraded {
|
||||
format!(
|
||||
r#"<span hidden data-mnote-workspace-shell-degraded="true" data-mnote-workspace-shell-degraded-reason="{}"></span>"#,
|
||||
escape_html(projection.degraded_reason.as_deref().unwrap_or("projection_unavailable")),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
dev_fixture_marker = if projection.dev_fixture {
|
||||
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="workspace-shell"></span>"#.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
);
|
||||
|
||||
format!(
|
||||
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="page" aria-selected="true" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="filetree" aria-selected="false" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page">{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree" hidden>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
|
||||
render_symbol("star", "wolai-section-icon"),
|
||||
render_symbol("folder_open", "wolai-folder-icon"),
|
||||
escape_html(&projection.workspace_id),
|
||||
|
||||
Reference in New Issue
Block a user