diff --git a/rust/crates/core-domain/src/entities.rs b/rust/crates/core-domain/src/entities.rs index f0b960af..d785d002 100644 --- a/rust/crates/core-domain/src/entities.rs +++ b/rust/crates/core-domain/src/entities.rs @@ -1,3 +1,9 @@ +//! 早期通用领域实体。 +//! +//! 当前运行时主链以 `core-protocol` 的 kernel projection、Page Aggregate 和 +//! editor block 协议为准。本文件中的 Page/Block/Asset 等传统实体仅作为 +//! legacy domain model / 测试与历史 adapter 边界保留,不代表 local-first 正文真源。 + use crate::audit::{ActorRef, AuditInfo, ChangeReason, RefLink, SourceKind}; use crate::ids::{ AgentSessionId, AssetId, AssetVersionId, BlockId, CommandLogId, EventId, PageId, ReferenceId, diff --git a/rust/crates/mnote-cli/README.md b/rust/crates/mnote-cli/README.md index 28e8ca2d..5c442b07 100644 --- a/rust/crates/mnote-cli/README.md +++ b/rust/crates/mnote-cli/README.md @@ -2,6 +2,11 @@ 当前 crate 是 Phase 2 的最小 CLI 协议入口。 +注意:当前 `page` / `block` / `tool run doc_*` 执行面仍是 Convex +`documents.*` 兼容路径,只适合调试、迁移验证和 cloud workspace 兼容场景。 +local-first 普通 Markdown 正文主链应优先走本地文件引用、Page Aggregate、 +Rust Web local_folder source 和 agent 原生 patch/diff,不应把本 CLI 视为正文真源入口。 + 当前目标不是直接替代全部执行链,而是先冻结这五类命令面的命名和 `--json` 输出协议: - `page` diff --git a/rust/crates/mnote-web/src/routes/local_search_index.rs b/rust/crates/mnote-web/src/routes/local_search_index.rs index 9f4c1611..09e8a63f 100644 --- a/rust/crates/mnote-web/src/routes/local_search_index.rs +++ b/rust/crates/mnote-web/src/routes/local_search_index.rs @@ -566,7 +566,11 @@ fn local_search_document_projection( "backlinks": document.backlinks, "resourceRefs": document.resource_refs, "updatedAt": document.updated_at, - "publicPath": format!("/documents/{}?sourceKind=local_folder&rootUri={}", document.document_id, root_uri) + "publicPath": format!( + "/documents/{}?sourceKind=local_folder&rootUri={}", + document.document_id, + encode_query_component(root_uri), + ) }) } @@ -580,10 +584,25 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s "sourceKind": "local_folder", "rootUri": root_uri, "updatedAt": resource.updated_at, - "publicPath": format!("/tree?sourceKind=local_folder&rootUri={}", root_uri) + "publicPath": format!( + "/?treeView=filetree&sourceKind=local_folder&rootUri={}", + encode_query_component(root_uri), + ) }) } +fn encode_query_component(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.as_bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(*byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value { let mut sorted = documents.iter().collect::>(); sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); @@ -846,6 +865,11 @@ mod tests { .unwrap() .iter() .any(|reference| reference.as_str() == Some("office/report.xlsx"))); + assert!(home["publicPath"] + .as_str() + .is_some_and(|path| path.starts_with( + "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F" + ))); // 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid: let child_search = query_local_search_index( @@ -895,7 +919,14 @@ mod tests { .unwrap() .iter() .any(|item| item["resourceType"].as_str() == Some("mindmap") - && item["path"].as_str() == Some("maps/idea.mindmap.json"))); + && item["path"].as_str() == Some("maps/idea.mindmap.json") + && item["publicPath"] + .as_str() + .is_some_and(|path| path + .starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri=")) + && item["publicPath"] + .as_str() + .is_some_and(|path| !path.starts_with("/tree?")))); let office_projection = query_local_search_index( &root, &format!("file://{}", root.display()), diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs index a7525fdb..868cd8b4 100644 --- a/rust/crates/mnote-web/src/routes/resource_trash.rs +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -52,6 +52,8 @@ pub struct WorkspaceTrashRequest { #[serde(rename_all = "camelCase")] pub struct MindmapTrashRequest { pub action: String, + pub source_kind: Option, + pub root_uri: Option, } #[derive(Debug, Deserialize)] @@ -827,6 +829,7 @@ pub async fn mindmap_trash_action( State(state): State, Extension(context): Extension, Path((doc_id, mindmap_id)): Path<(String, String)>, + Query(query): Query, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let action = body.action.trim(); @@ -845,6 +848,36 @@ pub async fn mindmap_trash_action( let user_id = current_user_id(&state, &context).await; let doc_id = require_id(&context, &doc_id, "docId")?; let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; + let local_source_kind = body + .source_kind + .as_deref() + .or(query.source_kind.as_deref()) + .map(str::trim); + if local_source_kind == Some("local_folder") { + let root_uri = query + .root_uri + .as_deref() + .or(body.root_uri.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") + .with_context(&context) + })?; + ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let local_action = if action == "restore" { + "restore" + } else { + "purge" + }; + let execution = execute_local_tree_command(root_uri, local_action, mindmap_id, None, None) + .map_err(|error| error.with_context(&context))?; + return Ok(ok_response( + &context, + annotate_resource_lifecycle_result(execution, command_name, "mindmap"), + )); + } let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await; let command = resource_command( &context, @@ -1284,6 +1317,103 @@ mod tests { .expect("trash index"); assert!(trash_index.contains("local-file:Page/map.mindmap.json")); + let restored = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "action": "restore", + "sourceKind": "local_folder", + "rootUri": root_uri, + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("restore response"); + let restore_status = restored.status(); + let restore_body = to_bytes(restored.into_body(), usize::MAX) + .await + .expect("restore body"); + let restored_payload: Value = serde_json::from_slice(&restore_body).expect("json"); + assert_eq!( + restore_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&restore_body) + ); + assert_eq!(restored_payload["result"]["sourceKind"], "local_folder"); + assert_eq!( + restored_payload["result"]["canonicalCommand"], + "tree.resource.restore" + ); + assert!(root.join("Page").join("map.mindmap.json").exists()); + + let delete_again = app() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json?sourceKind=local_folder&rootUri={root_uri}" + )) + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("delete again response"); + assert_eq!(delete_again.status(), StatusCode::OK); + let _ = to_bytes(delete_again.into_body(), usize::MAX).await; + + let purged = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "action": "purge", + "sourceKind": "local_folder", + "rootUri": root_uri, + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("purge response"); + let purge_status = purged.status(); + let purge_body = to_bytes(purged.into_body(), usize::MAX) + .await + .expect("purge body"); + let purged_payload: Value = serde_json::from_slice(&purge_body).expect("json"); + assert_eq!( + purge_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&purge_body) + ); + assert_eq!(purged_payload["result"]["sourceKind"], "local_folder"); + assert_eq!( + purged_payload["result"]["canonicalCommand"], + "tree.resource.purge" + ); + assert!(!root + .join(".mnote") + .join("trash") + .join("map.mindmap.json") + .exists()); + let _ = std::fs::remove_dir_all(&root); } diff --git a/rust/crates/storage-convex-bridge/src/lib.rs b/rust/crates/storage-convex-bridge/src/lib.rs index a3c75355..8e252034 100644 --- a/rust/crates/storage-convex-bridge/src/lib.rs +++ b/rust/crates/storage-convex-bridge/src/lib.rs @@ -1,3 +1,9 @@ +//! Convex workspace / control-plane / compat transport bridge. +//! +//! local-first 主链下,本地 Markdown 正文、附件和资源文件不以这里作为真源。 +//! 这个 crate 只保留用于 Convex workspace、历史 documents.* 兼容命令、 +//! control-plane 查询和迁移期执行计划映射。 + pub mod context; pub mod mapping; pub mod read_path; diff --git a/scripts/task179-tree-create-delete-no-reload-smoke.js b/scripts/task179-tree-create-delete-no-reload-smoke.js index 2d4fb920..8f767d06 100644 --- a/scripts/task179-tree-create-delete-no-reload-smoke.js +++ b/scripts/task179-tree-create-delete-no-reload-smoke.js @@ -18,9 +18,20 @@ const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); if (process.env.MNOTE_ALLOW_DEBUG_TREE_SMOKE !== "1") { - throw new Error( - "task179 只覆盖已退为显式 debug/internal 的 /tree 壳。当前主链请使用 document/sidebar smoke;如确需复核 debug 壳,请设置 MNOTE_ALLOW_DEBUG_TREE_SMOKE=1。", + console.log( + JSON.stringify( + { + ok: true, + skipped: true, + task: TASK, + reason: + "task179 只覆盖已退为显式 debug/internal 的 /tree 壳;默认跳过,避免干扰 local-first 主链回归。设置 MNOTE_ALLOW_DEBUG_TREE_SMOKE=1 后可显式复核。", + }, + null, + 2, + ), ); + process.exit(0); } async function writeResult(payload) {