fix: align local-first resource boundaries

This commit is contained in:
lix-2026
2026-05-20 11:57:23 +08:00
parent e246006c81
commit dd5578c28e
6 changed files with 194 additions and 5 deletions
+6
View File
@@ -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::audit::{ActorRef, AuditInfo, ChangeReason, RefLink, SourceKind};
use crate::ids::{ use crate::ids::{
AgentSessionId, AssetId, AssetVersionId, BlockId, CommandLogId, EventId, PageId, ReferenceId, AgentSessionId, AssetId, AssetVersionId, BlockId, CommandLogId, EventId, PageId, ReferenceId,
+5
View File
@@ -2,6 +2,11 @@
当前 crate 是 Phase 2 的最小 CLI 协议入口。 当前 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` 输出协议: 当前目标不是直接替代全部执行链,而是先冻结这五类命令面的命名和 `--json` 输出协议:
- `page` - `page`
@@ -566,7 +566,11 @@ fn local_search_document_projection(
"backlinks": document.backlinks, "backlinks": document.backlinks,
"resourceRefs": document.resource_refs, "resourceRefs": document.resource_refs,
"updatedAt": document.updated_at, "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", "sourceKind": "local_folder",
"rootUri": root_uri, "rootUri": root_uri,
"updatedAt": resource.updated_at, "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 { fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value {
let mut sorted = documents.iter().collect::<Vec<_>>(); let mut sorted = documents.iter().collect::<Vec<_>>();
sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
@@ -846,6 +865,11 @@ mod tests {
.unwrap() .unwrap()
.iter() .iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx"))); .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: // 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
let child_search = query_local_search_index( let child_search = query_local_search_index(
@@ -895,7 +919,14 @@ mod tests {
.unwrap() .unwrap()
.iter() .iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap") .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( let office_projection = query_local_search_index(
&root, &root,
&format!("file://{}", root.display()), &format!("file://{}", root.display()),
@@ -52,6 +52,8 @@ pub struct WorkspaceTrashRequest {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MindmapTrashRequest { pub struct MindmapTrashRequest {
pub action: String, pub action: String,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -827,6 +829,7 @@ pub async fn mindmap_trash_action(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>, Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapLocalQuery>,
Json(body): Json<MindmapTrashRequest>, Json(body): Json<MindmapTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let action = body.action.trim(); 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 user_id = current_user_id(&state, &context).await;
let doc_id = require_id(&context, &doc_id, "docId")?; let doc_id = require_id(&context, &doc_id, "docId")?;
let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; 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 workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await;
let command = resource_command( let command = resource_command(
&context, &context,
@@ -1284,6 +1317,103 @@ mod tests {
.expect("trash index"); .expect("trash index");
assert!(trash_index.contains("local-file:Page/map.mindmap.json")); 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); let _ = std::fs::remove_dir_all(&root);
} }
@@ -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 context;
pub mod mapping; pub mod mapping;
pub mod read_path; pub mod read_path;
@@ -18,9 +18,20 @@ const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
if (process.env.MNOTE_ALLOW_DEBUG_TREE_SMOKE !== "1") { if (process.env.MNOTE_ALLOW_DEBUG_TREE_SMOKE !== "1") {
throw new Error( console.log(
"task179 只覆盖已退为显式 debug/internal 的 /tree 壳。当前主链请使用 document/sidebar smoke;如确需复核 debug 壳,请设置 MNOTE_ALLOW_DEBUG_TREE_SMOKE=1。", 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) { async function writeResult(payload) {