fix: handle local mindmap trash and block insertion

This commit is contained in:
lix-2026
2026-05-20 11:19:42 +08:00
parent b4c8bcb647
commit 3feeaab3cb
9 changed files with 250 additions and 87 deletions
@@ -4309,8 +4309,9 @@ fn resolve_local_directory_id(root: &Path, entry_id: &str) -> Result<Option<Path
fn resolve_local_raw_file_id(root: &Path, entry_id: &str) -> Result<Option<PathBuf>, WebError> {
let trimmed = entry_id.trim();
let Some(relative_path) = trimmed
.strip_prefix("local:node:")
.strip_prefix("local-file:")
.or_else(|| trimmed.strip_prefix("local:asset:"))
.or_else(|| trimmed.strip_prefix("local:node:"))
else {
return Ok(None);
};
@@ -7675,6 +7676,37 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_delete_local_file_id_uses_trash_index() {
let root = temp_root("mnote-local-delete-local-file-id");
init_workspace(&root);
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
let result = execute_local_tree_command(
&root_uri,
"delete",
"local-file:docs/photo.png",
None,
None,
)
.expect("delete local-file asset");
assert_eq!(result["resourceKind"].as_str(), Some("local_file"));
assert_eq!(
result["trashEntryId"].as_str(),
Some("local-file:docs/photo.png")
);
assert!(!root.join("docs").join("photo.png").exists());
assert!(root.join(".mnote").join("trash").join("photo.png").exists());
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index");
assert!(trash_index.contains("local-file:docs/photo.png"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_refreshes_search_index_after_write() {
let root = temp_root("mnote-local-save-refresh-search-index");
@@ -4,11 +4,14 @@ use crate::error::WebError;
use crate::routes::command_support::{
execute_runtime_command_via_convex_with_artifacts, runtime_context,
};
use crate::routes::local_folder_source::{
ensure_local_workspace_access, execute_local_tree_command,
};
use crate::transport::convex::{
execute_convex_mutation_by_name, execute_convex_query_by_name,
persist_runtime_command_artifacts,
};
use axum::extract::{Extension, Path, State};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
@@ -51,6 +54,13 @@ pub struct MindmapTrashRequest {
pub action: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapLocalQuery {
pub source_kind: Option<String>,
pub root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableTrashRequest {
@@ -761,10 +771,30 @@ pub async fn mindmap_delete(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapLocalQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
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")?;
if query.source_kind.as_deref() == 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")
.with_context(&context)
})?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let execution = execute_local_tree_command(root_uri, "delete", &mindmap_id, None, None)
.map_err(|error| error.with_context(&context))?;
return Ok(ok_response(
&context,
annotate_resource_lifecycle_result(execution, "tree.resource.archive", "mindmap"),
));
}
let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await;
let command = resource_command(
&context,
@@ -1199,6 +1229,64 @@ mod tests {
serde_json::from_slice(&body).expect("json")
}
#[tokio::test]
async fn local_folder_mindmap_delete_moves_resource_file_to_trash() {
let root =
std::env::temp_dir().join(format!("mnote-local-mindmap-trash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write md");
std::fs::write(
root.join("Page").join("map.mindmap.json"),
r#"{"data":{"uid":"root","text":"KMIND"},"children":[]}"#,
)
.expect("write mindmap");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init workspace");
let response = 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("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
assert_eq!(
payload["result"]["canonicalCommand"],
"tree.resource.archive"
);
assert_eq!(payload["result"]["sourceKind"], "local_folder");
assert!(!root.join("Page").join("map.mindmap.json").exists());
assert!(root
.join(".mnote")
.join("trash")
.join("map.mindmap.json")
.exists());
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index");
assert!(trash_index.contains("local-file:Page/map.mindmap.json"));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn media_trash_routes_delete_restore_purge_and_empty() {
let deleted = post_json(
+1 -1
View File
@@ -7567,7 +7567,7 @@ mod tests {
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let asset_id = "local:asset:docs/photo.png";
let asset_id = "local-file:docs/photo.png";
let delete_response = app()
.oneshot(
+13 -8
View File
@@ -2648,15 +2648,20 @@ const SIDEBAR_TREE_JS: &str = r##"
function appendUploadedAssetRow(asset, documentId) {
var assetId = String(asset && asset.id || '').trim();
if (!assetId) return;
if (revealFileTreeAssetRow(assetId)) return;
if (!assetId) return false;
if (revealFileTreeAssetRow(assetId)) return true;
var objectKind = String(asset && (asset.objectKind || asset.resourceKind || '') || '').trim();
if (!objectKind && (String(asset && (asset.asset_type || asset.assetType) || '').trim() === 'mindmap' || /\.mindmap\.json$/i.test(assetId))) {
objectKind = 'mindmap';
}
var targetDocumentId = String(documentId || asset.document_id || asset.documentId || currentDocumentId() || '').trim();
var parentRow = targetDocumentId
? document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + targetDocumentId) + '"]')
: null;
if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false;
if (!parentRow) parentRow = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-kind="document"]');
var root = document.querySelector('#sidebar-file-tree-root .tree-root');
if (!root && !parentRow) return;
if (!root && !parentRow) return false;
var parentLi = parentRow ? parentRow.closest('.tree-node') : null;
var children = parentLi ? parentLi.querySelector(':scope > .tree-children') : null;
if (parentLi && !children) {
@@ -2675,10 +2680,6 @@ const SIDEBAR_TREE_JS: &str = r##"
var container = children || root;
var li = document.createElement('li');
li.className = 'tree-node';
var objectKind = String(asset && (asset.objectKind || asset.resourceKind || '') || '').trim();
if (!objectKind && (String(asset && (asset.asset_type || asset.assetType) || '').trim() === 'mindmap' || /\.mindmap\.json$/i.test(assetId))) {
objectKind = 'mindmap';
}
var objectIdentity = {
objectKind: objectKind || 'attachment',
documentId: targetDocumentId || null,
@@ -2697,6 +2698,7 @@ const SIDEBAR_TREE_JS: &str = r##"
container.appendChild(li);
revealFileTreeRow(li.querySelector('.tree-row'));
document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId);
return true;
}
function removeFileTreeAssetRow(assetId) {
@@ -2826,7 +2828,8 @@ const SIDEBAR_TREE_JS: &str = r##"
if (!target || !target.documentId || !target.mindmapId) return;
var asset = mindmapAssetFromTarget(target.documentId, target.mindmapId, payload && payload.writeResult);
if (!asset) return;
appendUploadedAssetRow(asset, target.documentId);
var appended = appendUploadedAssetRow(asset, target.documentId);
if (!appended && currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
document.documentElement.setAttribute('data-mnote-assets-local-applied', 'true');
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', asset.id || target.mindmapId);
}
@@ -8914,6 +8917,8 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("withLocalMindmapSourceParams"));
assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('sourceKind', 'local_folder')"));
assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('rootUri', rootUri)"));
assert!(SIDEBAR_TREE_JS.contains("if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false;"));
assert!(SIDEBAR_TREE_JS.contains("if (!appended && currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();"));
assert!(SIDEBAR_TREE_JS
.contains("data-mnote-last-mindmap-asset-open-mode', 'local-mindmap-object-shell'"));
assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl"));