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::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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user