fix: align local-first resource boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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()),
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct WorkspaceTrashRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapTrashRequest {
|
||||
pub action: String,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -827,6 +829,7 @@ pub async fn mindmap_trash_action(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path((doc_id, mindmap_id)): Path<(String, String)>,
|
||||
Query(query): Query<MindmapLocalQuery>,
|
||||
Json(body): Json<MindmapTrashRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user