feat(tree): checkpoint resource lifecycle work
提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。 不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
@@ -38,10 +38,28 @@ struct LocalFolderMetadata {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalTrashEntry {
|
||||
#[serde(default, alias = "documentId")]
|
||||
document_id: String,
|
||||
#[serde(default, alias = "resourceKind")]
|
||||
resource_kind: String,
|
||||
#[serde(default, alias = "resourceScope")]
|
||||
resource_scope: String,
|
||||
#[serde(default, alias = "originalFilePath")]
|
||||
original_file_path: String,
|
||||
#[serde(default, alias = "trashedFilePath")]
|
||||
trashed_file_path: String,
|
||||
#[serde(default, alias = "trashEntryId")]
|
||||
trash_entry_id: String,
|
||||
#[serde(default, alias = "originalRelativePath")]
|
||||
original_relative_path: String,
|
||||
#[serde(default, alias = "trashRelativePath")]
|
||||
trash_relative_path: String,
|
||||
#[serde(default, alias = "deletedAtMs")]
|
||||
deleted_at_ms: u128,
|
||||
#[serde(default, alias = "archivedAt")]
|
||||
archived_at: u128,
|
||||
#[serde(default, alias = "purgedAt")]
|
||||
purged_at: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -514,9 +532,9 @@ pub fn execute_local_tree_command(
|
||||
title.unwrap_or("外部文件"),
|
||||
),
|
||||
"move" => move_local_entry(&canonical_root, document_id, parent_id),
|
||||
"delete" | "trash" => trash_local_markdown_page(&canonical_root, document_id),
|
||||
"restore" => restore_local_markdown_page(&canonical_root, document_id),
|
||||
"purge" => purge_local_markdown_page(&canonical_root, document_id),
|
||||
"delete" | "trash" => trash_local_entry(&canonical_root, document_id),
|
||||
"restore" => restore_local_entry(&canonical_root, document_id),
|
||||
"purge" => purge_local_entry(&canonical_root, document_id),
|
||||
other => Err(WebError::bad_request_code(
|
||||
"local_tree_command_unsupported",
|
||||
format!("local_folder 暂不支持 tree action: {other}"),
|
||||
@@ -917,6 +935,35 @@ fn move_local_directory(
|
||||
}))
|
||||
}
|
||||
|
||||
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
if let Some(file) = resolve_local_raw_file_id(root, entry_id)? {
|
||||
return trash_local_raw_file(root, entry_id, &file);
|
||||
}
|
||||
trash_local_markdown_page(root, entry_id)
|
||||
}
|
||||
|
||||
fn restore_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
|
||||
return restore_local_raw_file(root, entry_id);
|
||||
}
|
||||
restore_local_markdown_page(root, entry_id)
|
||||
}
|
||||
|
||||
fn purge_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
|
||||
return purge_local_raw_file(root, entry_id);
|
||||
}
|
||||
if entry_id.starts_with("local:asset:") || entry_id.starts_with("local:node:") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"本地资源必须先进入回收站后才能永久删除",
|
||||
));
|
||||
}
|
||||
purge_local_markdown_page(root, entry_id)
|
||||
}
|
||||
|
||||
fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let markdown_file =
|
||||
@@ -954,9 +1001,16 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
document_id.to_string(),
|
||||
LocalTrashEntry {
|
||||
document_id: document_id.to_string(),
|
||||
resource_kind: "markdown".to_string(),
|
||||
resource_scope: "local_folder".to_string(),
|
||||
original_file_path: markdown_file.relative_path.clone(),
|
||||
trashed_file_path: trash_relative_path.clone(),
|
||||
trash_entry_id: document_id.to_string(),
|
||||
original_relative_path: markdown_file.relative_path,
|
||||
trash_relative_path: trash_relative_path.clone(),
|
||||
deleted_at_ms: now_ms(),
|
||||
archived_at: now_ms(),
|
||||
purged_at: None,
|
||||
},
|
||||
);
|
||||
write_page_ids_metadata(root, &metadata.page_ids)?;
|
||||
@@ -971,6 +1025,68 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
}))
|
||||
}
|
||||
|
||||
fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let relative_path = normalize_relative_path(root, file)?;
|
||||
if is_markdown_file(&relative_path) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_resource_not_supported",
|
||||
"Markdown 文件必须走页面生命周期,不能走 local_file 资源回收站",
|
||||
));
|
||||
}
|
||||
let trash_dir = root.join(".mnote").join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法创建本地回收站 {}: {error}", trash_dir.display()),
|
||||
)
|
||||
})?;
|
||||
let file_name = file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("local-file");
|
||||
let target = next_available_raw_path(&trash_dir, file_name);
|
||||
fs::rename(file, &target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法移动本地资源到回收站 {}: {error}", file.display()),
|
||||
)
|
||||
})?;
|
||||
let trash_relative_path = normalize_relative_path(root, &target)?;
|
||||
let trash_entry_id = local_file_trash_entry_id(&relative_path);
|
||||
let now = now_ms();
|
||||
metadata.trash_entries.insert(
|
||||
trash_entry_id.clone(),
|
||||
LocalTrashEntry {
|
||||
document_id: entry_id.to_string(),
|
||||
resource_kind: "local_file".to_string(),
|
||||
resource_scope: "local_folder".to_string(),
|
||||
original_file_path: relative_path.clone(),
|
||||
trashed_file_path: trash_relative_path.clone(),
|
||||
trash_entry_id: trash_entry_id.clone(),
|
||||
original_relative_path: relative_path.clone(),
|
||||
trash_relative_path: trash_relative_path.clone(),
|
||||
deleted_at_ms: now,
|
||||
archived_at: now,
|
||||
purged_at: None,
|
||||
},
|
||||
);
|
||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"originalFilePath": relative_path,
|
||||
"trashEntryId": trash_entry_id,
|
||||
"trashPath": trash_relative_path,
|
||||
"action": "delete",
|
||||
"canonicalCommand": "tree.resource.archive",
|
||||
"sourceKind": "local_folder",
|
||||
}))
|
||||
}
|
||||
|
||||
fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_entry = metadata
|
||||
@@ -1033,6 +1149,73 @@ fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result<Value,
|
||||
}))
|
||||
}
|
||||
|
||||
fn restore_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要恢复的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let trash_entry = metadata
|
||||
.trash_entries
|
||||
.get(&trash_key)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要恢复的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let original_relative_path = local_trash_original_path(&trash_entry);
|
||||
let trash_relative_path = local_trash_file_path(&trash_entry);
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||
if !trash_path.is_file() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"本地资源回收站文件不存在,无法恢复",
|
||||
));
|
||||
}
|
||||
let original_path = resolve_metadata_relative_path(root, &original_relative_path)?;
|
||||
if original_path.exists() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_file_restore_conflict",
|
||||
"本地资源原路径已存在,恢复会覆盖用户文件",
|
||||
));
|
||||
}
|
||||
let parent = original_path.parent().ok_or_else(|| {
|
||||
WebError::bad_request_code("local_tree_command_failed", "无法解析资源恢复目标目录")
|
||||
})?;
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法创建资源恢复目标目录 {}: {error}", parent.display()),
|
||||
)
|
||||
})?;
|
||||
fs::rename(&trash_path, &original_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法从本地回收站恢复资源 {}: {error}", trash_path.display()),
|
||||
)
|
||||
})?;
|
||||
metadata.trash_entries.remove(&trash_key);
|
||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"originalFilePath": original_relative_path,
|
||||
"relativePath": normalize_relative_path(root, &original_path)?,
|
||||
"trashEntryId": trash_key,
|
||||
"action": "restore",
|
||||
"canonicalCommand": "tree.resource.restore",
|
||||
"sourceKind": "local_folder",
|
||||
}))
|
||||
}
|
||||
|
||||
fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? {
|
||||
@@ -1083,6 +1266,47 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
}))
|
||||
}
|
||||
|
||||
fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要永久删除的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let trash_entry = metadata.trash_entries.remove(&trash_key).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要永久删除的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let trash_relative_path = local_trash_file_path(&trash_entry);
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||
if trash_path.exists() {
|
||||
fs::remove_file(&trash_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!(
|
||||
"无法永久删除本地资源回收站文件 {}: {error}",
|
||||
trash_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"trashEntryId": trash_key,
|
||||
"action": "purge",
|
||||
"canonicalCommand": "tree.resource.purge",
|
||||
"sourceKind": "local_folder",
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_local_parent_directory(
|
||||
root: &Path,
|
||||
metadata: &LocalFolderMetadata,
|
||||
@@ -1222,6 +1446,65 @@ fn resolve_local_raw_file_id(root: &Path, entry_id: &str) -> Result<Option<PathB
|
||||
}
|
||||
}
|
||||
|
||||
fn local_file_trash_entry_id(relative_path: &str) -> String {
|
||||
format!("local-file:{relative_path}")
|
||||
}
|
||||
|
||||
fn is_local_file_trash_entry(entry: &LocalTrashEntry) -> bool {
|
||||
entry.resource_kind == "local_file" || entry.trash_entry_id.starts_with("local-file:")
|
||||
}
|
||||
|
||||
fn local_trash_original_path(entry: &LocalTrashEntry) -> String {
|
||||
if !entry.original_file_path.trim().is_empty() {
|
||||
entry.original_file_path.clone()
|
||||
} else {
|
||||
entry.original_relative_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn local_trash_file_path(entry: &LocalTrashEntry) -> String {
|
||||
if !entry.trashed_file_path.trim().is_empty() {
|
||||
entry.trashed_file_path.clone()
|
||||
} else {
|
||||
entry.trash_relative_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn find_local_file_trash_entry_key(
|
||||
metadata: &LocalFolderMetadata,
|
||||
entry_id: &str,
|
||||
) -> Option<String> {
|
||||
let trimmed = entry_id.trim();
|
||||
if metadata
|
||||
.trash_entries
|
||||
.get(trimmed)
|
||||
.map(is_local_file_trash_entry)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
let relative_path = trimmed
|
||||
.strip_prefix("local:asset:")
|
||||
.or_else(|| trimmed.strip_prefix("local:node:"))
|
||||
.unwrap_or(trimmed);
|
||||
let expected_key = local_file_trash_entry_id(relative_path);
|
||||
if metadata
|
||||
.trash_entries
|
||||
.get(&expected_key)
|
||||
.map(is_local_file_trash_entry)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(expected_key);
|
||||
}
|
||||
metadata
|
||||
.trash_entries
|
||||
.iter()
|
||||
.find(|(_, entry)| {
|
||||
is_local_file_trash_entry(entry) && local_trash_original_path(entry) == relative_path
|
||||
})
|
||||
.map(|(key, _)| key.clone())
|
||||
}
|
||||
|
||||
pub fn local_workspace_id_from_root_uri(root_uri: &str) -> Result<String, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
|
||||
Reference in New Issue
Block a user