use crate::error::WebError; use crate::page_aggregate::{ PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, }; use crate::routes::local_markdown_parser::{ file_stem_title, parse_markdown_page, split_frontmatter, }; use crate::routes::snapshot_support::ProjectionSnapshot; use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::cmp::Ordering; use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; use std::fs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone)] struct LocalFolderEntry { path: PathBuf, relative_path: String, file_name: String, is_dir: bool, is_symlink: bool, is_readonly: bool, } #[derive(Debug, Clone, Default)] struct LocalFolderMetadata { page_ids: BTreeMap, page_options: BTreeMap, trash_entries: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalTrashEntry { document_id: String, original_relative_path: String, trash_relative_path: String, deleted_at_ms: u128, } #[derive(Debug, Clone)] struct LocalFolderRow { node_id: String, row_id: String, parent_node_id: Option, title: String, depth: u32, position: u32, row_kind: String, icon_hint: String, relative_path: String, source_uri: String, child_count: u32, expandable: bool, expanded_by_default: bool, document_id: Option, capabilities: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalFolderWatchRevision { pub root_uri: String, pub revision: String, pub entry_count: usize, pub latest_modified_ms: u128, } pub fn load_local_folder_file_tree_snapshot( root_uri: &str, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; if !canonical_root.is_dir() { return Err(WebError::bad_request_code( "local_folder_not_directory", "本地 rootUri 必须指向目录", )); } let root_source_uri = file_uri_for_path(&canonical_root); let metadata = load_local_folder_metadata(&canonical_root)?; let mut rows = Vec::new(); scan_directory( &canonical_root, &canonical_root, None, 0, &root_source_uri, &metadata, &mut rows, )?; let items = rows .iter() .map(local_folder_row_to_projection_item) .collect::>(); let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; let dataset = json!({ "workspace": { "id": local_workspace_id(&canonical_root), "sourceKind": "local_folder", "rootUri": root_source_uri, }, "nodes": [], "edges": [], "documents": [], "media_assets": [], "mindmap_assets": [], "table_assets": [], "mindmap_asset_children": {}, }); let projection = json!({ "projection": "file_tree", "sourceKind": "local_folder", "rootUri": root_source_uri, "watchRevision": watch_revision, "items": items, }); Ok(ProjectionSnapshot { dataset, projection, }) } pub fn load_local_folder_page_tree_snapshot( root_uri: &str, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; if !canonical_root.is_dir() { return Err(WebError::bad_request_code( "local_folder_not_directory", "本地 rootUri 必须指向目录", )); } let root_source_uri = file_uri_for_path(&canonical_root); let metadata = load_local_folder_metadata(&canonical_root)?; let mut rows = Vec::new(); scan_markdown_page_tree( &canonical_root, &canonical_root, None, 0, &metadata, &mut rows, )?; let child_counts = rows .iter() .filter_map(|row| row.parent_node_id.as_ref()) .fold( std::collections::BTreeMap::::new(), |mut acc, parent| { *acc.entry(parent.clone()).or_default() += 1; acc }, ); for row in &mut rows { row.child_count = child_counts.get(&row.node_id).copied().unwrap_or(0); row.expandable = row.child_count > 0; row.expanded_by_default = row.expandable && row.depth < 2; } let items = rows .iter() .map(local_folder_row_to_projection_item) .collect::>(); let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; Ok(ProjectionSnapshot { dataset: json!({ "workspace": { "id": local_workspace_id(&canonical_root), "sourceKind": "local_folder", "rootUri": root_source_uri, }, "documents": rows .iter() .filter_map(|row| { row.document_id.as_ref().map(|document_id| { json!({ "id": document_id, "workspace_id": local_workspace_id(&canonical_root), "title": row.title, "parent_id": row.parent_node_id, "sort_order": row.position, }) }) }) .collect::>(), }), projection: json!({ "projection": "page_tree", "sourceKind": "local_folder", "rootUri": root_source_uri, "watchRevision": watch_revision, "items": items, }), }) } pub fn local_folder_watch_revision(root_uri: &str) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; if !canonical_root.is_dir() { return Err(WebError::bad_request_code( "local_folder_not_directory", "本地 rootUri 必须指向目录", )); } let root_source_uri = file_uri_for_path(&canonical_root); local_folder_watch_revision_for_root(&canonical_root, &root_source_uri) } pub fn resolve_local_markdown_page_aggregate( root_uri: &str, document_id: &str, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; if !canonical_root.is_dir() { return Err(WebError::bad_request_code( "local_folder_not_directory", "本地 rootUri 必须指向目录", )); } let metadata = load_local_folder_metadata(&canonical_root)?; let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? .ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到本地 Markdown 页面对应的文件", ) })?; let markdown = fs::read_to_string(&markdown_file.path).map_err(|error| { WebError::bad_request_code( "local_markdown_read_failed", format!( "无法读取本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; let parsed = parse_markdown_page(&markdown, &markdown_file.file_name); let title = parsed.title; let content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body); let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64; let page_subtree = markdown_page_subtree(document_id, &title, &content); let workspace_id = local_workspace_id(&canonical_root); let page_options = metadata .page_options .get(document_id) .or_else(|| metadata.page_options.get(&markdown_file.relative_path)) .map(page_options_from_metadata) .unwrap_or_default(); let character_count = parsed.body.chars().count() as u64; let word_count = parsed .body .split_whitespace() .filter(|word| !word.trim().is_empty()) .count() as u64; let read_only = markdown_file.is_readonly; Ok(PageAggregate { schema: PageAggregate::SCHEMA.into(), projection_version: PageAggregate::VERSION, source: PageAggregateSource::KernelProjection, page_id: document_id.to_string(), parent_id: None, title: title.clone(), path: vec![document_id.to_string()], sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()], body_ref: Some(format!("local-md:{document_id}")), layout_options: serde_json::to_value(&page_options).unwrap_or_else(|_| json!({})), updated_at: None, identity: PageIdentity { document_id: document_id.to_string(), workspace_id, }, head: PageHead { title, updated_at: Value::Null, permissions: PagePermissions { read_only, disable_download: false, disable_copy: false, }, }, layout: PageLayout { page_options }, body: PageBody { content, revision: Value::Number(0.into()), conflict_detection_key: Value::String(local_markdown_conflict_detection_key( document_id, &markdown_file.path, )?), }, tree: PageTree { page_subtree }, stats: PageStats { word_count, character_count, block_count, todo_total: 0, todo_done: 0, }, }) } pub fn save_local_markdown_page( root_uri: &str, document_id: &str, expected_conflict_detection_key: Option<&str>, content: &Value, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; let metadata = load_local_folder_metadata(&canonical_root)?; let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? .ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到要保存的本地 Markdown 页面", ) })?; let current = fs::read_to_string(&markdown_file.path).map_err(|error| { WebError::bad_request_code( "local_markdown_read_failed", format!( "无法读取本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; let current_conflict_key = local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; if let Some(expected_key) = expected_conflict_detection_key .map(str::trim) .filter(|value| !value.is_empty()) { if expected_key != current_conflict_key { return Err(WebError::new( StatusCode::CONFLICT, "local_markdown_external_change", "本地 Markdown 文件已被外部修改,请刷新后再保存", )); } } let (frontmatter, _) = split_frontmatter(¤t); let body = editor_blocks_to_markdown(content); let next_markdown = if let Some(frontmatter) = frontmatter { format!("---\n{frontmatter}\n---\n{body}") } else { body }; fs::write(&markdown_file.path, next_markdown).map_err(|error| { WebError::bad_request_code( "local_markdown_write_failed", format!( "无法保存本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; let next_conflict_key = local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; Ok(json!({ "ok": true, "documentId": document_id, "revision": now_ms(), "conflict_detection_key": next_conflict_key, "executedCommand": "page.body.save", "canonicalCommand": "page.body.save", "sourceKind": "local_folder", })) } pub fn update_local_markdown_title( root_uri: &str, document_id: &str, title: &str, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; let metadata = load_local_folder_metadata(&canonical_root)?; let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? .ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到要更新标题的本地 Markdown 页面", ) })?; let current = fs::read_to_string(&markdown_file.path).map_err(|error| { WebError::bad_request_code( "local_markdown_read_failed", format!( "无法读取本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; fs::write( &markdown_file.path, markdown_with_frontmatter_fields(¤t, &[("title", title.trim())]), ) .map_err(|error| { WebError::bad_request_code( "local_markdown_write_failed", format!( "无法写入本地 Markdown 标题 {}: {error}", markdown_file.path.display() ), ) })?; Ok(json!({ "ok": true, "documentId": document_id, "title": title.trim(), "updated_at": Value::Null, "sourceKind": "local_folder", })) } pub fn update_local_page_options( root_uri: &str, document_id: &str, options: &Value, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; let mut metadata = load_local_folder_metadata(&canonical_root)?; metadata .page_options .insert(document_id.to_string(), options.clone()); write_page_options_metadata(&canonical_root, &metadata.page_options)?; Ok(json!({ "ok": true, "documentId": document_id, "options": options, "updated_at": Value::Null, "sourceKind": "local_folder", })) } pub fn execute_local_tree_command( root_uri: &str, action: &str, document_id: &str, parent_id: Option<&str>, title: Option<&str>, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; match action { "create" => { create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面")) } "createFolder" | "create_folder" | "folder.create" => { create_local_folder(&canonical_root, parent_id, title.unwrap_or("新建文件夹")) } "rename" => rename_local_entry(&canonical_root, document_id, title.unwrap_or("无标题")), "copy" => { copy_local_markdown_page(&canonical_root, document_id, parent_id, title.unwrap_or("")) } "dropFiles" | "drop_files" => drop_external_files_into_local_folder( &canonical_root, parent_id, 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), other => Err(WebError::bad_request_code( "local_tree_command_unsupported", format!("local_folder 暂不支持 tree action: {other}"), )), } } fn create_local_markdown_page( root: &Path, parent_id: Option<&str>, title: &str, ) -> Result { let metadata = load_local_folder_metadata(root)?; let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; let safe_title = sanitize_file_stem(title, "新页面"); let target = next_available_path(&parent_directory, &safe_title, "md"); let relative_path = normalize_relative_path(root, &target)?; let page_id = normalize_metadata_page_id(&format!( "local-mdid:{}", encode_local_id_segment(&format!("{}:{relative_path}", local_workspace_id(root))) )); let markdown = markdown_with_frontmatter_fields( &format!("# {}\n", title.trim()), &[("mnote_id", page_id.as_str()), ("title", title.trim())], ); fs::write(&target, markdown).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法创建本地 Markdown 文件 {}: {error}", target.display()), ) })?; let mut page_ids = metadata.page_ids; page_ids.insert(relative_path.clone(), page_id.clone()); write_page_ids_metadata(root, &page_ids)?; Ok(json!({ "ok": true, "id": page_id, "documentId": page_id, "relativePath": relative_path, "action": "create", "sourceKind": "local_folder", })) } fn create_local_folder( root: &Path, parent_id: Option<&str>, title: &str, ) -> Result { let metadata = load_local_folder_metadata(root)?; let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; let target = next_available_directory_path(&parent_directory, &sanitize_file_stem(title, "新建文件夹")); fs::create_dir_all(&target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法创建本地文件夹 {}: {error}", target.display()), ) })?; Ok(json!({ "ok": true, "id": local_directory_group_id(&normalize_relative_path(root, &target)?), "relativePath": normalize_relative_path(root, &target)?, "action": "createFolder", "sourceKind": "local_folder", })) } fn rename_local_entry(root: &Path, document_id: &str, title: &str) -> Result { if let Some(directory) = resolve_local_directory_id(root, document_id)? { return rename_local_directory(root, &directory, title); } if let Some(file) = resolve_local_raw_file_id(root, document_id)? { return rename_local_raw_file(root, &file, title); } rename_local_markdown_page(root, document_id, title) } fn rename_local_raw_file(root: &Path, file: &Path, title: &str) -> Result { let parent = file.parent().ok_or_else(|| { WebError::bad_request_code("local_tree_command_failed", "无法解析文件父目录") })?; let original_extension = file .extension() .and_then(|value| value.to_str()) .unwrap_or(""); let requested_name = sanitize_file_name(title, "文件"); let requested_has_extension = Path::new(&requested_name).extension().is_some(); let target_name = if requested_has_extension || original_extension.is_empty() { requested_name } else { format!( "{}.{}", sanitize_file_stem(&requested_name, "文件"), original_extension ) }; let target = next_available_raw_path(parent, &target_name); fs::rename(file, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法重命名本地文件 {}: {error}", file.display()), ) })?; Ok(json!({ "ok": true, "id": local_node_id(&normalize_relative_path(root, &target)?), "relativePath": normalize_relative_path(root, &target)?, "action": "rename", "sourceKind": "local_folder", })) } fn rename_local_markdown_page( root: &Path, document_id: &str, title: &str, ) -> Result { let mut metadata = load_local_folder_metadata(root)?; let markdown_file = find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到要重命名的本地 Markdown 页面", ) })?; let parent = markdown_file.path.parent().ok_or_else(|| { WebError::bad_request_code("local_tree_command_failed", "无法解析 Markdown 父目录") })?; let target = next_available_path(parent, &sanitize_file_stem(title, "无标题"), "md"); let old_relative_path = markdown_file.relative_path; fs::rename(&markdown_file.path, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法重命名本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; let new_relative_path = normalize_relative_path(root, &target)?; metadata.page_ids.remove(&old_relative_path); metadata .page_ids .insert(new_relative_path.clone(), document_id.to_string()); write_page_ids_metadata(root, &metadata.page_ids)?; Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "relativePath": new_relative_path, "action": "rename", "sourceKind": "local_folder", })) } fn rename_local_directory(root: &Path, directory: &Path, title: &str) -> Result { if directory == root { return Err(WebError::bad_request_code( "local_tree_command_failed", "不能重命名本地 workspace root", )); } let mut metadata = load_local_folder_metadata(root)?; let parent = directory.parent().ok_or_else(|| { WebError::bad_request_code("local_tree_command_failed", "无法解析文件夹父目录") })?; let target = next_available_directory_path(parent, &sanitize_file_stem(title, "文件夹")); let old_relative_path = normalize_relative_path(root, directory)?; fs::rename(directory, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法重命名本地文件夹 {}: {error}", directory.display()), ) })?; let new_relative_path = normalize_relative_path(root, &target)?; rewrite_page_ids_prefix( &mut metadata.page_ids, &old_relative_path, &new_relative_path, ); write_page_ids_metadata(root, &metadata.page_ids)?; Ok(json!({ "ok": true, "id": local_directory_group_id(&new_relative_path), "relativePath": new_relative_path, "action": "rename", "sourceKind": "local_folder", })) } fn copy_local_markdown_page( root: &Path, document_id: &str, parent_id: Option<&str>, title: &str, ) -> Result { let mut metadata = load_local_folder_metadata(root)?; let markdown_file = find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到要复制的本地 Markdown 页面", ) })?; let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; let source_stem = markdown_file .path .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or("页面"); let copy_stem = if title.trim().is_empty() { source_stem } else { title.trim() }; let target = next_available_path( &target_directory, &sanitize_file_stem(copy_stem, "页面"), "md", ); let new_relative_path = normalize_relative_path(root, &target)?; let new_page_id = normalize_metadata_page_id(&format!( "local-mdid:{}", encode_local_id_segment(&format!("{}:{new_relative_path}", local_workspace_id(root))) )); let source_markdown = fs::read_to_string(&markdown_file.path).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法读取待复制 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; fs::write( &target, markdown_with_frontmatter_fields(&source_markdown, &[("mnote_id", new_page_id.as_str())]), ) .map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法复制本地 Markdown 文件到 {}: {error}", target.display()), ) })?; metadata .page_ids .insert(new_relative_path.clone(), new_page_id.clone()); write_page_ids_metadata(root, &metadata.page_ids)?; Ok(json!({ "ok": true, "id": new_page_id, "documentId": new_page_id, "relativePath": new_relative_path, "action": "copy", "sourceKind": "local_folder", })) } fn move_local_entry( root: &Path, document_id: &str, parent_id: Option<&str>, ) -> Result { if let Some(directory) = resolve_local_directory_id(root, document_id)? { return move_local_directory(root, &directory, parent_id); } move_local_markdown_page(root, document_id, parent_id) } fn move_local_markdown_page( root: &Path, document_id: &str, parent_id: Option<&str>, ) -> Result { let mut metadata = load_local_folder_metadata(root)?; let markdown_file = find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到要移动的本地 Markdown 页面", ) })?; let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; if markdown_file.path.parent() == Some(target_directory.as_path()) { return Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "relativePath": markdown_file.relative_path, "action": "move", "sourceKind": "local_folder", })); } let file_name = markdown_file .path .file_stem() .and_then(|stem| stem.to_str()) .map(|stem| sanitize_file_stem(stem, "页面")) .unwrap_or_else(|| "页面".to_string()); let target = next_available_path(&target_directory, &file_name, "md"); if target == markdown_file.path { return Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "relativePath": markdown_file.relative_path, "action": "move", "sourceKind": "local_folder", })); } if target.starts_with(&markdown_file.path) { return Err(WebError::bad_request_code( "local_tree_command_failed", "禁止把本地页面移动到自身或后代", )); } let old_relative_path = markdown_file.relative_path; fs::rename(&markdown_file.path, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法移动本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; let new_relative_path = normalize_relative_path(root, &target)?; metadata.page_ids.remove(&old_relative_path); metadata .page_ids .insert(new_relative_path.clone(), document_id.to_string()); write_page_ids_metadata(root, &metadata.page_ids)?; Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "relativePath": new_relative_path, "action": "move", "sourceKind": "local_folder", })) } fn move_local_directory( root: &Path, directory: &Path, parent_id: Option<&str>, ) -> Result { if directory == root { return Err(WebError::bad_request_code( "local_tree_command_failed", "不能移动本地 workspace root", )); } let mut metadata = load_local_folder_metadata(root)?; let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; if target_directory == directory || target_directory.starts_with(directory) { return Err(WebError::bad_request_code( "local_tree_command_failed", "禁止把本地文件夹移动到自身或后代", )); } if directory.parent() == Some(target_directory.as_path()) { return Ok(json!({ "ok": true, "id": local_directory_group_id(&normalize_relative_path(root, directory)?), "relativePath": normalize_relative_path(root, directory)?, "action": "move", "sourceKind": "local_folder", })); } let stem = directory .file_name() .and_then(|name| name.to_str()) .map(|name| sanitize_file_stem(name, "文件夹")) .unwrap_or_else(|| "文件夹".to_string()); let target = next_available_directory_path(&target_directory, &stem); let old_relative_path = normalize_relative_path(root, directory)?; fs::rename(directory, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法移动本地文件夹 {}: {error}", directory.display()), ) })?; let new_relative_path = normalize_relative_path(root, &target)?; rewrite_page_ids_prefix( &mut metadata.page_ids, &old_relative_path, &new_relative_path, ); write_page_ids_metadata(root, &metadata.page_ids)?; Ok(json!({ "ok": true, "id": local_directory_group_id(&new_relative_path), "relativePath": new_relative_path, "action": "move", "sourceKind": "local_folder", })) } fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let markdown_file = find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到要删除的本地 Markdown 页面", ) })?; 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 = markdown_file .path .file_name() .and_then(|name| name.to_str()) .unwrap_or("page.md"); let target = next_available_path(&trash_dir, &file_stem_title(file_name), "md"); fs::rename(&markdown_file.path, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法移动到本地回收站 {}: {error}", markdown_file.path.display() ), ) })?; let trash_relative_path = normalize_relative_path(root, &target)?; metadata.page_ids.remove(&markdown_file.relative_path); metadata.trash_entries.insert( document_id.to_string(), LocalTrashEntry { document_id: document_id.to_string(), original_relative_path: markdown_file.relative_path, trash_relative_path: trash_relative_path.clone(), deleted_at_ms: now_ms(), }, ); write_page_ids_metadata(root, &metadata.page_ids)?; write_trash_index_metadata(root, &metadata.trash_entries)?; Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "trashPath": trash_relative_path, "action": "delete", "sourceKind": "local_folder", })) } fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let trash_entry = metadata .trash_entries .get(document_id) .cloned() .ok_or_else(|| { WebError::bad_request_code( "local_trash_entry_not_found", "找不到要恢复的本地回收站记录", ) })?; let trash_path = resolve_metadata_relative_path(root, &trash_entry.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, &trash_entry.original_relative_path)?; 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()), ) })?; let stem = original_path .file_stem() .and_then(|stem| stem.to_str()) .map(|stem| sanitize_file_stem(stem, "页面")) .unwrap_or_else(|| "页面".to_string()); let target = if original_path.exists() { next_available_path(parent, &stem, "md") } else { original_path }; fs::rename(&trash_path, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法从本地回收站恢复 {}: {error}", trash_path.display()), ) })?; let restored_relative_path = normalize_relative_path(root, &target)?; metadata .page_ids .insert(restored_relative_path.clone(), document_id.to_string()); metadata.trash_entries.remove(document_id); write_page_ids_metadata(root, &metadata.page_ids)?; write_trash_index_metadata(root, &metadata.trash_entries)?; Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "relativePath": restored_relative_path, "action": "restore", "sourceKind": "local_folder", })) } fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? { fs::remove_file(&markdown_file.path).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法永久删除本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; metadata.page_ids.remove(&markdown_file.relative_path); write_page_ids_metadata(root, &metadata.page_ids)?; return Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "action": "purge", "sourceKind": "local_folder", })); } let trash_entry = metadata.trash_entries.remove(document_id).ok_or_else(|| { WebError::bad_request_code( "local_markdown_not_found", "找不到要永久删除的本地 Markdown 页面", ) })?; let trash_path = resolve_metadata_relative_path(root, &trash_entry.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": document_id, "documentId": document_id, "action": "purge", "sourceKind": "local_folder", })) } fn resolve_local_parent_directory( root: &Path, metadata: &LocalFolderMetadata, parent_id: Option<&str>, ) -> Result { let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(root.to_path_buf()); }; if let Some(encoded) = parent_id.strip_prefix("local-dir:") { let relative_path = decode_local_id_segment(encoded)?; let directory = root.join(relative_path); if directory.starts_with(root) && directory.is_dir() { return Ok(directory); } } if let Some(relative_path) = parent_id.strip_prefix("local:node:") { let directory = resolve_metadata_relative_path(root, relative_path)?; if directory.is_dir() { return Ok(directory); } } if let Some(relative_path) = parent_id.strip_prefix("local:folder:") { let directory = resolve_metadata_relative_path(root, relative_path)?; if directory.is_dir() { return Ok(directory); } } if let Some(markdown_file) = find_markdown_by_page_id(root, metadata, parent_id)? { if let Some(parent) = markdown_file.path.parent() { return Ok(parent.to_path_buf()); } } Err(WebError::bad_request_code( "local_tree_command_failed", "无法解析本地新建目标目录", )) } fn drop_external_files_into_local_folder( root: &Path, parent_id: Option<&str>, files_json: &str, ) -> Result { let metadata = load_local_folder_metadata(root)?; let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; let files = serde_json::from_str::>(files_json).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("外部拖入文件 payload 非法: {error}"), ) })?; if files.is_empty() { return Err(WebError::bad_request_code( "local_tree_command_failed", "外部拖入文件不能为空", )); } let mut written = Vec::new(); for file in files { let name = sanitize_file_name(&file.name, "dropped-file"); let path = next_available_raw_path(&target_directory, &name); fs::write(&path, file.text.unwrap_or_default()).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法写入外部拖入文件 {}: {error}", path.display()), ) })?; written.push(json!({ "name": name, "relativePath": normalize_relative_path(root, &path)?, })); } Ok(json!({ "ok": true, "action": "dropFiles", "files": written, "sourceKind": "local_folder", })) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalDropFilePayload { name: String, text: Option, } fn resolve_local_directory_id(root: &Path, entry_id: &str) -> Result, WebError> { let trimmed = entry_id.trim(); if let Some(relative_path) = trimmed .strip_prefix("local:node:") .or_else(|| trimmed.strip_prefix("local:folder:")) { let directory = resolve_metadata_relative_path(root, relative_path)?; if directory.is_dir() { return Ok(Some(directory)); } if directory.is_file() { return Ok(None); } return Err(WebError::bad_request_code( "local_tree_command_failed", "本地文件夹不存在", )); } let Some(encoded) = trimmed.strip_prefix("local-dir:") else { return Ok(None); }; let relative_path = decode_local_id_segment(encoded)?; let directory = resolve_metadata_relative_path(root, &relative_path)?; if directory.is_dir() { Ok(Some(directory)) } else { Err(WebError::bad_request_code( "local_tree_command_failed", "本地文件夹不存在", )) } } fn resolve_local_raw_file_id(root: &Path, entry_id: &str) -> Result, WebError> { let trimmed = entry_id.trim(); let Some(relative_path) = trimmed .strip_prefix("local:node:") .or_else(|| trimmed.strip_prefix("local:asset:")) else { return Ok(None); }; let file = resolve_metadata_relative_path(root, relative_path)?; if file.is_file() { Ok(Some(file)) } else { Err(WebError::bad_request_code( "local_tree_command_failed", "本地文件不存在", )) } } pub fn local_workspace_id_from_root_uri(root_uri: &str) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; Ok(local_workspace_id(&canonical_root)) } fn parse_file_root_uri(root_uri: &str) -> Result { let trimmed = root_uri.trim(); if trimmed.is_empty() { return Err(WebError::bad_request_code( "local_folder_root_required", "缺少本地文件夹 rootUri", )); } let Some(path) = trimmed.strip_prefix("file://") else { return Err(WebError::bad_request_code( "local_folder_root_invalid", "本地文件夹 rootUri 必须使用 file://", )); }; if path.trim().is_empty() { return Err(WebError::bad_request_code( "local_folder_root_invalid", "本地文件夹 rootUri 不能为空", )); } Ok(PathBuf::from(percent_decode_file_uri_path(path)?)) } fn percent_decode_file_uri_path(value: &str) -> Result { let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { if bytes[index] == b'%' { if index + 2 >= bytes.len() { return Err(WebError::bad_request_code( "local_folder_root_invalid", "file:// rootUri 包含不完整的百分号编码", )); } let hex = &value[index + 1..index + 3]; let byte = u8::from_str_radix(hex, 16).map_err(|_| { WebError::bad_request_code( "local_folder_root_invalid", "file:// rootUri 包含无效的百分号编码", ) })?; decoded.push(byte); index += 3; } else { decoded.push(bytes[index]); index += 1; } } String::from_utf8(decoded).map_err(|_| { WebError::bad_request_code( "local_folder_root_invalid", "file:// rootUri 不是 UTF-8 路径", ) }) } fn load_local_folder_metadata(root: &Path) -> Result { Ok(LocalFolderMetadata { page_ids: load_metadata_string_map(&root.join(".mnote").join("page-ids.json"))?, page_options: load_metadata_value_map(&root.join(".mnote").join("page-options.json"))?, trash_entries: load_trash_index_map(&root.join(".mnote").join("trash-index.json"))?, }) } fn load_metadata_string_map(path: &Path) -> Result, WebError> { if !path.exists() { return Ok(BTreeMap::new()); } let value = read_metadata_json(path)?; let source = value .get("pages") .or_else(|| value.get("pageIds")) .unwrap_or(&value); let Some(map) = source.as_object() else { return Err(metadata_invalid(path, "元数据必须是对象")); }; let mut result = BTreeMap::new(); for (key, value) in map { let Some(page_id) = value .as_str() .map(str::trim) .filter(|item| !item.is_empty()) else { return Err(metadata_invalid(path, "page id 必须是非空字符串")); }; result.insert(key.clone(), page_id.to_string()); } Ok(result) } fn load_metadata_value_map(path: &Path) -> Result, WebError> { if !path.exists() { return Ok(BTreeMap::new()); } let value = read_metadata_json(path)?; let source = value .get("pages") .or_else(|| value.get("options")) .unwrap_or(&value); let Some(map) = source.as_object() else { return Err(metadata_invalid(path, "页面选项元数据必须是对象")); }; Ok(map .iter() .map(|(key, value)| (key.clone(), value.clone())) .collect()) } fn read_metadata_json(path: &Path) -> Result { let raw = fs::read_to_string(path).map_err(|error| { WebError::bad_request_code( "local_metadata_read_failed", format!("无法读取本地元数据 {}: {error}", path.display()), ) })?; serde_json::from_str(&raw) .map_err(|error| metadata_invalid(path, format!("元数据 JSON 损坏: {error}"))) } fn load_trash_index_map(path: &Path) -> Result, WebError> { if !path.exists() { return Ok(BTreeMap::new()); } let value = read_metadata_json(path)?; let source = value.get("entries").unwrap_or(&value); let entries: BTreeMap = serde_json::from_value(source.clone()) .map_err(|error| metadata_invalid(path, format!("回收站索引损坏: {error}")))?; Ok(entries) } fn metadata_invalid(path: &Path, message: impl Into) -> WebError { WebError::bad_request_code( "local_metadata_invalid", format!("{};请修复或移走 {} 后重试", message.into(), path.display()), ) } #[allow(dead_code)] pub fn initialize_local_page_id(root_uri: &str, relative_path: &str) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) })?; let target = canonical_root .join(relative_path) .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_page_id_target_invalid", format!("无法解析本地 Markdown 文件: {error}"), ) })?; if !target.starts_with(&canonical_root) || !target.is_file() || !is_markdown_file(relative_path) { return Err(WebError::bad_request_code( "local_page_id_target_invalid", "只能为 root 内的 Markdown 文件初始化 page id", )); } let mut metadata = load_local_folder_metadata(&canonical_root)?; if let Some(existing) = metadata.page_ids.get(relative_path) { return Ok(normalize_metadata_page_id(existing)); } let page_id = format!( "local-mdid:{}", encode_local_id_segment(&format!( "{}:{relative_path}", local_workspace_id(&canonical_root) )) ); metadata .page_ids .insert(relative_path.to_string(), page_id.clone()); write_page_ids_metadata(&canonical_root, &metadata.page_ids)?; Ok(page_id) } #[allow(dead_code)] fn write_page_ids_metadata( root: &Path, page_ids: &BTreeMap, ) -> Result<(), WebError> { let mnote_dir = root.join(".mnote"); fs::create_dir_all(&mnote_dir).map_err(|error| { WebError::bad_request_code( "local_metadata_write_failed", format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), ) })?; let path = mnote_dir.join("page-ids.json"); let value = json!({ "version": 1, "pages": page_ids, }); write_json_atomic(&path, &value) } fn write_trash_index_metadata( root: &Path, entries: &BTreeMap, ) -> Result<(), WebError> { let mnote_dir = root.join(".mnote"); fs::create_dir_all(&mnote_dir).map_err(|error| { WebError::bad_request_code( "local_metadata_write_failed", format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), ) })?; let path = mnote_dir.join("trash-index.json"); let value = json!({ "version": 1, "entries": entries, }); write_json_atomic(&path, &value) } fn write_page_options_metadata( root: &Path, page_options: &BTreeMap, ) -> Result<(), WebError> { let mnote_dir = root.join(".mnote"); fs::create_dir_all(&mnote_dir).map_err(|error| { WebError::bad_request_code( "local_metadata_write_failed", format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), ) })?; let path = mnote_dir.join("page-options.json"); let value = json!({ "version": 1, "pages": page_options, }); write_json_atomic(&path, &value) } #[allow(dead_code)] fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> { let tmp_path = path.with_extension("json.tmp"); let bytes = serde_json::to_vec_pretty(value) .map_err(|error| WebError::internal(format!("本地元数据序列化失败: {error}")))?; fs::write(&tmp_path, bytes).map_err(|error| { WebError::bad_request_code( "local_metadata_write_failed", format!("无法写入临时元数据 {}: {error}", tmp_path.display()), ) })?; fs::rename(&tmp_path, path).map_err(|error| { let _ = fs::remove_file(&tmp_path); WebError::bad_request_code( "local_metadata_write_failed", format!("无法替换本地元数据 {}: {error}", path.display()), ) }) } fn scan_directory( root: &Path, directory: &Path, parent_node_id: Option, depth: u32, root_source_uri: &str, metadata: &LocalFolderMetadata, rows: &mut Vec, ) -> Result<(), WebError> { let entries = read_sorted_entries(directory, root)?; let entry_count = entries.len(); for (position, entry) in entries.into_iter().enumerate() { let node_id = local_node_id(if entry.relative_path.is_empty() { "." } else { &entry.relative_path }); let child_count = if entry.is_dir && !entry.is_symlink { count_visible_children(&entry.path, root)? } else { 0 }; let row_kind = if entry.is_dir { "folder".to_string() } else if is_markdown_file(&entry.file_name) { "markdown".to_string() } else { "asset".to_string() }; let icon_hint = icon_hint_for_entry(&entry); rows.push(LocalFolderRow { node_id: node_id.clone(), row_id: format!("local:{row_kind}:{}", entry.relative_path), parent_node_id: parent_node_id.clone(), title: entry.file_name.clone(), depth, position: position as u32, row_kind, icon_hint, relative_path: entry.relative_path.clone(), source_uri: file_uri_for_path(&entry.path), child_count, expandable: entry.is_dir && child_count > 0, expanded_by_default: depth < 1 && entry.is_dir && entry_count <= 80, document_id: if is_markdown_file(&entry.file_name) { Some(local_markdown_page_id(&entry.relative_path, None, metadata)) } else { None }, capabilities: local_entry_capabilities(&entry), }); if entry.is_dir && !entry.is_symlink { scan_directory( root, &entry.path, Some(node_id), depth + 1, root_source_uri, metadata, rows, )?; } } if rows.is_empty() && directory == root { rows.push(LocalFolderRow { node_id: local_node_id("."), row_id: "local:folder:.".to_string(), parent_node_id: None, title: root .file_name() .and_then(|name| name.to_str()) .unwrap_or("local folder") .to_string(), depth: 0, position: 0, row_kind: "folder".to_string(), icon_hint: "folder".to_string(), relative_path: ".".to_string(), source_uri: root_source_uri.to_string(), child_count: 0, expandable: false, expanded_by_default: false, document_id: None, capabilities: local_path_capabilities(root), }); } Ok(()) } fn read_sorted_entries(directory: &Path, root: &Path) -> Result, WebError> { let read_dir = fs::read_dir(directory).map_err(|error| { WebError::bad_request_code( "local_folder_scan_failed", format!("无法读取本地目录 {}: {error}", directory.display()), ) })?; let mut entries = Vec::new(); for entry in read_dir { let entry = entry.map_err(|error| { WebError::bad_request_code( "local_folder_scan_failed", format!("读取目录项失败: {error}"), ) })?; let path = entry.path(); let metadata = fs::symlink_metadata(&path).map_err(|error| { WebError::bad_request_code( "local_folder_scan_failed", format!("无法读取目录项元数据 {}: {error}", path.display()), ) })?; let file_name = entry.file_name().to_string_lossy().to_string(); let relative_path = normalize_relative_path(root, &path)?; if should_ignore_entry(&relative_path, &file_name) { continue; } let is_symlink = metadata.file_type().is_symlink(); let is_dir = if is_symlink { false } else { metadata.is_dir() }; let is_readonly = metadata.permissions().readonly(); entries.push(LocalFolderEntry { path, relative_path, file_name, is_dir, is_symlink, is_readonly, }); } entries.sort_by(compare_local_entries); Ok(entries) } fn count_visible_children(directory: &Path, root: &Path) -> Result { Ok(read_sorted_entries(directory, root)?.len() as u32) } fn local_entry_capabilities(entry: &LocalFolderEntry) -> Vec { let mut capabilities = Vec::new(); if entry.is_readonly { capabilities.push("readonly".to_string()); } if entry.is_symlink { capabilities.push("symlink".to_string()); } capabilities } fn local_path_capabilities(path: &Path) -> Vec { match fs::symlink_metadata(path) { Ok(metadata) if metadata.permissions().readonly() => vec!["readonly".to_string()], _ => Vec::new(), } } fn normalize_relative_path(root: &Path, path: &Path) -> Result { if !path.starts_with(root) { return Err(WebError::bad_request_code( "local_folder_root_escape", "本地文件夹扫描不能越过 root", )); } let relative = path.strip_prefix(root).map_err(|_| { WebError::bad_request_code("local_folder_root_escape", "本地路径不在 root 内") })?; Ok(relative .components() .map(|component| component.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/")) } fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result { let relative = Path::new(relative_path); if relative.is_absolute() || relative .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(WebError::bad_request_code( "local_folder_root_escape", "本地元数据路径不能越过 root", )); } let target = root.join(relative); if !target.starts_with(root) { return Err(WebError::bad_request_code( "local_folder_root_escape", "本地元数据路径不在 root 内", )); } Ok(target) } fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool { if matches!(file_name, ".git" | "node_modules" | ".mnote") { return true; } relative_path == ".mnote/trash" || relative_path.starts_with(".mnote/trash/") } fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering { match (a.is_dir, b.is_dir) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, _ => a .file_name .to_ascii_lowercase() .cmp(&b.file_name.to_ascii_lowercase()) .then_with(|| a.file_name.cmp(&b.file_name)), } } fn scan_markdown_page_tree( root: &Path, directory: &Path, parent_node_id: Option, depth: u32, metadata: &LocalFolderMetadata, rows: &mut Vec, ) -> Result { let entries = read_sorted_entries(directory, root)?; let mut directory_rows = Vec::::new(); let mut contains_markdown = false; for (position, entry) in entries.into_iter().enumerate() { if entry.is_dir && !entry.is_symlink { let node_id = local_directory_group_id(&entry.relative_path); let mut child_rows = Vec::new(); let child_contains_markdown = scan_markdown_page_tree( root, &entry.path, Some(node_id.clone()), depth + 1, metadata, &mut child_rows, )?; if child_contains_markdown { directory_rows.push(LocalFolderRow { node_id, row_id: format!("local:page-group:{}", entry.relative_path), parent_node_id: parent_node_id.clone(), title: entry.file_name.clone(), depth, position: position as u32, row_kind: "document".to_string(), icon_hint: "folder".to_string(), relative_path: entry.relative_path.clone(), source_uri: file_uri_for_path(&entry.path), child_count: 0, expandable: true, expanded_by_default: depth < 2, document_id: None, capabilities: local_entry_capabilities(&entry), }); directory_rows.extend(child_rows); contains_markdown = true; } continue; } if is_markdown_file(&entry.file_name) { let markdown = fs::read_to_string(&entry.path).unwrap_or_default(); let parsed = parse_markdown_page(&markdown, &entry.file_name); let page_id = local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata); directory_rows.push(LocalFolderRow { node_id: page_id.clone(), row_id: format!("local:page:{}", entry.relative_path), parent_node_id: parent_node_id.clone(), title: parsed.title, depth, position: position as u32, row_kind: "document".to_string(), icon_hint: "markdown".to_string(), relative_path: entry.relative_path.clone(), source_uri: file_uri_for_path(&entry.path), child_count: 0, expandable: false, expanded_by_default: false, document_id: Some(page_id), capabilities: local_entry_capabilities(&entry), }); contains_markdown = true; } } rows.extend(directory_rows); Ok(contains_markdown) } fn find_markdown_by_page_id( root: &Path, metadata: &LocalFolderMetadata, document_id: &str, ) -> Result, WebError> { fn walk( root: &Path, directory: &Path, metadata: &LocalFolderMetadata, document_id: &str, ) -> Result, WebError> { for entry in read_sorted_entries(directory, root)? { if entry.is_dir && !entry.is_symlink { if let Some(found) = walk(root, &entry.path, metadata, document_id)? { return Ok(Some(found)); } continue; } if !is_markdown_file(&entry.file_name) { continue; } let markdown = fs::read_to_string(&entry.path).unwrap_or_default(); let parsed = parse_markdown_page(&markdown, &entry.file_name); let page_id = local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata); if page_id == document_id { return Ok(Some(entry)); } } Ok(None) } walk(root, root, metadata, document_id) } fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { let mut item = json!({ "rowId": row.row_id, "rowKind": row.row_kind, "nodeId": row.node_id, "parentNodeId": row.parent_node_id, "title": row.title, "depth": row.depth, "position": row.position, "childCount": row.child_count, "expandable": row.expandable, "expandedByDefault": row.expanded_by_default, "capabilities": row.capabilities.clone(), "iconHint": row.icon_hint, "resourceMeta": { "resourceKind": row.row_kind, "iconHint": row.icon_hint, "extra": { "source": { "sourceKind": "local_folder", "sourceUri": row.source_uri, "relativePath": row.relative_path, "storageIdentity": { "kind": "file_path", "path": row.relative_path, }, "operationProfile": "local_readonly", } } } }); if let Some(document_id) = row.document_id.as_ref() { item["resourceMeta"]["documentId"] = Value::String(document_id.clone()); } item } fn local_node_id(relative_path: &str) -> String { format!("local:node:{relative_path}") } fn local_directory_group_id(relative_path: &str) -> String { format!("local-dir:{}", encode_local_id_segment(relative_path)) } fn local_markdown_page_id( relative_path: &str, mnote_id: Option<&str>, metadata: &LocalFolderMetadata, ) -> String { if let Some(mnote_id) = mnote_id.map(str::trim).filter(|value| !value.is_empty()) { if mnote_id.starts_with("local-mdid:") || mnote_id.starts_with("local-md:") { return mnote_id.to_string(); } return format!("local-mdid:{}", encode_local_id_segment(mnote_id)); } if let Some(page_id) = metadata .page_ids .get(relative_path) .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return normalize_metadata_page_id(page_id); } format!("local-md:{}", encode_local_id_segment(relative_path)) } fn normalize_metadata_page_id(page_id: &str) -> String { let trimmed = page_id.trim(); if trimmed.starts_with("local-md:") || trimmed.starts_with("local-mdid:") { trimmed.to_string() } else { format!("local-mdid:{}", encode_local_id_segment(trimmed)) } } fn page_options_from_metadata(value: &Value) -> PageOptions { let mut options = PageOptions::default(); if let Some(wide_layout) = value .get("wideLayout") .or_else(|| value.get("wide_layout")) .and_then(Value::as_bool) { options.wide_layout = wide_layout; } if let Some(small_text) = value .get("smallText") .or_else(|| value.get("small_text")) .and_then(Value::as_bool) { options.small_text = small_text; } if let Some(layout_density) = value .get("layoutDensity") .or_else(|| value.get("layout_density")) .and_then(Value::as_str) .map(str::trim) .filter(|item| !item.is_empty()) { options.layout_density = layout_density.to_string(); } if let Some(show_toc) = value .get("showToc") .or_else(|| value.get("show_toc")) .and_then(Value::as_bool) { options.show_toc = show_toc; } if let Some(show_structure) = value .get("showStructure") .or_else(|| value.get("show_structure")) .and_then(Value::as_bool) { options.show_structure = show_structure; } if let Some(show_heading_numbers) = value .get("showHeadingNumbers") .or_else(|| value.get("show_heading_numbers")) .and_then(Value::as_bool) { options.show_heading_numbers = show_heading_numbers; } if let Some(protect_editing) = value .get("protectEditing") .or_else(|| value.get("protect_editing")) .and_then(Value::as_bool) { options.protect_editing = protect_editing; } options } fn local_workspace_id(root: &Path) -> String { format!( "local:{}", root.to_string_lossy() .chars() .map(|character| match character { '/' | '\\' | ':' | ' ' => '_', value if value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | '.') => value, _ => '_', }) .collect::() ) } fn local_folder_watch_revision_for_root( root: &Path, root_source_uri: &str, ) -> Result { let mut hasher = DefaultHasher::new(); let mut entry_count = 0usize; let mut latest_modified_ms = 0u128; fn visit( root: &Path, directory: &Path, hasher: &mut DefaultHasher, entry_count: &mut usize, latest_modified_ms: &mut u128, ) -> Result<(), WebError> { for entry in read_sorted_entries(directory, root)? { entry.relative_path.hash(hasher); entry.file_name.hash(hasher); entry.is_dir.hash(hasher); entry.is_symlink.hash(hasher); entry.is_readonly.hash(hasher); if let Ok(meta) = fs::symlink_metadata(&entry.path) { meta.len().hash(hasher); if let Ok(modified) = meta.modified() { if let Ok(delta) = modified.duration_since(UNIX_EPOCH) { let modified_ms = delta.as_millis(); modified_ms.hash(hasher); if modified_ms > *latest_modified_ms { *latest_modified_ms = modified_ms; } } } } *entry_count += 1; if entry.is_dir && !entry.is_symlink { visit(root, &entry.path, hasher, entry_count, latest_modified_ms)?; } } Ok(()) } visit( root, root, &mut hasher, &mut entry_count, &mut latest_modified_ms, )?; Ok(LocalFolderWatchRevision { root_uri: root_source_uri.to_string(), revision: format!("{:016x}", hasher.finish()), entry_count, latest_modified_ms, }) } fn file_uri_for_path(path: &Path) -> String { format!("file://{}", path.display()) } fn is_markdown_file(file_name: &str) -> bool { file_name.to_ascii_lowercase().ends_with(".md") } fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String { if entry.is_dir { return "folder".to_string(); } if entry.is_symlink { return "symlink".to_string(); } let lower = entry.file_name.to_ascii_lowercase(); if lower.ends_with(".md") || lower.ends_with(".markdown") { "markdown" } else if matches!( extension(&lower).as_deref(), Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp") ) { "image" } else if matches!( extension(&lower).as_deref(), Some("mp4" | "webm" | "mov" | "mkv") ) { "video" } else if matches!( extension(&lower).as_deref(), Some("mp3" | "wav" | "flac" | "ogg") ) { "audio" } else if lower.ends_with(".pdf") { "pdf" } else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) { "book" } else if matches!( extension(&lower).as_deref(), Some("csv" | "tsv" | "xlsx" | "xls") ) { "table" } else { "unknown" } .to_string() } fn extension(file_name: &str) -> Option { Path::new(file_name) .extension() .and_then(|extension| extension.to_str()) .map(ToOwned::to_owned) } fn encode_local_id_segment(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.as_bytes() { let character = *byte as char; if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { encoded.push(character); } else { encoded.push('~'); encoded.push_str(&format!("{byte:02X}")); } } encoded } pub(crate) fn decode_local_id_segment(value: &str) -> Result { let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { if bytes[index] == b'~' { if index + 2 >= bytes.len() { return Err(WebError::bad_request_code( "local_id_invalid", "本地 page id 编码不完整", )); } let hex = &value[index + 1..index + 3]; let byte = u8::from_str_radix(hex, 16).map_err(|_| { WebError::bad_request_code("local_id_invalid", "本地 page id 编码非法") })?; decoded.push(byte); index += 3; } else { decoded.push(bytes[index]); index += 1; } } String::from_utf8(decoded) .map_err(|_| WebError::bad_request_code("local_id_invalid", "本地 page id 不是 UTF-8")) } fn rewrite_page_ids_prefix( page_ids: &mut BTreeMap, old_relative_path: &str, new_relative_path: &str, ) { let old_prefix = format!("{old_relative_path}/"); let replacements = page_ids .iter() .filter_map(|(relative_path, page_id)| { if relative_path == old_relative_path { Some(( relative_path.clone(), new_relative_path.to_string(), page_id.clone(), )) } else if let Some(rest) = relative_path.strip_prefix(&old_prefix) { Some(( relative_path.clone(), format!("{new_relative_path}/{rest}"), page_id.clone(), )) } else { None } }) .collect::>(); for (old_key, new_key, page_id) in replacements { page_ids.remove(&old_key); page_ids.insert(new_key, page_id); } } fn sanitize_file_stem(value: &str, fallback: &str) -> String { let sanitized = value .trim() .chars() .map(|character| { if matches!( character, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' ) { '_' } else { character } }) .collect::(); let trimmed = sanitized.trim().trim_matches('.').trim(); if trimmed.is_empty() { fallback.to_string() } else { trimmed.to_string() } } fn sanitize_file_name(value: &str, fallback: &str) -> String { let sanitized = value .trim() .chars() .map(|character| { if matches!( character, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' ) { '_' } else { character } }) .collect::(); let trimmed = sanitized.trim().trim_matches('.').trim(); if trimmed.is_empty() { fallback.to_string() } else { trimmed.to_string() } } fn next_available_path(directory: &Path, stem: &str, extension: &str) -> PathBuf { let first = directory.join(format!("{stem}.{extension}")); if !first.exists() { return first; } for index in 2..10_000 { let candidate = directory.join(format!("{stem} {index}.{extension}")); if !candidate.exists() { return candidate; } } directory.join(format!("{stem}-{}.{}", std::process::id(), extension)) } fn next_available_raw_path(directory: &Path, file_name: &str) -> PathBuf { let first = directory.join(file_name); if !first.exists() { return first; } let path = Path::new(file_name); let stem = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or(file_name); let extension = path.extension().and_then(|value| value.to_str()); for index in 2..10_000 { let candidate_name = if let Some(extension) = extension { format!("{stem} {index}.{extension}") } else { format!("{stem} {index}") }; let candidate = directory.join(candidate_name); if !candidate.exists() { return candidate; } } directory.join(format!("{stem}-{}", std::process::id())) } fn next_available_directory_path(directory: &Path, stem: &str) -> PathBuf { let first = directory.join(stem); if !first.exists() { return first; } for index in 2..10_000 { let candidate = directory.join(format!("{stem} {index}")); if !candidate.exists() { return candidate; } } directory.join(format!("{stem}-{}", std::process::id())) } fn markdown_with_frontmatter_fields(markdown: &str, fields: &[(&str, &str)]) -> String { let (frontmatter, body) = split_frontmatter(markdown); let mut lines = frontmatter .unwrap_or_default() .lines() .map(ToOwned::to_owned) .collect::>(); for (key, value) in fields { let replacement = format!("{key}: {}", value.trim()); if let Some(existing) = lines.iter_mut().find(|line| { line.split_once(':') .map(|(candidate, _)| candidate.trim() == *key) .unwrap_or(false) }) { *existing = replacement; } else { lines.push(replacement); } } format!("---\n{}\n---\n{}", lines.join("\n"), body) } fn editor_blocks_to_markdown(content: &Value) -> String { let blocks = if let Some(array) = content.as_array() { array.clone() } else { content .get("blocks") .and_then(Value::as_array) .cloned() .unwrap_or_default() }; let mut lines = Vec::new(); for block in blocks { let block_type = block .get("type") .or_else(|| block.get("blockType")) .and_then(Value::as_str) .unwrap_or("paragraph"); let inline_markdown = block_content_value(&block) .map(inline_nodes_to_markdown) .unwrap_or_default() .trim() .to_string(); let text = if inline_markdown.is_empty() { extract_block_text(&block).trim().to_string() } else { inline_markdown }; if text.is_empty() && !matches!(block_type, "divider" | "media" | "table") { lines.push(String::new()); continue; } match block_type { "media" => { let props = block.get("props").and_then(Value::as_object); let url = props .and_then(|props| { props .get("sourcePath") .or_else(|| props.get("url")) .or_else(|| props.get("src")) }) .and_then(Value::as_str) .unwrap_or("") .trim(); let name = props .and_then(|props| props.get("name").or_else(|| props.get("fileName"))) .and_then(Value::as_str) .unwrap_or(text.as_str()) .trim(); if url.is_empty() { lines.push(text); } else { lines.push(format!( "[{}]({url})", if name.is_empty() { url } else { name } )); } } "heading" => { let level = block .get("props") .and_then(|props| props.get("level")) .and_then(Value::as_u64) .unwrap_or(1) .clamp(1, 6); lines.push(format!("{} {text}", "#".repeat(level as usize))); } "bulletListItem" | "bullet_list_item" | "list_item" => { lines.push(format!("- {text}")); } "numberedListItem" | "numbered_list_item" => { lines.push(format!("1. {text}")); } "todo" | "checkListItem" | "advancedTodo" => { let checked = block .get("props") .and_then(|props| props.get("checked")) .and_then(Value::as_bool) .unwrap_or(false); let checkbox = if checked { "[x]" } else { "[ ]" }; lines.push(format!("- {checkbox} {text}")); } "quote" | "blockquote" => { lines.push(format!("> {text}")); } "codeBlock" | "code_block" | "code" => { lines.push(format!("```\n{text}\n```")); } "divider" => { lines.push("---".to_string()); } "table" => { let table_markdown = editor_block_table_to_markdown(&block); if table_markdown.is_empty() { lines.push(text); } else { lines.extend(table_markdown.lines().map(ToOwned::to_owned)); } } _ => lines.push(text), } lines.push(String::new()); } let markdown = lines.join("\n").trim_end().to_string(); if markdown.is_empty() { "\n".to_string() } else { format!("{markdown}\n") } } fn editor_block_table_to_markdown(block: &Value) -> String { let table = block .get("props") .and_then(|props| props.get("tiptapTable")) .or_else(|| block.get("tiptapTable")); let rows = table .and_then(|value| value.get("content")) .and_then(Value::as_array); let Some(rows) = rows else { return String::new(); }; let mut parsed_rows = Vec::>::new(); let mut max_columns = 0usize; for row in rows { let cells = row .get("content") .and_then(Value::as_array) .map(|cells| { cells .iter() .map(|cell| { let cell_text = cell .get("content") .map(inline_nodes_to_markdown) .unwrap_or_default() .replace('\n', " ") .replace('|', r"\|") .trim() .to_string(); let is_header = cell.get("type").and_then(Value::as_str) == Some("tableHeader"); (cell_text, is_header) }) .collect::>() }) .unwrap_or_default(); max_columns = max_columns.max(cells.len()); parsed_rows.push(cells); } if parsed_rows.is_empty() || max_columns == 0 { return String::new(); } let alignments = alignments_from_table(block); let mut lines = Vec::::new(); for (index, row) in parsed_rows.iter().enumerate() { let mut cells = row.iter().map(|(text, _)| text.clone()).collect::>(); while cells.len() < max_columns { cells.push(String::new()); } lines.push(format!("| {} |", cells.join(" | "))); if index == 0 { let align_row = (0..max_columns) .map( |column_index| match table_cell_alignment(&alignments, column_index) { "left" => ":---", "center" => ":---:", "right" => "---:", _ => "---", }, ) .collect::>() .join(" | "); lines.push(format!("| {} |", align_row)); } } if lines.len() < 2 { String::new() } else { lines.join("\n") } } fn alignments_from_table(block: &Value) -> Vec { block .get("props") .and_then(|props| props.get("tiptapTable")) .or_else(|| block.get("tiptapTable")) .and_then(|value| value.get("content")) .and_then(Value::as_array) .and_then(|rows| rows.get(0)) .and_then(|row| row.get("content")) .and_then(Value::as_array) .map(|cells| { cells .iter() .map(|cell| { cell.get("attrs") .and_then(|attrs| attrs.get("textAlign")) .and_then(Value::as_str) .unwrap_or("") .to_string() }) .collect::>() }) .unwrap_or_default() } fn table_cell_alignment(alignments: &[String], index: usize) -> &str { alignments.get(index).map(String::as_str).unwrap_or("") } fn extract_block_text(value: &Value) -> String { if let Some(text) = value.as_str() { return text.to_string(); } if let Some(array) = value.as_array() { return array .iter() .map(extract_block_text) .collect::>() .join(""); } if let Some(object) = value.as_object() { if let Some(text) = object.get("text").and_then(Value::as_str) { return text.to_string(); } if let Some(content) = object.get("content") { return extract_block_text(content); } if let Some(children) = object.get("children") { return extract_block_text(children); } } String::new() } fn block_content_value(block: &Value) -> Option<&Value> { block.get("content").or_else(|| block.get("contentNodes")) } fn inline_nodes_to_markdown(value: &Value) -> String { if let Some(text) = value.as_str() { return escape_markdown_inline_text(text); } if let Some(array) = value.as_array() { return array .iter() .map(inline_node_to_markdown) .collect::>() .join(""); } if let Some(object) = value.as_object() { if let Some(text) = object.get("text").and_then(Value::as_str) { return markdown_text_with_styles(text, &inline_styles_from_object(object)); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { return inline_nodes_to_markdown(content); } } String::new() } fn inline_node_to_markdown(node: &Value) -> String { if let Some(text) = node.as_str() { return escape_markdown_inline_text(text); } if let Some(object) = node.as_object() { let text = object .get("text") .and_then(Value::as_str) .unwrap_or_default(); if !text.is_empty() { return markdown_text_with_styles(text, &inline_styles_from_object(object)); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { return inline_nodes_to_markdown(content); } } String::new() } fn inline_styles_from_object(object: &Map) -> Value { let mut styles = object .get("styles") .and_then(Value::as_object) .cloned() .unwrap_or_default(); for mark in object .get("marks") .and_then(Value::as_array) .into_iter() .flatten() { let mark_type = mark.get("type").and_then(Value::as_str).unwrap_or_default(); match mark_type { "bold" | "strong" => { styles.insert("bold".to_string(), Value::Bool(true)); } "italic" | "em" => { styles.insert("italic".to_string(), Value::Bool(true)); } "underline" => { styles.insert("underline".to_string(), Value::Bool(true)); } "strike" | "strikethrough" => { styles.insert("strike".to_string(), Value::Bool(true)); } "code" => { styles.insert("code".to_string(), Value::Bool(true)); } "link" => { if let Some(href) = mark .get("attrs") .and_then(|attrs| attrs.get("href")) .and_then(Value::as_str) .map(str::trim) .filter(|href| !href.is_empty()) { styles.insert("link".to_string(), Value::String(href.to_string())); } } _ => {} } } Value::Object(styles) } fn markdown_text_with_styles(text: &str, styles: &Value) -> String { let mut value = escape_markdown_inline_text(text); let link = styles .get("link") .or_else(|| styles.get("href")) .and_then(Value::as_str) .map(str::trim) .filter(|href| !href.is_empty()) .map(ToOwned::to_owned); if styles .get("code") .or_else(|| styles.get("inlineCode")) .and_then(Value::as_bool) .unwrap_or(false) { value = format!("`{}`", text.replace('`', r"\`")); } if styles.get("bold").and_then(Value::as_bool).unwrap_or(false) { value = format!("**{value}**"); } if styles .get("italic") .and_then(Value::as_bool) .unwrap_or(false) { value = format!("*{value}*"); } if styles .get("strike") .or_else(|| styles.get("strikethrough")) .and_then(Value::as_bool) .unwrap_or(false) { value = format!("~~{value}~~"); } if let Some(href) = link { value = format!("[{}]({href})", value.replace(']', r"\]")); } value } fn escape_markdown_inline_text(text: &str) -> String { text.replace('\\', r"\\") } fn now_ms() -> u128 { system_time_ms(SystemTime::now()) } fn system_time_ms(time: SystemTime) -> u128 { time.duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or(0) } fn local_markdown_conflict_detection_key( document_id: &str, markdown_path: &Path, ) -> Result { let content = fs::read(markdown_path).map_err(|error| { WebError::bad_request_code( "local_markdown_read_failed", format!( "无法读取本地 Markdown 文件 {}: {error}", markdown_path.display() ), ) })?; let meta = fs::metadata(markdown_path).map_err(|error| { WebError::bad_request_code( "local_markdown_stat_failed", format!( "无法读取本地 Markdown 文件状态 {}: {error}", markdown_path.display() ), ) })?; let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH)); let mut hasher = DefaultHasher::new(); content.hash(&mut hasher); let content_hash = hasher.finish(); Ok(format!( "local-md:{document_id}:{modified_ms}:{}:{content_hash:016x}", meta.len() )) } fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value { let outline = content .as_array() .unwrap_or(&Vec::new()) .iter() .filter(|block| block.get("type").and_then(Value::as_str) == Some("heading")) .enumerate() .map(|(index, block)| { json!({ "id": format!("outline-{index}"), "nodeId": document_id, "blockId": block.get("id").and_then(Value::as_str).unwrap_or_default(), "title": block .get("content") .and_then(Value::as_array) .and_then(|items| items.first()) .and_then(|item| item.get("text")) .and_then(Value::as_str) .unwrap_or(title), "depth": block .get("props") .and_then(|props| props.get("level")) .and_then(Value::as_u64) .unwrap_or(1), }) }) .collect::>(); json!({ "projectionId": format!("page-subtree:{document_id}"), "projection": "page_tree", "rootNodeId": document_id, "rootNode": { "id": document_id, "title": title, "nodeType": "page", "depth": 0, }, "subtree": { "rootNodeId": document_id, "nodes": [{ "id": document_id, "title": title, "nodeType": "page", "depth": 0, }], }, "outline": outline, "evidence": [{ "id": format!("evidence:{document_id}"), "nodeId": document_id, "kind": "page", "snippet": title, }], "stats": { "blockCount": content.as_array().map(|blocks| blocks.len()).unwrap_or(0), "headingCount": outline.len(), "evidenceCount": 1, "maxDepth": 1, }, }) } #[cfg(test)] mod tests { use super::{ initialize_local_page_id, load_local_folder_page_tree_snapshot, local_folder_watch_revision, resolve_local_markdown_page_aggregate, save_local_markdown_page, }; use serde_json::Value; fn temp_root(name: &str) -> std::path::PathBuf { let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("create temp root"); root } #[test] fn local_frontmatter_mnote_id_survives_rename_and_move() { let root = temp_root("mnote-local-frontmatter-id"); std::fs::write( root.join("page.md"), "---\nmnote_id: stable-frontmatter-id\ntitle: Stable\n---\n正文\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot"); assert!(first .projection .to_string() .contains("local-mdid:stable-frontmatter-id")); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md")) .expect("move md"); let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot"); let second_json = second.projection.to_string(); assert!(second_json.contains("local-mdid:stable-frontmatter-id")); assert!(second_json.contains("renamed.md")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_page_ids_metadata_overrides_path_derived_id_and_hides_mnote() { let root = temp_root("mnote-local-page-ids"); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("page.md"), "# Metadata Page\n").expect("write md"); std::fs::write( root.join(".mnote").join("page-ids.json"), r#"{"version":1,"pages":{"docs/page.md":"stable-from-page-ids"}}"#, ) .expect("write page ids"); let root_uri = format!("file://{}", root.display()); let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("snapshot"); let html_json = snapshot.projection.to_string(); assert!(html_json.contains("local-mdid:stable-from-page-ids")); assert!(!html_json.contains("page-ids.json")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_page_id_initialization_writes_page_ids_atomically() { let root = temp_root("mnote-local-page-id-init"); std::fs::write(root.join("draft.md"), "# Draft\n").expect("write md"); let root_uri = format!("file://{}", root.display()); let page_id = initialize_local_page_id(&root_uri, "draft.md").expect("init page id"); assert!(page_id.starts_with("local-mdid:")); let metadata = std::fs::read_to_string(root.join(".mnote").join("page-ids.json")) .expect("read page ids"); let parsed: Value = serde_json::from_str(&metadata).expect("json"); assert_eq!(parsed["pages"]["draft.md"], page_id); assert!(!root.join(".mnote").join("page-ids.json.tmp").exists()); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_metadata_damage_returns_explainable_error() { let root = temp_root("mnote-local-bad-metadata"); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); std::fs::write(root.join("README.md"), "# Broken Metadata\n").expect("write md"); std::fs::write(root.join(".mnote").join("page-ids.json"), "{not-json") .expect("write bad metadata"); let root_uri = format!("file://{}", root.display()); let error = load_local_folder_page_tree_snapshot(&root_uri).expect_err("metadata error"); assert!(error.message().contains("元数据 JSON 损坏")); assert!(error.message().contains("请修复或移走")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_page_options_metadata_flows_into_page_aggregate() { let root = temp_root("mnote-local-page-options"); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); std::fs::write(root.join("README.md"), "# Local Options\n").expect("write md"); std::fs::write( root.join(".mnote").join("page-options.json"), r#"{"version":1,"pages":{"local-md:README.md":{"wideLayout":true,"showToc":true,"showHeadingNumbers":true}}}"#, ) .expect("write options"); let root_uri = format!("file://{}", root.display()); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") .expect("aggregate"); assert!(aggregate.layout.page_options.wide_layout); assert!(aggregate.layout.page_options.show_toc); assert!(aggregate.layout.page_options.show_heading_numbers); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_parser_preserves_pipe_table_as_table_block() { let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( "| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n", ); let array = blocks.as_array().expect("blocks"); let table = array .iter() .find(|block| block["type"].as_str() == Some("table")) .expect("pipe table 应保留为 table block"); assert_eq!(table["props"]["tiptapTable"]["type"], "table"); assert_eq!( table["props"]["tiptapTable"]["content"][0]["content"][0]["type"], "tableHeader" ); assert_eq!( table["props"]["tiptapTable"]["content"][1]["content"][0]["type"], "tableCell" ); assert_eq!( table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0] ["text"], "左" ); assert_eq!( table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] ["text"], "A" ); assert_eq!( table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] ["marks"][0]["type"], "code" ); assert_eq!( table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] ["text"], "B" ); assert_eq!( table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] ["marks"][0]["type"], "bold" ); } #[test] fn local_markdown_parser_preserves_task_list_and_inline_marks() { let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( "- [x] `3000` 默认由 **mnote-web** 监听。\n- [ ] *待办* ~~删除~~ [链接](https://example.com)\n", ); let array = blocks.as_array().expect("blocks"); assert_eq!(array[0]["type"], "todo"); assert_eq!(array[0]["props"]["checked"], true); assert_eq!(array[1]["type"], "todo"); assert_eq!(array[1]["props"]["checked"], false); let first_content = array[0]["content"].as_array().expect("first content"); assert!(first_content.iter().any(|node| { node["text"].as_str() == Some("3000") && node["styles"]["code"].as_bool() == Some(true) })); assert!(first_content.iter().any(|node| { node["text"].as_str() == Some("mnote-web") && node["styles"]["bold"].as_bool() == Some(true) })); let second_content = array[1]["content"].as_array().expect("second content"); assert!(second_content.iter().any(|node| { node["text"].as_str() == Some("待办") && node["styles"]["italic"].as_bool() == Some(true) })); assert!(second_content.iter().any(|node| { node["text"].as_str() == Some("删除") && node["styles"]["strike"].as_bool() == Some(true) })); assert!(second_content.iter().any(|node| { node["text"].as_str() == Some("链接") && node["styles"]["link"].as_str() == Some("https://example.com") })); } #[test] fn local_markdown_aggregate_follows_file_permissions_for_editability() { let root = temp_root("mnote-local-readonly-permission"); std::fs::write(root.join("README.md"), "# Editable?\n").expect("write md"); let root_uri = format!("file://{}", root.display()); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") .expect("aggregate"); assert!(!aggregate.head.permissions.read_only); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_folder_watch_revision_changes_for_visible_files_only() { let root = temp_root("mnote-local-watch-revision"); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("page.md"), "# Watch\n").expect("write md"); let root_uri = format!("file://{}", root.display()); let initial = local_folder_watch_revision(&root_uri).expect("initial revision"); std::fs::write(root.join(".mnote").join("ignored.txt"), "ignored").expect("write ignored"); let ignored = local_folder_watch_revision(&root_uri).expect("ignored revision"); assert_eq!(initial.revision, ignored.revision); std::fs::write(root.join("docs").join("page.md"), "# Watch Again\n").expect("update md"); let updated = local_folder_watch_revision(&root_uri).expect("updated revision"); assert_ne!(initial.revision, updated.revision); assert!(updated.entry_count >= 1); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size() { let root = temp_root("mnote-local-conflict-key-content"); let file = root.join("README.md"); std::fs::write(&file, "aaaa\n").expect("write first content"); let first = super::local_markdown_conflict_detection_key("local-md:README.md", &file) .expect("first conflict key"); std::fs::write(&file, "bbbb\n").expect("write second content"); let second = super::local_markdown_conflict_detection_key("local-md:README.md", &file) .expect("second conflict key"); assert_ne!(first, second); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() { let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( r#"# Title paragraph ## Section - bullet 1. numbered > quote ```rust fn main() {} ``` --- [Spec](attachments/spec.pdf) | unsupported | table | "#, ); let block_types = blocks .as_array() .expect("blocks") .iter() .map(|block| block["type"].as_str().unwrap_or_default().to_string()) .collect::>(); assert!(block_types.contains(&"heading".to_string())); assert!(block_types.contains(&"paragraph".to_string())); assert!(block_types.contains(&"bulletListItem".to_string())); assert!(block_types.contains(&"numberedListItem".to_string())); assert!(block_types.contains(&"quote".to_string())); assert!(block_types.contains(&"codeBlock".to_string())); assert!(block_types.contains(&"divider".to_string())); assert!(block_types.contains(&"media".to_string())); assert!(blocks.to_string().contains("attachments/spec.pdf")); assert!(blocks.to_string().contains("unsupported")); } #[test] fn local_markdown_parser_preserves_attachment_media_block() { let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( "[Spec](attachments/spec.pdf)\n", ); let array = blocks.as_array().expect("blocks"); let media = array .iter() .find(|block| block["type"].as_str() == Some("media")) .expect("media block"); assert_eq!(media["props"]["name"], "Spec"); assert_eq!(media["props"]["sourcePath"], "attachments/spec.pdf"); } #[test] fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() { let root = temp_root("mnote-local-markdown-save-basic-blocks"); std::fs::write( root.join("README.md"), "---\ntitle: Preserved\nmnote_id: stable\n---\n# Old\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); save_local_markdown_page( &root_uri, "local-mdid:stable", None, &serde_json::json!([ {"type":"heading","props":{"level":2},"content":[{"type":"text","text":"Heading"}]}, {"type":"bulletListItem","content":[{"type":"text","text":"Bullet"}]}, {"type":"numberedListItem","content":[{"type":"text","text":"Numbered"}]}, {"type":"quote","content":[{"type":"text","text":"Quoted"}]}, {"type":"codeBlock","content":[{"type":"text","text":"let x = 1;"}]}, {"type":"divider"}, {"type":"media","props":{"name":"Spec","sourcePath":"attachments/spec.pdf"}}, {"type":"table","props":{"tiptapTable":{"type":"table","content":[ {"type":"tableRow","content":[ {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]}, {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]} ]}, {"type":"tableRow","content":[ {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]}, {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]} ]} ]}}} ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.starts_with("---\ntitle: Preserved\nmnote_id: stable\n---\n")); assert!(saved.contains("## Heading")); assert!(saved.contains("- Bullet")); assert!(saved.contains("1. Numbered")); assert!(saved.contains("> Quoted")); assert!(saved.contains("```\nlet x = 1;\n```")); assert!(saved.contains("[Spec](attachments/spec.pdf)")); assert!(saved.contains("| 左 | 右 |")); assert!(saved.contains("| --- | --- |")); assert!(saved.contains("| A | B |")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_writes_task_list_and_inline_marks() { let root = temp_root("mnote-local-markdown-save-inline-marks"); std::fs::write(root.join("README.md"), "---\ntitle: Inline\n---\n# Old\n") .expect("write md"); let root_uri = format!("file://{}", root.display()); save_local_markdown_page( &root_uri, "local-md:README.md", None, &serde_json::json!([ { "type":"todo", "props":{"checked":true}, "content":[ {"type":"text","text":"3000","styles":{"code":true}}, {"type":"text","text":" 默认由 "}, {"type":"text","text":"mnote-web","styles":{"bold":true}}, {"type":"text","text":" 监听"} ] }, { "type":"todo", "props":{"checked":false}, "content":[ {"type":"text","text":"待办","styles":{"italic":true}}, {"type":"text","text":" "}, {"type":"text","text":"删除","styles":{"strike":true}}, {"type":"text","text":" "}, {"type":"text","text":"链接","styles":{"link":"https://example.com"}} ] } ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("- [x] `3000` 默认由 **mnote-web** 监听")); assert!(saved.contains("- [ ] *待办* ~~删除~~ [链接](https://example.com)")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_rejects_stale_external_file_change() { let root = temp_root("mnote-local-markdown-save-stale-conflict"); std::fs::write(root.join("README.md"), "---\ntitle: Stale\n---\n# Old\n") .expect("write md"); let root_uri = format!("file://{}", root.display()); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") .expect("aggregate"); let stale_key = aggregate .body .conflict_detection_key .as_str() .expect("conflict key") .to_string(); std::thread::sleep(std::time::Duration::from_millis(5)); std::fs::write(root.join("README.md"), "---\ntitle: Stale\n---\n# External\n") .expect("external write"); let error = save_local_markdown_page( &root_uri, "local-md:README.md", Some(&stale_key), &serde_json::json!([ {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Editor"}]} ]), ) .expect_err("stale save should fail"); assert!(error .message() .contains("本地 Markdown 文件已被外部修改")); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("# External")); assert!(!saved.contains("# Editor")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_writes_table_inline_marks() { let root = temp_root("mnote-local-markdown-save-table-inline-marks"); std::fs::write(root.join("README.md"), "---\ntitle: Table\n---\n# Old\n") .expect("write md"); let root_uri = format!("file://{}", root.display()); save_local_markdown_page( &root_uri, "local-md:README.md", None, &serde_json::json!([ {"type":"table","props":{"tiptapTable":{"type":"table","content":[ {"type":"tableRow","content":[ {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]}, {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]} ]}, {"type":"tableRow","content":[ {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{"code":true}}]}]}, {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{"bold":true}}]}]} ]} ]}}} ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("| `A` | **B** |")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_round_trips_tiptap_table_marks() { let root = temp_root("mnote-local-markdown-save-table-marks-roundtrip"); std::fs::write( root.join("README.md"), "---\ntitle: Table Marks\n---\n# Old\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); let parsed = crate::routes::local_markdown_parser::markdown_to_blocks( "| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n", ); save_local_markdown_page(&root_uri, "local-md:README.md", None, &parsed).expect("save"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("| `A` | **B** |")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_writes_table_alignment_markers() { let root = temp_root("mnote-local-markdown-save-table-alignments"); std::fs::write( root.join("README.md"), "---\ntitle: Table Align\n---\n# Old\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); save_local_markdown_page( &root_uri, "local-md:README.md", None, &serde_json::json!([ {"type":"table","props":{"tiptapTable":{"type":"table","content":[ {"type":"tableRow","content":[ {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]}, {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"中","styles":{}}]}]}, {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]} ]}, {"type":"tableRow","content":[ {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]}, {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]}, {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]} ]} ]}}} ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("| :--- | :---: | ---: |")); let _ = std::fs::remove_dir_all(&root); } }