收口本地工作区清理与资源投影

清理历史 Electron、Graphify、沙箱和截图等仓库跟踪残留,补充 CodeGraph 与 Convex active deploy source 协作说明。

新增 tree-first 下一阶段设计稿和 2026-05-20 清理总结,记录本地工作区、路径身份和 Zed/Lapce/VSCode 参考收口方向。

扩展 Rust Web 本地文件夹、DocumentBuffer、mindmap 资源、tree runtime 和页面聚合链路,并补充 task455 local-folder mindmap clean smoke。

验证:git diff --check 通过;pnpm store status --store-dir .pnpm-store 通过;npm ls --depth=0 --json 通过;find -L node_modules 未发现断链。cargo test -p mnote-web 当前 418 passed / 35 failed。
This commit is contained in:
lix-2026
2026-05-20 10:43:38 +08:00
parent 90818cdf75
commit b4c8bcb647
73 changed files with 8073 additions and 8240 deletions
+49 -31
View File
@@ -521,6 +521,7 @@ pub async fn content(
}
pub async fn page_body_write(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<core_protocol::PageBodyWriteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -548,7 +549,7 @@ pub async fn page_body_write(
}
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let result = write_local_markdown_page_body(&body)?;
let result = write_local_markdown_page_body(&body, Some(&state.buffer_store))?;
Ok(ok_response(&context, result))
}
@@ -580,23 +581,26 @@ pub async fn save(
.expected_file_version
.as_deref()
.or(body.conflict_detection_key.as_deref());
let result = write_local_markdown_page_body(&core_protocol::PageBodyWriteRequest {
document_id: document_id.to_string(),
workspace_id: body.workspace_id.clone().unwrap_or_default(),
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(),
expected_file_version: expected_file_version.map(ToOwned::to_owned),
base_content_hash: body.base_content_hash.clone(),
content_format: body
.content_format
.clone()
.unwrap_or_else(|| "editorBlocks".into()),
content: body.content.clone(),
editor_source: body
.editor_source
.clone()
.or_else(|| Some("documents/save-compat".into())),
})?;
let result = write_local_markdown_page_body(
&core_protocol::PageBodyWriteRequest {
document_id: document_id.to_string(),
workspace_id: body.workspace_id.clone().unwrap_or_default(),
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(),
expected_file_version: expected_file_version.map(ToOwned::to_owned),
base_content_hash: body.base_content_hash.clone(),
content_format: body
.content_format
.clone()
.unwrap_or_else(|| "editorBlocks".into()),
content: body.content.clone(),
editor_source: body
.editor_source
.clone()
.or_else(|| Some("documents/save-compat".into())),
},
Some(&state.buffer_store),
)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
@@ -1368,10 +1372,10 @@ mod tests {
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::create_dir_all(root.join("Old Local Title")).expect("create local page bundle");
std::fs::write(
root.join("README.md"),
"---\nmnote_id: local-stable\ntitle: Old Title\n---\n# Old\n",
root.join("Old Local Title").join("Old Local Title.md"),
"# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
@@ -1380,7 +1384,7 @@ mod tests {
&root_uri,
)
.expect("init local workspace");
let document_id = "local-mdid:local-stable";
let document_id = "local-md:Old~20Local~20Title~2FOld~20Local~20Title.md";
let title_response = app()
.oneshot(
@@ -1404,6 +1408,14 @@ mod tests {
.await
.expect("title response");
assert_eq!(title_response.status(), StatusCode::OK);
let title_body = to_bytes(title_response.into_body(), usize::MAX)
.await
.expect("title body");
let title_payload: Value = serde_json::from_slice(&title_body).expect("title json");
let renamed_document_id = title_payload["result"]["documentId"]
.as_str()
.expect("renamed document id")
.to_string();
let save_response = app()
.oneshot(
@@ -1415,7 +1427,7 @@ mod tests {
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"documentId": renamed_document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"content": [
@@ -1460,7 +1472,7 @@ mod tests {
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"documentId": renamed_document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"options": {
@@ -1476,17 +1488,23 @@ mod tests {
.expect("options response");
assert_eq!(options_response.status(), StatusCode::OK);
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("mnote_id: local-stable"));
assert!(markdown.contains("title: New Local Title"));
let markdown =
std::fs::read_to_string(root.join("New Local Title").join("New Local Title.md"))
.expect("read md");
assert!(markdown.contains("## Saved Heading"));
assert!(markdown.contains("Saved body"));
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
.expect("read page options");
let options_json: Value = serde_json::from_str(&options).expect("options json");
assert_eq!(options_json["pages"][document_id]["wideLayout"], true);
assert_eq!(options_json["pages"][document_id]["showToc"], false);
assert_eq!(
options_json["pages"][&renamed_document_id]["wideLayout"],
true
);
assert_eq!(
options_json["pages"][&renamed_document_id]["showToc"],
false
);
let _ = std::fs::remove_dir_all(&root);
}
@@ -1510,7 +1528,7 @@ mod tests {
&root_uri,
)
.expect("init local workspace");
let document_id = "local-mdid:expected-file-version";
let document_id = "local-md:README.md";
let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate(
&root_uri,
document_id,
@@ -1582,7 +1600,7 @@ mod tests {
assert!(payload["details"]["conflict"]["currentDiskVersion"]
.as_str()
.unwrap_or("")
.starts_with("local-md:local-mdid:expected-file-version:"));
.starts_with("local-md:local-md:README.md:"));
assert!(payload["details"]["conflict"]["suggestedActions"]
.as_array()
.unwrap()
+597 -17
View File
@@ -1,9 +1,10 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::context::{stable_actor_id, RequestContext};
use crate::error::WebError;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
load_local_folder_page_tree_snapshot, local_access_policy_path_display,
create_default_local_workspace_for_actor, ensure_local_workspace_read_access,
is_local_access_policy_admin_context, load_local_folder_page_tree_snapshot,
load_local_trash_entries, local_access_policy_path_display,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
@@ -22,10 +23,12 @@ use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use futures_util::{SinkExt, StreamExt};
use leptos::prelude::InnerHtmlAttribute;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
@@ -36,6 +39,8 @@ const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -305,24 +310,58 @@ pub async fn root_entry(
Some(root_uri.to_string()),
)
} else if should_render_local_first_landing {
let workspace_id = "local-first-entry".to_string();
let payload = create_default_local_workspace_for_actor(
&context.auth.actor_id,
&context.auth.actor_type,
)
.map_err(|error| error.with_context(&context))?;
let root_uri = payload
.get("workspace")
.and_then(|workspace| workspace.get("rootUri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
})?
.to_string();
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
let workspace_id = snapshot
.dataset
.get("workspace")
.and_then(|workspace| workspace.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("local-folder")
.to_string();
let workspace_projection = build_workspace_shell_projection(
&json!({
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
}),
&snapshot.dataset,
&workspace_id,
None,
requested_page_id.as_deref(),
"我的空间",
);
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html =
render_local_sidebar_tree_html(&root_uri, selected_active_page_id.as_deref())?;
let file_tree_html =
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref())?;
(
workspace_id,
workspace_projection,
String::new(),
String::new(),
None,
None,
None,
sidebar_tree_html,
file_tree_html,
selected_active_page_id,
Some("local_folder".to_string()),
Some(root_uri),
)
} else {
let workspace_id =
@@ -448,6 +487,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")}
/>
});
let body_extra = format!(
@@ -475,7 +515,7 @@ pub async fn root_entry(
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}">
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
{}
{}
</body>
@@ -483,6 +523,8 @@ pub async fn root_entry(
escape_html(&html_title),
crate::ssr::MNOTE_CSS,
escape_html(context.auth.actor_id.as_str()),
escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
escape_html(active_root_uri.as_deref().unwrap_or("")),
content,
body_extra
))
@@ -506,6 +548,81 @@ pub async fn trash_entry(
return Ok(response);
}
let source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if source_kind == Some("local_folder") {
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None).unwrap_or_default();
let workspace_projection = build_workspace_shell_projection(
&json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
}),
&workspace_id,
None,
"我的空间",
);
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
let trash_workbench_html = render_local_trash_workbench_html(
&workspace_id,
root_uri,
&load_local_trash_entries(root_uri)?,
);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="trash"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={"我的空间".to_string()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"垃圾箱".to_string()}
enable_tree_live={false}
>
<div inner_html={trash_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>本地文件夹垃圾箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
escape_html(root_uri),
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
return Ok(response);
}
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
@@ -815,6 +932,202 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
)
}
fn render_local_trash_workbench_html(
workspace_id: &str,
root_uri: &str,
entries: &BTreeMap<String, crate::routes::local_folder_source::LocalTrashEntry>,
) -> String {
let mut document_rows = Vec::new();
let mut resource_rows = Vec::new();
for (entry_id, entry) in entries {
let deleted_at = entry.deleted_at_ms.to_string();
let original = escape_html(&entry.original_relative_path);
let trash_path = escape_html(&entry.trash_relative_path);
let kind = escape_html(&entry.resource_kind);
let row = format!(
r#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}">
<div class="mnote-trash-row-main">
<span class="mnote-trash-kind">{kind}</span>
<span class="mnote-trash-title">{original}</span>
<span class="mnote-trash-meta">回收站:{trash_path} · {deleted_at}</span>
</div>
<div class="mnote-trash-actions">
<button type="button" data-trash-action="local-restore" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">恢复</button>
<button type="button" data-trash-action="local-purge" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">彻底删除</button>
</div>
</article>"#,
entry_id = escape_html(entry_id),
kind = kind,
original = original,
trash_path = trash_path,
deleted_at = escape_html(&deleted_at),
command_id = escape_html(local_trash_command_id(entry_id, entry).as_str()),
);
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
document_rows.push(row);
} else {
resource_rows.push(row);
}
}
let document_body = if document_rows.is_empty() {
r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string()
} else {
document_rows.join("")
};
let resource_body = if resource_rows.is_empty() {
r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
} else {
resource_rows.join("")
};
format!(
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-trash-source-kind="local_folder" data-workspace-id="{workspace_id}" data-root-uri="{root_uri}" data-trash-fetch-path="/trash">
<header class="mnote-trash-header">
<h1>本地文件夹垃圾箱</h1>
<p>本地删除项保存在当前目录的 <code>.mnote/trash</code> 与 <code>.mnote/trash-index.json</code> 中。</p>
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
</header>
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
<div class="mnote-trash-section-title">
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
<button type="button" data-trash-action="local-empty-documents"{document_empty_disabled}>清空页面垃圾箱</button>
</div>
{document_body}
</section>
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
<div class="mnote-trash-section-title">
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
<button type="button" data-trash-action="local-empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
</div>
{resource_body}
</section>
</section>
<script>
(function() {{
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
if (!root) return;
var workspaceId = root.getAttribute('data-workspace-id') || '';
var rootUri = root.getAttribute('data-root-uri') || '';
function setStatus(message, failed) {{
var status = root.querySelector('[data-trash-status]');
if (!status) return;
status.textContent = message || '';
status.setAttribute('data-type', failed ? 'error' : 'success');
}}
function readJson(response) {{
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
return payload;
}});
}}
function postJson(url, body) {{
return fetch(url, {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify(body || {{}})
}}).then(readJson);
}}
function refresh() {{
var url = new URL('/trash', window.location.origin);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
return fetch(url.toString(), {{ headers: {{ 'x-mnote-trash-live-refresh': '1' }} }})
.then(function(response) {{ return response.text().then(function(html) {{ return {{ response: response, html: html }}; }}); }})
.then(function(result) {{
if (!result.response.ok) throw new Error('trash_live_refresh_failed_' + result.response.status);
var parsed = new DOMParser().parseFromString(result.html, 'text/html');
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
root.innerHTML = nextRoot.innerHTML;
return true;
}})
.catch(function(error) {{
setStatus(error && error.message ? error.message : String(error), true);
return false;
}});
}}
root.addEventListener('click', function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action');
var entryId = button.getAttribute('data-trash-entry-id') || '';
var documentId = button.getAttribute('data-document-id') || entryId;
var kind = button.getAttribute('data-resource-kind') || '';
if (action === 'local-restore' || action === 'local-purge') {{
if (action === 'local-purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
button.disabled = true;
postJson('/api/tree/commands', {{
action: action === 'local-restore' ? 'restore' : 'purge',
sourceKind: 'local_folder',
rootUri: rootUri,
workspaceId: workspaceId,
documentId: entryId
}}).then(function() {{
return refresh();
}}).then(function() {{
setStatus(action === 'local-restore' ? '已恢复项目' : '已彻底删除项目', false);
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
}}
if (action === 'local-empty-documents' || action === 'local-empty-resources') {{
var selector = action === 'local-empty-documents'
? '[data-trash-row="local"][data-resource-kind="markdown"], [data-trash-row="local"][data-resource-kind="markdown_bundle"]'
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
if (rows.length === 0) return;
if (!window.confirm('清空后无法恢复,确定继续吗?')) return;
button.disabled = true;
Promise.all(rows.map(function(row) {{
var entryId = row.getAttribute('data-trash-entry-id') || '';
return postJson('/api/tree/commands', {{
action: 'purge',
sourceKind: 'local_folder',
rootUri: rootUri,
workspaceId: workspaceId,
documentId: entryId
}});
}})).then(function() {{
return refresh();
}}).then(function() {{
setStatus(action === 'local-empty-documents' ? '已清空页面垃圾箱' : '已清空资源垃圾箱', false);
}}).catch(function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
}}
}});
}})();
</script>"#,
workspace_id = escape_html(workspace_id),
root_uri = escape_html(root_uri),
document_count = document_rows.len(),
resource_count = resource_rows.len(),
document_empty_disabled = if document_rows.is_empty() {
" disabled"
} else {
""
},
resource_empty_disabled = if resource_rows.is_empty() {
" disabled"
} else {
""
},
document_body = document_body,
resource_body = resource_body,
)
}
fn local_trash_command_id(
entry_id: &str,
entry: &crate::routes::local_folder_source::LocalTrashEntry,
) -> String {
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
return entry.document_id.clone();
}
entry_id.to_string()
}
fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] {
dataset
.get(key)
@@ -1334,6 +1647,10 @@ fn build_auth_proxy_response(
COOKIE_CONVEX_AUTH_REFRESH_TOKEN,
tokens.get("refreshToken"),
);
if let Some(actor_id) = resolve_mnote_actor_id(&value, tokens, context) {
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, &actor_id);
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
}
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
}
}
@@ -1342,6 +1659,61 @@ fn build_auth_proxy_response(
response
}
fn resolve_mnote_actor_id(
value: &serde_json::Value,
tokens: &serde_json::Value,
context: &RequestContext,
) -> Option<String> {
[
"/userId",
"/user/id",
"/user/_id",
"/user/subject",
"/profile/userId",
"/profile/id",
]
.iter()
.find_map(|pointer| {
value
.pointer(pointer)
.and_then(Value::as_str)
.and_then(stable_actor_id)
})
.or_else(|| {
tokens
.get("token")
.and_then(Value::as_str)
.and_then(extract_actor_id_from_jwt)
})
.or_else(|| {
let actor_id = stable_actor_id(&context.auth.actor_id)?;
if actor_id == "anonymous" {
None
} else {
Some(actor_id)
}
})
}
fn extract_actor_id_from_jwt(token: &str) -> Option<String> {
let payload_segment = token.split('.').nth(1)?;
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
let payload: Value = serde_json::from_slice(&decoded).ok()?;
["sub", "userId", "id", "_id"].iter().find_map(|key| {
payload
.get(key)
.and_then(Value::as_str)
.and_then(stable_actor_id)
})
}
fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn set_auth_cookie_from_value(
headers: &mut axum::http::HeaderMap,
name: &'static str,
@@ -1360,6 +1732,8 @@ fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
}
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
@@ -1456,9 +1830,20 @@ mod tests {
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use base64::Engine;
use tokio::net::TcpListener;
use tower::util::ServiceExt;
fn temp_root(name: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!("{name}-{stamp}"));
std::fs::create_dir_all(&path).expect("temp root");
path
}
fn app() -> axum::Router {
app_with_legacy_next_base_url("http://127.0.0.1:3100".into())
}
@@ -1526,6 +1911,7 @@ mod tests {
axum::Json(serde_json::json!({
"status": "success",
"value": {
"userId": "user_demo",
"tokens": {
"token": "jwt-demo",
"refreshToken": "refresh-demo"
@@ -1542,6 +1928,43 @@ mod tests {
format!("http://{addr}")
}
async fn spawn_convex_auth_upstream_with_token(token: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("convex auth listener");
let addr = listener.local_addr().expect("convex auth addr");
let app = axum::Router::new().route(
"/api/action",
post(move || {
let token = token.clone();
async move {
axum::Json(serde_json::json!({
"status": "success",
"value": {
"tokens": {
"token": token,
"refreshToken": "refresh-demo"
}
}
}))
}
}),
);
tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("convex auth server");
});
format!("http://{addr}")
}
fn unsigned_jwt_with_subject(subject: &str) -> String {
let payload = serde_json::json!({ "sub": subject });
let encoded_payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
format!("header.{encoded_payload}.signature")
}
async fn spawn_legacy_auth_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -1745,6 +2168,66 @@ mod tests {
assert!(!html.contains("window.location.reload"));
}
#[tokio::test]
async fn trash_entry_renders_local_folder_trash_workbench() {
let root = temp_root("mnote-local-folder-trash-entry");
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&format!("file://{}", root.display()),
)
.expect("init local workspace");
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
std::fs::create_dir_all(root.join(".mnote").join("trash")).expect("create trash dir");
std::fs::write(
root.join(".mnote").join("trash-index.json"),
serde_json::json!({
"entries": {
"local-md:Deleted~2FDeleted.md": {
"documentId": "local-md:Deleted~2FDeleted.md",
"resourceKind": "markdown_bundle",
"resourceScope": "local_folder",
"originalRelativePath": "Deleted",
"trashRelativePath": ".mnote/trash/Deleted",
"deletedAtMs": 1770000000000u64
}
}
})
.to_string(),
)
.expect("write trash index");
let root_uri = format!("file://{}", root.display());
let uri = format!(
"/trash?sourceKind=local_folder&rootUri={}",
root_uri.replace(':', "%3A").replace('/', "%2F")
);
let response = app()
.oneshot(
Request::builder()
.uri(uri)
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-trash-source-kind="local_folder""#));
assert!(html.contains("Deleted"));
assert!(html.contains(r#"data-trash-action="local-restore""#));
assert!(html.contains(r#"data-trash-action="local-purge""#));
assert!(html.contains(r#"data-trash-action="local-empty-documents""#));
assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
let selected = super::choose_root_entry_active_page_id(
@@ -1860,6 +2343,11 @@ mod tests {
#[tokio::test]
async fn root_entry_renders_local_first_landing_without_convex() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let base = temp_root("mnote-root-local-first-landing");
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
.oneshot(
Request::builder()
@@ -1871,15 +2359,19 @@ mod tests {
)
.await
.expect("response");
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(html.contains("初始化的新页面"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
@@ -2060,6 +2552,52 @@ mod tests {
assert!(html.contains(r#"data-mnote-shell="workspace""#));
}
#[tokio::test]
async fn root_entry_initializes_default_local_workspace_page() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let base = temp_root("mnote-root-default-local-workspace");
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
let expected_root = base
.join("users")
.join("user_real")
.join("workspaces")
.join("my-space");
assert!(expected_root
.join("初始化的新页面")
.join("初始化的新页面.md")
.exists());
assert!(html.contains("初始化的新页面"));
assert!(html.contains("local_folder"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -2150,6 +2688,12 @@ mod tests {
assert!(values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_id=user_demo")));
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_type=user")));
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
@@ -2158,6 +2702,42 @@ mod tests {
assert_eq!(payload["tokens"]["refreshToken"], "dummy");
}
#[tokio::test]
async fn auth_api_normalizes_session_subject_before_setting_actor_cookie() {
let token = unsigned_jwt_with_subject("user_stable|session_rotating");
let convex_url = spawn_convex_auth_upstream_with_token(token).await;
let response = app_with_config_and_convex_url(
"http://127.0.0.1:3100".into(),
false,
Some(convex_url),
)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let cookies = response.headers().get_all(header::SET_COOKIE);
let values = cookies
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_id=user_stable")));
assert!(!values
.iter()
.any(|value| value.contains("mnote_actor_id=user_stable|session_rotating")));
}
#[tokio::test]
async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
let legacy_base_url = spawn_legacy_auth_upstream().await;
@@ -1,6 +1,10 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
@@ -22,6 +26,8 @@ pub struct KernelProjectionQuery {
pub depth: Option<u32>,
pub query: Option<String>,
pub max_results: Option<usize>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -65,6 +71,30 @@ async fn project_projection(
Query(query): Query<KernelProjectionQuery>,
projection: KernelProjectionKind,
) -> Result<(StatusCode, Json<Value>), WebError> {
let source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if source_kind == Some("local_folder") {
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let snapshot = if projection == KernelProjectionKind::FileTree {
load_local_folder_file_tree_snapshot(root_uri)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
};
return Ok(ok_response(&context, snapshot.projection));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
@@ -189,6 +219,7 @@ pub async fn graph(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
@@ -296,6 +327,50 @@ mod tests {
}
}
#[tokio::test]
async fn tree_projection_routes_short_circuit_local_folder_without_convex_dataset() {
let root = std::env::temp_dir().join(format!(
"mnote-local-kernel-projection-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("本地页面")).expect("create local page bundle");
std::fs::write(root.join("本地页面").join("本地页面.md"), "")
.expect("write local markdown");
let root_uri = format!("file://{}", root.display());
initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/tree/projections/file?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&rootNodeId=local-md:%E6%9C%AC%E5%9C%B0%E9%A1%B5%E9%9D%A2~2F%E6%9C%AC%E5%9C%B0%E9%A1%B5%E9%9D%A2.md"
))
.header("x-mnote-actor-id", "dev-user")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["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"] == "本地页面.md"));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn tree_projection_routes_keep_ok_response_shape() {
let response = app()
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ use serde_json::{json, Map, Value};
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
pub title: String,
pub mnote_id: Option<String>,
pub body: String,
}
@@ -37,6 +36,14 @@ enum MarkdownBlock {
alignments: Vec<TableAlignment>,
rows: Vec<MarkdownTableRow>,
},
Image {
alt: String,
source_path: String,
},
Mindmap {
name: String,
source_path: String,
},
Media {
name: String,
source_path: String,
@@ -71,18 +78,10 @@ struct MarkdownInlineStyles {
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
let (frontmatter, body) = split_frontmatter(markdown);
let title = frontmatter
.as_deref()
.and_then(|content| read_frontmatter_field(content, "title"))
.or_else(|| extract_first_h1_title(body))
.unwrap_or_else(|| file_stem_title(file_name));
let mnote_id = frontmatter
.as_deref()
.and_then(|content| read_frontmatter_field(content, "mnote_id"));
let (_, body) = split_frontmatter(markdown);
let title = file_stem_title(file_name);
ParsedLocalMarkdownPage {
title,
mnote_id,
body: body.to_string(),
}
}
@@ -97,7 +96,12 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
.unwrap_or(trimmed)
.strip_prefix('[')?;
let (label, rest) = value.split_once("](")?;
let target = rest.strip_suffix(')')?.trim();
let raw_target = rest.strip_suffix(')')?.trim();
let target = raw_target
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_target)
.trim();
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
@@ -179,6 +183,14 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}
fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
if let Some((alt, source_path)) = paragraph_image(node) {
blocks.push(MarkdownBlock::Image { alt, source_path });
return;
}
if let Some((name, source_path)) = paragraph_mindmap(node) {
blocks.push(MarkdownBlock::Mindmap { name, source_path });
return;
}
if let Some((name, source_path)) = paragraph_attachment_media(node) {
blocks.push(MarkdownBlock::Media { name, source_path });
return;
@@ -350,6 +362,28 @@ fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, Stri
link_attachment_media(first)
}
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_mindmap(first)
}
fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
let NodeValue::Image(link) = &first.data.borrow().value else {
return None;
};
let alt = collect_plain_text(first).trim().to_string();
Some((alt, link.url.clone()))
}
fn paragraph_leading_attachment_media<'a>(
node: &'a AstNode<'a>,
) -> Option<(String, String, Vec<MarkdownInline>)> {
@@ -377,6 +411,33 @@ fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)>
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
}
fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
let target = link.url.trim();
let file_name = std::path::Path::new(target)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(target)
.trim();
let lower = file_name.to_ascii_lowercase();
let is_mindmap = lower.ends_with(".mindmap.json")
|| (file_name.starts_with("思维导图") && lower.ends_with(".json"));
if !is_mindmap {
return None;
}
let name = collect_plain_text(node).trim().to_string();
Some((
if name.is_empty() {
"思维导图".to_string()
} else {
name
},
target.to_string(),
))
}
fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value {
Value::Array(
document
@@ -438,6 +499,29 @@ fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value {
MarkdownBlock::Table { alignments, rows } => {
table_block_to_json(alignments, rows, block_number)
}
MarkdownBlock::Image { alt, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "image",
"props": {
"src": source_path,
"alt": alt,
"title": alt,
},
"content": [],
"children": [],
}),
MarkdownBlock::Mindmap { name, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "mindmap",
"props": {
"name": name,
"sourcePath": source_path,
"mindmapId": source_path,
"rootNodeId": "root",
},
"content": [],
"children": [],
}),
MarkdownBlock::Media { name, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "media",
@@ -634,32 +718,6 @@ pub(crate) fn split_frontmatter(markdown: &str) -> (Option<String>, &str) {
(None, normalized)
}
fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option<String> {
frontmatter.lines().find_map(|line| {
let (candidate_key, value) = line.split_once(':')?;
if candidate_key.trim() != key {
return None;
}
let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn extract_first_h1_title(body: &str) -> Option<String> {
body.lines().find_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("# ")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
pub(crate) fn file_stem_title(file_name: &str) -> String {
std::path::Path::new(file_name)
.file_stem()
@@ -669,3 +727,21 @@ pub(crate) fn file_stem_title(file_name: &str) -> String {
.unwrap_or(file_name)
.to_string()
}
#[cfg(test)]
mod tests {
use super::markdown_to_blocks;
#[test]
fn markdown_image_parses_as_image_block() {
let blocks = markdown_to_blocks("![示例图片](assets/photo.jpg)\n");
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("image"));
assert_eq!(first["props"]["src"].as_str(), Some("assets/photo.jpg"));
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
}
@@ -1,4 +1,5 @@
use crate::error::WebError;
use crate::routes::local_folder_source::encode_local_id_segment;
use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
};
@@ -411,11 +412,7 @@ fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocum
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let document_id = parsed
.mnote_id
.as_deref()
.map(|id| format!("local-mdid:{id}"))
.unwrap_or_else(|| format!("local-md:{}", relative_path.replace('/', "~2F")));
let document_id = format!("local-md:{}", encode_local_id_segment(&relative_path));
let metadata = fs::metadata(path).map_err(|error| {
WebError::bad_request_code(
"local_search_index_stat_failed",
@@ -849,6 +846,34 @@ mod tests {
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
let child_search = query_local_search_index(
&root,
&format!("file://{}", root.display()),
"local-ws-test",
"office.xlsx",
None,
10,
false,
false,
)
.expect("child search");
let child_result = child_search["results"]
.as_array()
.unwrap()
.iter()
.find(|item| item["path"].as_str() == Some("docs/child.md"))
.expect("child in search results");
assert_eq!(
child_result["documentId"].as_str(),
Some("local-md:docs~2Fchild.md")
);
assert_ne!(
child_result["documentId"].as_str(),
Some("local-mdid:child-page")
);
assert!(root
.join(".mnote")
.join("index")
@@ -923,7 +948,7 @@ mod tests {
.iter()
.find(|document| document.path == "docs/child.md")
.expect("child document");
assert_eq!(child.title, "Child Updated");
assert_eq!(child.title, "child");
assert!(child.raw_text.contains("ChangedToken"));
fs::remove_file(root.join("docs").join("child.md")).expect("remove child");
+307 -2
View File
@@ -2,9 +2,13 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use crate::routes::local_folder_source::{
ensure_local_workspace_access, ensure_local_workspace_read_access, read_local_mindmap_data,
write_local_mindmap_data,
};
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex, fetch_query_data_via_convex,
resolve_effective_workspace_id,
execute_runtime_query_against_data, execute_runtime_query_via_convex,
fetch_documents_meta_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
@@ -26,6 +30,8 @@ pub struct MindmapQueryParams {
pub query_name: Option<String>,
pub root_node_id: Option<String>,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -38,6 +44,8 @@ pub struct MindmapCommandRequest {
pub workspace_id: Option<String>,
pub data: Option<Value>,
pub create_only: Option<bool>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
}
fn default_mindmap_data() -> Value {
@@ -81,6 +89,25 @@ fn read_workspace_id_from_meta(meta: &Value) -> Option<String> {
.map(ToOwned::to_owned)
}
fn is_local_folder_source(source_kind: Option<&str>) -> bool {
source_kind
.map(str::trim)
.is_some_and(|value| value == "local_folder")
}
fn local_root_uri<'a>(
query_root_uri: Option<&'a str>,
body_root_uri: Option<&'a str>,
) -> Result<&'a str, WebError> {
query_root_uri
.or(body_root_uri)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地思维导图 rootUri")
})
}
async fn resolve_mindmap_workspace_id(
state: &AppState,
context: &RequestContext,
@@ -122,6 +149,29 @@ pub async fn get_mindmap(
)
.with_context(&context));
}
if is_local_folder_source(params.source_kind.as_deref()) {
let root_uri = local_root_uri(params.root_uri.as_deref(), None)?;
ensure_local_workspace_read_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let data = read_local_mindmap_data(root_uri, document_id, mindmap_id)
.map_err(|error| error.with_context(&context))?;
let query_name = resolve_query_name(&params);
let result = execute_runtime_query_against_data(
&context,
params.workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: query_name.into(),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"rootNodeId": params.root_node_id,
"workspaceId": params.workspace_id,
}),
},
data,
)?;
return Ok((StatusCode::OK, response_headers(), Json(result)));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
let query_name = resolve_query_name(&params);
@@ -148,6 +198,7 @@ pub async fn apply_mindmap_command(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((document_id, mindmap_id)): Path<(String, String)>,
Query(params): Query<MindmapQueryParams>,
Json(body): Json<MindmapCommandRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = document_id.trim();
@@ -170,6 +221,101 @@ pub async fn apply_mindmap_command(
)
.with_context(&context));
}
let local_source_kind = body
.source_kind
.as_deref()
.or(params.source_kind.as_deref());
if is_local_folder_source(local_source_kind) {
let root_uri = local_root_uri(params.root_uri.as_deref(), body.root_uri.as_deref())?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
if command_name == Some("mindmap.command.apply") {
let current = read_local_mindmap_data(root_uri, document_id, mindmap_id)
.map_err(|error| error.with_context(&context))?;
let applied = apply_mindmap_kernel_commands_to_value(&current, &body.commands)
.map_err(|error| WebError::bad_request(error.message).with_context(&context))?;
if !applied.errors.is_empty() {
return Err(WebError::bad_request_code(
"mindmap_command_failed",
applied.errors.join("; "),
)
.with_context(&context)
.with_header("x-error-phase", "local_mindmap_command_apply"));
}
let write_result = write_local_mindmap_data(
root_uri,
document_id,
mindmap_id,
applied.data.clone(),
false,
)
.map_err(|error| error.with_context(&context))?;
let projection = execute_runtime_query_against_data(
&context,
body.workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "mindmap.simple_mind_map_scene.get".into(),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"workspaceId": body.workspace_id,
}),
},
applied.data,
)?;
return Ok((
StatusCode::OK,
response_headers(),
Json(json!({
"ok": true,
"sourceKind": "local_folder",
"commandName": "mindmap.command.apply",
"applied": applied.applied,
"errors": applied.errors,
"projectionRevision": body.projection_revision,
"writeResult": write_result,
"kernelRevision": projection.get("kernelRevision").cloned().unwrap_or(json!(1)),
"root": projection.get("root").cloned().unwrap_or(Value::Null),
})),
));
}
let data = body.data.unwrap_or_else(default_mindmap_data);
let write_result = write_local_mindmap_data(
root_uri,
document_id,
mindmap_id,
data.clone(),
body.create_only.unwrap_or(false),
)
.map_err(|error| error.with_context(&context))?;
let projection = execute_runtime_query_against_data(
&context,
body.workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "mindmap.simple_mind_map_scene.get".into(),
payload: json!({
"documentId": document_id,
"mindmapId": mindmap_id,
"workspaceId": body.workspace_id,
}),
},
data,
)?;
return Ok((
StatusCode::OK,
response_headers(),
Json(json!({
"ok": true,
"sourceKind": "local_folder",
"commandName": "mindmaps.put",
"writeResult": write_result,
"kernelRevision": projection.get("kernelRevision").cloned().unwrap_or(json!(1)),
"root": projection.get("root").cloned().unwrap_or(Value::Null),
})),
));
}
let effective_workspace_id =
resolve_mindmap_workspace_id(&state, &context, body.workspace_id.as_deref(), document_id)
.await?;
@@ -323,6 +469,7 @@ mod tests {
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use std::path::PathBuf;
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -354,6 +501,26 @@ mod tests {
}))
}
fn temp_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{}-{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp root");
root
}
fn encode_query_value(value: &str) -> String {
value.replace(':', "%3A").replace('/', "%2F")
}
fn init_local_workspace(root: &PathBuf) -> String {
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"dev-user", &root_uri,
)
.expect("init local workspace");
root_uri
}
#[tokio::test]
async fn mindmap_put_derives_workspace_and_returns_tree_artifacts() {
let response = app()
@@ -450,4 +617,142 @@ mod tests {
})
);
}
#[tokio::test]
async fn local_folder_mindmap_put_writes_file_and_get_reads_back() {
let root = temp_root("mnote-local-mindmap-api-put-get");
let root_uri = init_local_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "").expect("write page");
let encoded_root = encode_query_value(&root_uri);
let encoded_mindmap_id = encode_query_value("思维导图123456.json");
let put_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri(format!(
"/api/mindmap/local-md:Page~2FPage.md/{encoded_mindmap_id}?sourceKind=local_folder&rootUri={encoded_root}"
))
.header("content-type", "application/json")
.header("x-mnote-actor-id", "dev-user")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"data": {
"data": {"text": "本地主题", "uid": "root"},
"children": []
},
"createOnly": false
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(put_response.status(), StatusCode::OK);
let put_body = to_bytes(put_response.into_body(), usize::MAX)
.await
.expect("body");
let put_payload: Value = serde_json::from_slice(&put_body).expect("json");
assert_eq!(put_payload["sourceKind"], "local_folder");
assert_eq!(put_payload["commandName"], "mindmaps.put");
assert_eq!(
put_payload["writeResult"]["relativePath"],
"Page/思维导图123456.json"
);
assert!(
root.join("Page").join("思维导图123456.json").is_file(),
"mindmap json should be persisted next to the page markdown"
);
let get_response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/mindmap/local-md:Page~2FPage.md/{encoded_mindmap_id}?sourceKind=local_folder&rootUri={encoded_root}&view=simple_mind_map_scene"
))
.header("x-mnote-actor-id", "dev-user")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(get_response.status(), StatusCode::OK);
let get_body = to_bytes(get_response.into_body(), usize::MAX)
.await
.expect("body");
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
assert_eq!(get_payload["root"]["data"]["text"], "本地主题");
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_folder_mindmap_command_apply_updates_persisted_file() {
let root = temp_root("mnote-local-mindmap-api-command-apply");
let root_uri = init_local_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "").expect("write page");
std::fs::write(
root.join("Page").join("思维导图123456.json"),
json!({
"data": {"text": "旧主题", "uid": "root"},
"children": []
})
.to_string(),
)
.expect("write mindmap");
let encoded_root = encode_query_value(&root_uri);
let encoded_mindmap_id = encode_query_value("思维导图123456.json");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri(format!(
"/api/mindmap/local-md:Page~2FPage.md/{encoded_mindmap_id}?sourceKind=local_folder&rootUri={encoded_root}"
))
.header("content-type", "application/json")
.header("x-mnote-actor-id", "dev-user")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"commandName": "mindmap.command.apply",
"commands": [
{
"type": "updateText",
"mindmapId": "思维导图123456.json",
"nodeId": "root",
"text": "新主题"
}
],
"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["sourceKind"], "local_folder");
assert_eq!(payload["commandName"], "mindmap.command.apply");
assert_eq!(payload["root"]["data"]["text"], "新主题");
let saved = std::fs::read_to_string(root.join("Page").join("思维导图123456.json"))
.expect("read saved mindmap");
let saved_json: Value = serde_json::from_str(&saved).expect("saved json");
assert_eq!(saved_json["data"]["text"], "新主题");
let _ = std::fs::remove_dir_all(&root);
}
}
@@ -3,27 +3,46 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::web_shell::{
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
load_workspace_shell_projection,
load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html,
};
use crate::ssr::pages::mindmap::MindmapPage;
use crate::workspace_shell::render_workspace_shell_sidebar_html;
use axum::extract::{Extension, Path, State};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use serde_json::json;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapShellQuery {
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub workspace_id: Option<String>,
}
pub async fn mindmap_object_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapShellQuery>,
) -> Result<Response, WebError> {
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let aggregate = build_page_aggregate_snapshot(&state, &context, &doc_id, None, None, None)
.await
.ok();
let source_kind = query.source_kind.as_deref();
let root_uri = query.root_uri.as_deref();
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&doc_id,
query.workspace_id.as_deref(),
source_kind,
root_uri,
)
.await
.ok();
let workspace_id = aggregate
.as_ref()
.map(|value| value.identity.workspace_id.clone())
@@ -33,44 +52,69 @@ pub async fn mindmap_object_shell(
.map(|value| value.head.title.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "思维导图".to_string());
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) =
if let Some(workspace_id) = workspace_id.as_deref() {
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
workspace_id,
Some(&doc_id),
&default_workspace_name,
)
.await;
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
.await
.unwrap_or_default();
let active_filetree_row_id = format!("asset:{mindmap_id}");
let file_tree_html = load_file_tree_html(
state.config(),
&context,
workspace_id,
Some(&doc_id),
Some(active_filetree_row_id.as_str()),
)
.await
.unwrap_or_default();
let workspace_name = workspace_projection.workspace_name.clone();
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
(
Some(workspace_name),
Some(sidebar_tree_html),
Some(workspace_sidebar_html),
)
} else {
(None, None, None)
};
let is_local_folder = source_kind.map(str::trim) == Some("local_folder");
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) = if is_local_folder {
let root_uri = root_uri.unwrap_or_default();
let sidebar_tree_html =
render_local_sidebar_tree_html(root_uri, Some(&doc_id)).unwrap_or_default();
let file_tree_html =
render_local_file_tree_html(root_uri, Some(&doc_id)).unwrap_or_default();
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
workspace_id.as_deref().unwrap_or("local-folder"),
Some(&doc_id),
&default_workspace_name,
)
.await;
let workspace_name = workspace_projection.workspace_name.clone();
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
(
Some(workspace_name),
Some(sidebar_tree_html),
Some(workspace_sidebar_html),
)
} else if let Some(workspace_id) = workspace_id.as_deref() {
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
workspace_id,
Some(&doc_id),
&default_workspace_name,
)
.await;
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
.await
.unwrap_or_default();
let active_filetree_row_id = format!("asset:{mindmap_id}");
let file_tree_html = load_file_tree_html(
state.config(),
&context,
workspace_id,
Some(&doc_id),
Some(active_filetree_row_id.as_str()),
)
.await
.unwrap_or_default();
let workspace_name = workspace_projection.workspace_name.clone();
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
(
Some(workspace_name),
Some(sidebar_tree_html),
Some(workspace_sidebar_html),
)
} else {
(None, None, None)
};
let editor_bootstrap = json!({
"documentId": format!("__mindmap_object__:{doc_id}:{mindmap_id}"),
"workspaceId": workspace_id
@@ -99,6 +143,8 @@ pub async fn mindmap_object_shell(
"documentId": doc_id,
"mindmapId": mindmap_id
},
"sourceKind": source_kind.unwrap_or("convex_workspace"),
"rootUri": root_uri.unwrap_or(""),
"revision": serde_json::Value::Null,
"conflictDetectionKey": serde_json::Value::Null,
"pageOptions": {
@@ -114,6 +160,8 @@ pub async fn mindmap_object_shell(
"shell": "mindmap",
"documentId": doc_id,
"mindmapId": mindmap_id,
"sourceKind": source_kind.unwrap_or("convex_workspace"),
"rootUri": root_uri.unwrap_or(""),
"projection": {
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
"runtime": "simple-mind-map",
@@ -151,7 +199,7 @@ pub async fn mindmap_object_shell(
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
{}
<script id="__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
@@ -162,6 +210,8 @@ pub async fn mindmap_object_shell(
crate::ssr::MNOTE_CSS,
escape_html(&doc_id),
escape_html(&mindmap_id),
escape_html(source_kind.unwrap_or("convex_workspace")),
escape_html(root_uri.unwrap_or("")),
body_content,
escape_script_json(&editor_bootstrap_json),
escape_script_json(&contract_json),
+95 -67
View File
@@ -443,48 +443,8 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
rows
}
fn short_mindmap_file_name(raw: &str, asset_id: Option<&str>) -> String {
let source = asset_id
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(raw.trim());
let digits = source
.chars()
.rev()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>()
.chars()
.rev()
.collect::<String>();
let suffix = if digits.len() >= 4 {
digits[digits.len().saturating_sub(4)..].to_string()
} else {
source
.trim_start_matches("mindmap")
.trim_start_matches(|ch| ch == '-' || ch == '_')
.chars()
.take(6)
.collect::<String>()
};
if suffix.trim().is_empty() {
"思维导图.json".to_string()
} else {
format!("思维导图-{suffix}.json")
}
}
fn normalize_filetree_mindmap_title(
raw_title: &str,
asset_id: Option<&str>,
icon_kind: &str,
resource_kind: Option<&str>,
) -> String {
fn normalize_filetree_mindmap_title(raw_title: &str) -> String {
let title = raw_title.trim();
let is_mindmap = icon_kind == "mindmap" || resource_kind == Some("mindmap");
let generated = title.starts_with("mindmap-") || title.starts_with("mindmap_");
if is_mindmap && (generated || title.chars().count() > 24) {
return short_mindmap_file_name(title, asset_id);
}
if title.is_empty() {
"无标题".to_string()
} else {
@@ -592,14 +552,7 @@ pub(crate) fn collect_filetree_render_rows(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
title: normalize_filetree_mindmap_title(
raw_title,
asset_id.as_deref(),
&icon_kind,
resource_meta
.and_then(|meta| meta.get("resourceKind"))
.and_then(Value::as_str),
),
title: normalize_filetree_mindmap_title(raw_title),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item
.get("expandable")
@@ -7336,8 +7289,10 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("local_folder"));
assert!(html.contains("Frontmatter Title"));
assert!(html.contains("Child H1"));
assert!(html.contains("README"));
assert!(html.contains("child"));
assert!(!html.contains("Frontmatter Title"));
assert!(!html.contains("Child H1"));
assert!(html.contains(">docs<") || html.contains("docs"));
assert!(!html.contains("image.png"));
}
@@ -7380,8 +7335,23 @@ mod tests {
.as_str()
.expect("document id")
.to_string();
assert!(root.join("新页面.md").exists());
assert!(root.join(".mnote").join("page-ids.json").exists());
let created_relative_path = payload["result"]["execution"]["relativePath"]
.as_str()
.expect("created relative path");
let (created_dir, created_file) = created_relative_path
.split_once('/')
.expect("created nested bundle path");
assert!(created_dir.starts_with("新页面"));
assert_eq!(created_file, format!("{created_dir}.md"));
assert!(root.join(created_dir).join(created_file).exists());
assert_eq!(
document_id,
format!(
"local-md:{}",
crate::routes::local_folder_source::encode_local_id_segment(created_relative_path)
)
);
assert!(!root.join(".mnote").join("page-ids.json").exists());
let rename_response = app()
.oneshot(
@@ -7406,8 +7376,18 @@ mod tests {
"{}",
String::from_utf8_lossy(&rename_body)
);
assert!(!root.join("新页面.md").exists());
assert!(root.join("重命名页面.md").exists());
assert!(!root.join(created_dir).exists());
assert!(root.join("重命名页面").join("重命名页面.md").exists());
let rename_payload: Value = serde_json::from_slice(&rename_body).expect("rename json");
let renamed_document_id = rename_payload["result"]["documentId"]
.as_str()
.expect("renamed document id")
.to_string();
assert_eq!(
renamed_document_id,
"local-md:~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md"
);
assert!(!root.join(".mnote").join("page-ids.json").exists());
std::fs::create_dir_all(root.join("docs")).expect("create docs dir");
let move_response = app()
@@ -7417,15 +7397,38 @@ mod tests {
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"move","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","parentId":"local-dir:docs","sortOrder":0}}"#
r#"{{"action":"move","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{renamed_document_id}","parentId":"local-dir:docs","sortOrder":0}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(move_response.status(), StatusCode::OK);
assert!(!root.join("重命名页面.md").exists());
assert!(root.join("docs").join("重命名页面.md").exists());
let move_status = move_response.status();
let move_body = axum::body::to_bytes(move_response.into_body(), usize::MAX)
.await
.expect("move body");
assert_eq!(
move_status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&move_body)
);
assert!(!root.join("重命名页面").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()
.expect("moved document id")
.to_string();
assert_eq!(
moved_document_id,
"local-md:docs~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md"
);
assert!(!root.join(".mnote").join("page-ids.json").exists());
let copy_response = app()
.oneshot(
@@ -7434,7 +7437,7 @@ mod tests {
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"copy","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","parentId":"local-dir:docs"}}"#
r#"{{"action":"copy","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}","parentId":"local-dir:docs"}}"#
)))
.expect("request"),
)
@@ -7455,7 +7458,11 @@ mod tests {
.as_str()
.expect("copied document id")
.to_string();
assert!(root.join("docs").join("重命名页面 2.md").exists());
assert!(root
.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists());
let folder_response = app()
.oneshot(
@@ -7480,20 +7487,22 @@ mod tests {
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}"}}"#
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(delete_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("重命名页面.md").exists());
assert!(!root.join("docs").join("重命名页面").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());
let restore_response = app()
.oneshot(
@@ -7502,14 +7511,33 @@ mod tests {
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}"}}"#
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(restore_response.status(), StatusCode::OK);
assert!(root.join("docs").join("重命名页面.md").exists());
let restore_status = restore_response.status();
let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX)
.await
.expect("restore body");
assert_eq!(
restore_status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&restore_body)
);
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(),
Some(moved_document_id.as_str())
);
assert!(!root.join(".mnote").join("page-ids.json").exists());
let purge_response = app()
.oneshot(
@@ -7525,7 +7553,7 @@ mod tests {
.await
.expect("response");
assert_eq!(purge_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("重命名页面 2.md").exists());
assert!(!root.join("docs").join("重命名页面 2").exists());
let _ = std::fs::remove_dir_all(&root);
}
+182 -25
View File
@@ -213,7 +213,7 @@ pub async fn document_page_shell(
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
@@ -228,6 +228,8 @@ pub async fn document_page_shell(
escape_html(title),
crate::ssr::MNOTE_CSS,
escape_html(&document_id),
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
escape_html(primary_root_uri.unwrap_or("")),
secondary_requested,
secondary_invalid,
body_content,
@@ -557,11 +559,24 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
}
writeLastSavedTitle(title);
updateVisibleTitle(input, title, currentTarget.documentId);
const result = payload?.result || {};
const nextDocumentId = String(result.documentId || result.id || currentTarget.documentId || '').trim();
const previousDocumentId = currentTarget.documentId;
const nextTitle = String(result.title || title || '').trim() || '';
if (nextDocumentId) {
input.setAttribute('data-document-id', nextDocumentId);
}
writeLastSavedTitle(nextTitle);
updateVisibleTitle(input, nextTitle, nextDocumentId || currentTarget.documentId);
setStatus(input, 'saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', {
detail: { documentId: currentTarget.documentId, workspaceId: currentTarget.workspaceId || null, title, payload },
detail: {
documentId: nextDocumentId || currentTarget.documentId,
previousDocumentId,
workspaceId: currentTarget.workspaceId || null,
title: nextTitle,
payload,
},
}));
} catch (error) {
setStatus(input, 'error', error instanceof Error ? error.message : String(error));
@@ -896,6 +911,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const mindmapId = firstNonEmptyText(
block?.props?.mindmapId,
block?.props?.mindmap_id,
block?.props?.sourcePath,
block?.props?.source_path,
block?.mindmapId,
block?.mindmap_id,
data?.mindmapId,
@@ -1023,10 +1040,84 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return textToTiptapDocument(fallbackText);
};
const pageBodyTiptapDocument = (body, fallbackText = '') => {
const decodeLocalIdSegment = (segment) => {
const encoded = String(segment || '').replace(/~([0-9a-fA-F]{2})/g, '%$1');
try {
return decodeURIComponent(encoded);
} catch (_) {
return String(segment || '').replace(/~2F/g, '/').replace(/~20/g, ' ');
}
};
const localMarkdownRelativePathFromDocumentId = (documentId) => {
const raw = String(documentId || '').trim();
const segment = raw.startsWith('local-md:') ? raw.slice('local-md:'.length) : raw;
return decodeLocalIdSegment(segment).replace(/^\/+/, '');
};
const localMarkdownDirectoryFromDocumentId = (documentId) => {
const relativePath = localMarkdownRelativePathFromDocumentId(documentId);
const slash = relativePath.lastIndexOf('/');
return slash >= 0 ? relativePath.slice(0, slash) : '';
};
const isExternalOrSpecialUrl = (value) => {
const text = String(value || '').trim();
return !text
|| text.startsWith('#')
|| text.startsWith('data:')
|| text.startsWith('blob:')
|| text.startsWith('mailto:')
|| text.startsWith('http://')
|| text.startsWith('https://')
|| text.startsWith('/api/');
};
const normalizeLocalAssetRelativePath = (value, context) => {
const text = String(value || '').trim();
if (!text || isExternalOrSpecialUrl(text)) return text;
if (text.startsWith('/')) return text.replace(/^\/+/, '');
const baseDir = localMarkdownDirectoryFromDocumentId(context?.documentId);
return (baseDir ? `${baseDir}/${text}` : text)
.split('/')
.filter((part) => part && part !== '.')
.join('/');
};
const localFileOpenUrlForTiptap = (value, context) => {
if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value;
const relativePath = normalizeLocalAssetRelativePath(value, context);
if (!relativePath || isExternalOrSpecialUrl(relativePath)) return value;
const url = new URL('/api/local-folder/files/open', window.location.origin);
url.searchParams.set('rootUri', context.rootUri);
url.searchParams.set('path', relativePath);
return url.toString();
};
const localizeTiptapAssetUrls = (node, context) => {
if (!node || typeof node !== 'object') return node;
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) };
}
if (Array.isArray(node.marks)) {
node.marks = node.marks.map((mark) => {
if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark;
return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } };
});
}
if (Array.isArray(node.content)) {
node.content = node.content.map((child) => localizeTiptapAssetUrls(child, context));
}
return node;
};
const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context);
}
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
if (blockDocument) return toTiptapDocument(blockDocument, fallbackText);
return toTiptapDocument(body?.content, fallbackText);
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context);
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context);
};
const inlineTextNodes = (node) => {
@@ -1089,6 +1180,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const mindmapId = firstNonEmptyText(
attrs?.mindmapId,
attrs?.mindmap_id,
attrs?.sourcePath,
attrs?.source_path,
data?.mindmapId,
data?.mindmap_id,
data?.id,
@@ -1106,10 +1199,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const tiptapNodeToEditorBlock = (node, index) => {
const blockId = blockIdOf(node, index);
if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') {
const mindmapId = firstNonEmptyText(
node?.attrs?.mindmapId,
node?.attrs?.mindmap_id,
node?.attrs?.sourcePath,
node?.attrs?.source_path,
blockId
);
return {
blockId,
blockType: 'mindmap',
props: mindmapPropsFromAttrs(node?.attrs, blockId),
props: {
...mindmapPropsFromAttrs(node?.attrs, blockId),
sourcePath: mindmapId,
},
contentNodes: [],
childBlockIds: [],
};
@@ -1577,6 +1680,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
};
const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => {
if (!session || session.sourceKind !== 'local_folder') return false;
if (!sessionHasRecentExternalSignal(session)) return false;
if (!sessionPlainText(session)) return false;
return !aggregatePlainText(nextAggregate);
};
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const conflictEnvelopeFromResponse = (payload) => (
payload?.error?.details?.conflict
|| payload?.details?.conflict
@@ -1610,7 +1722,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
@@ -1937,6 +2049,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
const scheduleSessionExternalRefresh = (session, source) => {
if (!session || session.views.size === 0) return;
if (session.externalRefreshTimer) return;
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
session.externalRefreshTimer = window.setTimeout(() => {
@@ -1949,6 +2062,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const refreshSessionFromExternalChange = async (session, source) => {
if (document.hidden) return;
if (!session || session.views.size === 0) return;
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
try {
const response = await fetch(pageAggregateUrl({
@@ -1973,11 +2087,26 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
const nextAggregate = payload?.result;
let nextAggregate = payload?.result;
if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) {
await delay(500);
try {
const retryAggregate = await fetchLatestSessionAggregate(session);
if (aggregatePlainText(retryAggregate)) {
nextAggregate = retryAggregate;
} else {
markSessionExternalConflict(session, '');
return;
}
} catch (_retryError) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
}
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
@@ -2036,11 +2165,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const payload = parseLocalFolderEventPayload(event);
if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
const eventKind = String(payload.eventKind || '');
const mayAffectMissingDocument = eventKind.includes('Remove') || eventKind.includes('Name');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument && !mayAffectMissingDocument) return;
if (targetsCurrentDocument && targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
@@ -2256,8 +2391,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const tiptapDocument = pageBodyTiptapDocument(pageBody);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
@@ -2282,6 +2417,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
externalChangePending: false,
externalRefreshSource: '',
lastExternalChangeSignalAt: 0,
lastSelfSaveSignalAt: 0,
lastUserInputAt: 0,
status: 'booting',
error: null,
@@ -3498,10 +3634,16 @@ mod tests {
assert!(html.contains("/api/tree/events"));
assert!(html.contains("data-mnote-tree-live-transport"));
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
assert!(html.contains("const pageBodyTiptapDocument = (body, fallbackText = '') => {"));
assert!(html.contains(
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
));
assert!(html.contains("body?.blockDocument || body?.block_document"));
assert!(html.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody);"));
assert!(html.contains("const tiptapDocument = pageBodyTiptapDocument(pageBody);"));
assert!(html.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content"));
assert!(html
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
assert!(html.contains(
"const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
));
assert!(!html.contains("mnote-web-document-shell"));
}
@@ -3655,10 +3797,10 @@ mod tests {
let root =
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
std::fs::write(
root.join("README.md"),
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
root.join("Local Aggregate").join("Local Aggregate.md"),
"# Local Heading\n正文内容\n",
)
.expect("write local md");
@@ -3668,7 +3810,7 @@ mod tests {
.oneshot(
Request::builder()
.uri(format!(
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
"/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
@@ -3695,7 +3837,7 @@ mod tests {
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(
payload["result"]["identity"]["documentId"],
"local-md:README.md"
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
);
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
@@ -3710,10 +3852,19 @@ mod tests {
let root =
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
.expect("write child md");
std::fs::create_dir_all(root.join("Local Shell")).expect("create local page bundle");
std::fs::create_dir_all(root.join("docs").join("Child Page"))
.expect("create local child bundle");
std::fs::write(
root.join("Local Shell").join("Local Shell.md"),
"# Local Shell\n正文\n",
)
.expect("write root md");
std::fs::write(
root.join("docs").join("Child Page").join("Child Page.md"),
"# Child Page\n",
)
.expect("write child md");
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
@@ -3722,7 +3873,7 @@ mod tests {
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
"/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
@@ -3771,6 +3922,10 @@ mod tests {
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
assert!(html.contains("mayAffectMissingDocument"));
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
assert!(html.contains("targetSession.views.size === 0"));
assert!(html.contains("session.views.size === 0"));
assert!(html.contains("targetSession.saving"));
assert!(html.contains("lastSelfSaveSignalAt"));
assert!(html.contains("command: 'replaceContent'"));
assert!(html.contains("external-change-conflict"));
assert!(html.contains("mnote-editor-conflict-panel"));
@@ -3875,6 +4030,8 @@ mod tests {
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("Spec"));
assert!(html.contains("assets/spec.pdf"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
}
#[tokio::test]