use crate::app::AppState; use crate::context::RequestContext; 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::local_search_index; use crate::routes::snapshot_support::ProjectionSnapshot; use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::Json; use bridge_runtime::project_legacy_content_to_block_document; use control_plane::AppendAuditInput; use control_plane::{ CreateShareLinkInput, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput, ShareLinkRecord, }; use core_protocol::{ KernelObjectIdentity, KernelObjectKind, ObjectWorkspacePath, WorkspaceSourceKind, }; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::cmp::Ordering; use std::collections::hash_map::DefaultHasher; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use time::OffsetDateTime; #[cfg(test)] static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); #[cfg(test)] static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); #[derive(Debug, Clone)] struct LocalPageTreeSnapshotCacheEntry { watch_revision: LocalFolderWatchRevision, snapshot: ProjectionSnapshot, } static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock< Mutex>, > = OnceLock::new(); fn local_page_tree_snapshot_cache( ) -> &'static Mutex> { LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) } #[cfg(test)] pub(crate) fn reset_local_page_tree_snapshot_test_loads() { LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst); LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst); if let Some(cache) = LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get() { if let Ok(mut cache) = cache.lock() { cache.clear(); } } } #[cfg(test)] pub(crate) fn local_page_tree_snapshot_test_loads() -> u64 { LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst) } #[cfg(test)] fn local_page_tree_snapshot_scan_test_loads() -> u64 { LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst) } #[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_options: BTreeMap, trash_entries: BTreeMap, uploaded_assets: BTreeMap, file_order: BTreeMap>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct AffectedParent { relative_path: String, reason: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct RevealTarget { relative_path: String, row_id: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct LocalFileOperationResource { document_id: Option, relative_path: String, object_identity: KernelObjectIdentity, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct LocalFileOperationResult { schema: String, operation_id: String, #[serde(skip_serializing_if = "Option::is_none")] batch_id: Option, action: String, root_uri: String, #[serde(skip_serializing_if = "Option::is_none")] resource: Option, #[serde(skip_serializing_if = "Option::is_none")] previous_resource: Option, affected_parents: Vec, #[serde(skip_serializing_if = "Option::is_none")] reveal_target: Option, #[serde(skip_serializing_if = "Option::is_none")] select_target: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalWorkspaceManifest { #[serde(default, alias = "workspace_id")] workspace_id: String, #[serde(default, alias = "owner_id")] owner_id: String, #[serde(default, alias = "created_at")] created_at: String, #[serde(default, alias = "capabilities")] capabilities: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalAccessPolicy { #[serde(default)] admins: Vec, #[serde(default)] grants: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalAccessGrant { #[serde(default)] id: String, #[serde(default, alias = "user_id")] user_id: String, #[serde(default, alias = "root_uri")] root_uri: String, #[serde(default, alias = "root_path")] root_path: String, #[serde(default, alias = "access")] permission: String, #[serde(default = "default_true")] recursive: bool, #[serde(default)] capabilities: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalShareGrantsStore { #[serde(default)] grants: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalShareGrant { #[serde(default)] id: String, #[serde(default, alias = "share_id")] share_id: String, #[serde(default, alias = "owner_user_id")] owner_user_id: String, #[serde(default, alias = "target_user_id")] target_user_id: String, #[serde(default, alias = "root_uri")] root_uri: String, #[serde(default, alias = "root_path")] root_path: String, #[serde(default, alias = "documentId")] document_id: String, #[serde(default, alias = "allowedResourceIds")] allowed_resource_ids: Vec, #[serde(default, alias = "access")] permission: String, #[serde(default)] capabilities: Vec, #[serde(default, alias = "createdAt")] created_at: String, #[serde(default, alias = "revokedAt")] revoked_at: Option, #[serde(default = "default_true")] active: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LocalAccessMode { Read, Write, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct LocalTrashEntry { #[serde(default, alias = "documentId")] pub(crate) document_id: String, #[serde(default, alias = "resourceKind")] pub(crate) resource_kind: String, #[serde(default, alias = "resourceScope")] pub(crate) resource_scope: String, #[serde(default, alias = "originalFilePath")] pub(crate) original_file_path: String, #[serde(default, alias = "trashedFilePath")] pub(crate) trashed_file_path: String, #[serde(default, alias = "trashEntryId")] pub(crate) trash_entry_id: String, #[serde(default, alias = "originalRelativePath")] pub(crate) original_relative_path: String, #[serde(default, alias = "trashRelativePath")] pub(crate) trash_relative_path: String, #[serde(default, alias = "deletedAtMs")] pub(crate) deleted_at_ms: u128, #[serde(default, alias = "archivedAt")] pub(crate) archived_at: u128, #[serde(default, alias = "purgedAt")] pub(crate) purged_at: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalUploadedAssetEntry { #[serde(default, alias = "documentId")] document_id: String, #[serde(default, alias = "relativePath")] relative_path: String, #[serde(default, alias = "fileName")] file_name: String, #[serde(default, alias = "createdAtMs")] created_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, workspace_id: String, root_source_uri: String, } #[derive(Debug, Clone)] struct LocalFolderScanResult { rows: Vec, watch_revision: LocalFolderWatchRevision, } #[derive(Debug, Clone)] pub(crate) struct LocalUploadFile { name: String, content_type: String, bytes: Vec, } #[derive(Debug, Clone)] struct LocalAssetUploadFields { file: LocalUploadFile, root_uri: String, document_id: String, target_relative_path: Option, upload_intent: String, kind: String, } fn local_folder_ok_response( context: &RequestContext, result: Value, ) -> (StatusCode, HeaderMap, Json) { ( StatusCode::OK, HeaderMap::new(), Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "owner": "mnote-web", "result": result, })), ) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalFileOpenQuery { pub root_uri: String, pub path: String, #[serde(default)] pub download: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalResourceReadQuery { pub root_uri: String, pub path: String, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalResourceWriteRequest { #[serde(default, alias = "root_uri")] pub root_uri: String, #[serde(default)] pub path: String, #[serde(default, alias = "expected_file_version")] pub expected_file_version: Option, #[serde(default, alias = "content_format")] pub content_format: Option, #[serde(default)] pub content: Value, #[serde(default, alias = "tiptap_document")] pub tiptap_document: Option, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalAccessValidateRootRequest { #[serde(default, alias = "root_uri")] pub root_uri: String, #[serde(default, alias = "root_path")] pub root_path: String, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalAccessGrantRequest { // 兼容旧 share/access grant payload;SQLite 目录授权创建时由控制面生成真实 grant id。 #[allow(dead_code)] #[serde(default)] pub id: String, #[serde(default, alias = "user_id")] pub user_id: String, #[serde(default, alias = "root_uri")] pub root_uri: String, #[serde(default, alias = "root_path")] pub root_path: String, #[serde(default, alias = "access")] pub permission: String, #[serde(default = "default_true")] pub recursive: bool, #[serde(default)] pub capabilities: Vec, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalShareGrantRequest { #[serde(default)] pub id: String, #[serde(default, alias = "share_id")] pub share_id: String, #[serde(default, alias = "owner_user_id")] pub owner_user_id: String, #[serde(default, alias = "target_user_id")] pub target_user_id: String, #[serde(default, alias = "root_uri")] pub root_uri: String, #[serde(default, alias = "root_path")] pub root_path: String, #[serde(default, alias = "documentId")] pub document_id: String, #[serde(default, alias = "allowedResourceIds")] pub allowed_resource_ids: Vec, #[serde(default, alias = "access")] pub permission: String, #[serde(default)] pub capabilities: Vec, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ShareLinkListQuery { #[serde(default, alias = "workspace_id")] pub workspace_id: String, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalShareLinkRequest { #[serde(default, alias = "workspace_id")] pub workspace_id: String, #[serde(default, alias = "resource_kind")] pub resource_kind: String, #[serde(default, alias = "resource_id")] pub resource_id: String, #[serde(default)] pub token: Option, #[serde(default, alias = "access")] pub permission: String, #[serde(default, alias = "expires_at")] pub expires_at: Option, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SharedCacheRecordRequest { pub root_uri: String, pub share_id: String, pub permission: String, #[serde(default)] pub remote_version: String, #[serde(default)] pub base_version: String, #[serde(default)] pub source_actor: String, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncPendingChangeRequest { pub root_uri: String, pub share_id: String, pub resource_id: String, #[serde(default)] pub local_version: String, #[serde(default)] pub base_version: String, #[serde(default)] pub remote_version: String, #[serde(default)] pub change_summary: String, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncConflictReportRequest { pub root_uri: String, pub share_id: String, pub resource_id: String, #[serde(default)] pub local_version: String, #[serde(default)] pub remote_version: String, #[serde(default)] pub base_version: String, #[serde(default)] pub actor_id: String, #[serde(default)] pub summary: String, } const DEFAULT_LOCAL_WORKSPACE_BASE_DIR: &str = "/mnt/Data1T/Mnote_data"; const DEFAULT_LOCAL_WORKSPACE_SLUG: &str = "my-space"; const ENV_LOCAL_ADMIN_USER_IDS: &str = "MNOTE_ADMIN_USER_IDS"; const ENV_LOCAL_ACCESS_POLICY_FILE: &str = "MNOTE_LOCAL_ACCESS_POLICY_FILE"; const ENV_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE"; fn default_true() -> bool { true } #[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(crate) fn ensure_local_workspace_access_for_actor( actor_id: &str, actor_type: &str, root_uri: &str, ) -> Result { ensure_local_workspace_access_for_actor_with_mode( actor_id, actor_type, root_uri, LocalAccessMode::Write, ) } pub(crate) fn ensure_local_workspace_read_access_for_actor( actor_id: &str, actor_type: &str, root_uri: &str, ) -> Result { ensure_local_workspace_access_for_actor_with_mode( actor_id, actor_type, root_uri, LocalAccessMode::Read, ) } fn ensure_local_workspace_access_for_actor_with_mode( actor_id: &str, actor_type: &str, root_uri: &str, mode: LocalAccessMode, ) -> Result { let actor_id = actor_id.trim(); let actor_type = actor_type.trim(); if actor_id.is_empty() || actor_id == "anonymous" || actor_type.is_empty() || actor_type == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_workspace_auth_required", "本地工作区访问需要先登录", )); } let policy = load_local_access_policy().unwrap_or_default(); 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 必须指向目录", )); } if is_local_admin(actor_id, actor_type, &policy) { return Ok(canonical_root); } if local_access_policy_allows(&policy, actor_id, &canonical_root, mode) { return Ok(canonical_root); } if let Ok(manifest) = load_local_workspace_manifest(&canonical_root) { if manifest.owner_id.trim() == actor_id { return Ok(canonical_root); } } Err(WebError::new( StatusCode::FORBIDDEN, "local_workspace_access_denied", "当前用户无权访问该本地工作区", )) } pub(crate) fn ensure_local_workspace_access_with_state( state: &AppState, context: &RequestContext, root_uri: &str, mode: LocalAccessMode, ) -> 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 actor_id = context.auth.actor_id.trim(); let sqlite_access = state .control_plane() .resolve_access(actor_id, &file_uri_for_path(&canonical_root)) .map_err(|error| WebError::internal(format!("SQLite 控制面授权查询失败: {error}")))?; if local_access_permission_allows(&sqlite_access.permission, mode) { return Ok(canonical_root); } ensure_local_workspace_access_for_actor_with_mode( &context.auth.actor_id, &context.auth.actor_type, root_uri, mode, ) } pub(crate) fn ensure_local_workspace_read_access_with_state( state: &AppState, context: &RequestContext, root_uri: &str, ) -> Result { ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Read) } pub(crate) fn ensure_local_workspace_write_access_with_state( state: &AppState, context: &RequestContext, root_uri: &str, ) -> Result { ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write) } pub(crate) fn ensure_local_path_read_access( context: &RequestContext, root_uri: &str, candidate_path: &str, ) -> Result { let canonical_root = ensure_local_workspace_read_access(context, root_uri)?; let candidate_path = candidate_path.trim(); if candidate_path.is_empty() { return Err(WebError::bad_request_code( "local_file_path_required", "本地文件读取缺少文件路径", )); } let raw_path = candidate_path .strip_prefix("file://") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(candidate_path)); let target_path = if raw_path.is_absolute() { raw_path } else { canonical_root.join(raw_path) }; let canonical_target = target_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_file_unavailable", format!("无法访问本地文件: {error}"), ) })?; if !canonical_target.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_file_root_escape", "本地文件读取不能越过授权目录", )); } if !canonical_target.is_file() { return Err(WebError::bad_request_code( "local_file_not_file", "本地文件读取目标必须是文件", )); } Ok(canonical_target) } pub(crate) fn ensure_local_workspace_read_access( context: &RequestContext, root_uri: &str, ) -> Result { ensure_local_workspace_read_access_for_actor( &context.auth.actor_id, &context.auth.actor_type, root_uri, ) } pub(crate) fn ensure_local_workspace_access( context: &RequestContext, root_uri: &str, ) -> Result { ensure_local_workspace_access_for_actor( &context.auth.actor_id, &context.auth.actor_type, root_uri, ) } fn local_access_policy_path() -> PathBuf { std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| { default_local_workspace_base_dir() .join("control-plane") .join("access-policy.json") }) } pub(crate) fn control_plane_db_path_display() -> String { std::env::var("MNOTE_CONTROL_PLANE_DB_PATH") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| { default_local_workspace_base_dir() .join("control-plane") .join("control-plane.db") .display() .to_string() }) } fn local_share_grants_path() -> PathBuf { std::env::var(ENV_SHARE_GRANTS_FILE) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| { default_local_workspace_base_dir() .join("control-plane") .join("share-grants.json") }) } fn load_local_access_policy() -> Result { let path = local_access_policy_path(); if !path.exists() { return Ok(LocalAccessPolicy::default()); } let content = fs::read_to_string(&path).map_err(|error| { WebError::bad_request_code( "local_access_policy_read_failed", format!("无法读取本地目录授权策略 {}: {error}", path.display()), ) })?; serde_json::from_str::(&content).map_err(|error| { WebError::bad_request_code( "local_access_policy_invalid", format!("本地目录授权策略格式非法: {error}"), ) }) } fn load_local_share_grants_store() -> Result { let path = local_share_grants_path(); if !path.exists() { return Ok(LocalShareGrantsStore::default()); } let content = fs::read_to_string(&path).map_err(|error| { WebError::bad_request_code( "local_share_grants_read_failed", format!("无法读取分享授权控制面 {}: {error}", path.display()), ) })?; serde_json::from_str::(&content).map_err(|error| { WebError::bad_request_code( "local_share_grants_invalid", format!("分享授权控制面格式非法: {error}"), ) }) } fn write_local_share_grants_store(store: &LocalShareGrantsStore) -> Result<(), WebError> { let path = local_share_grants_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { WebError::bad_request_code( "local_share_grants_dir_create_failed", format!("无法创建分享授权目录 {}: {error}", parent.display()), ) })?; } let content = serde_json::to_string_pretty(store) .map_err(|error| WebError::internal(format!("无法序列化分享授权控制面: {error}")))?; fs::write(&path, format!("{content}\n")).map_err(|error| { WebError::bad_request_code( "local_share_grants_write_failed", format!("无法写入分享授权控制面 {}: {error}", path.display()), ) }) } fn require_local_access_policy_admin( context: &RequestContext, ) -> Result { let policy = load_local_access_policy()?; let actor_id = context.auth.actor_id.trim(); let actor_type = context.auth.actor_type.trim(); if actor_id.is_empty() || actor_id == "anonymous" || actor_type.is_empty() || actor_type == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_access_policy_auth_required", "管理本地目录授权需要先登录", )); } if !is_local_admin(actor_id, actor_type, &policy) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_access_policy_admin_required", "只有管理员可以管理本地目录授权", )); } Ok(policy) } fn require_share_grants_admin(context: &RequestContext) -> Result<(), WebError> { let policy = load_local_access_policy()?; let actor_id = context.auth.actor_id.trim(); let actor_type = context.auth.actor_type.trim(); if actor_id.is_empty() || actor_id == "anonymous" || actor_type.is_empty() || actor_type == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_share_grants_auth_required", "管理分享授权需要先登录", )); } if !is_local_admin(actor_id, actor_type, &policy) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grants_admin_required", "只有管理员可以管理分享授权", )); } Ok(()) } pub(crate) fn is_local_access_policy_admin_context(context: &RequestContext) -> bool { let actor_id = context.auth.actor_id.trim(); let actor_type = context.auth.actor_type.trim(); is_local_access_policy_admin_actor(actor_id, actor_type) } pub(crate) fn is_local_access_policy_admin_actor(actor_id: &str, actor_type: &str) -> bool { if actor_id.is_empty() || actor_id == "anonymous" || actor_type.is_empty() || actor_type == "anonymous" { return false; } let policy = load_local_access_policy().unwrap_or_default(); is_local_admin(actor_id, actor_type, &policy) } fn env_local_admin_ids() -> Vec { std::env::var(ENV_LOCAL_ADMIN_USER_IDS) .ok() .map(|value| { value .split([',', ';', '\n', ' ']) .map(str::trim) .filter(|item| !item.is_empty()) .map(ToOwned::to_owned) .collect::>() }) .unwrap_or_default() } fn canonical_root_from_admin_request(root_uri: &str, root_path: &str) -> Result { let path = if !root_uri.trim().is_empty() { parse_file_root_uri(root_uri.trim())? } else if !root_path.trim().is_empty() { PathBuf::from(root_path.trim()) } else { return Err(WebError::bad_request_code( "local_access_policy_root_required", "必须提供 rootUri 或 rootPath", )); }; let canonical = path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_access_policy_root_unavailable", format!("无法访问授权目录: {error}"), ) })?; if !canonical.is_dir() { return Err(WebError::bad_request_code( "local_access_policy_root_not_directory", "授权目标必须是目录", )); } Ok(canonical) } fn normalize_local_access_permission(permission: &str) -> Result { let permission = permission.trim(); match permission { "read" | "write" => Ok(permission.to_string()), _ => Err(WebError::bad_request_code( "local_access_policy_permission_invalid", "授权 permission 只能是 read 或 write", )), } } fn normalize_local_access_capabilities(capabilities: &[String]) -> Result, WebError> { let mut normalized = Vec::new(); for capability in capabilities { let capability = capability.trim(); if capability.is_empty() { continue; } match capability { "ai" | "share" => { if !normalized.iter().any(|item| item == capability) { normalized.push(capability.to_string()); } } _ => { return Err(WebError::bad_request_code( "local_access_policy_capability_invalid", "授权 capability 只能是 ai 或 share", )); } } } normalized.sort(); Ok(normalized) } fn normalize_share_grant_permission(permission: &str) -> Result { let permission = permission.trim(); match permission { "read" | "write" => Ok(permission.to_string()), _ => Err(WebError::bad_request_code( "local_share_grant_permission_invalid", "分享授权 permission 只能是 read 或 write", )), } } fn normalize_share_grant_capabilities(capabilities: &[String]) -> Result, WebError> { let mut normalized = Vec::new(); for capability in capabilities { let capability = capability.trim(); if capability.is_empty() { continue; } match capability { "ai" | "share" => { if !normalized.iter().any(|item| item == capability) { normalized.push(capability.to_string()); } } _ => { return Err(WebError::bad_request_code( "local_share_grant_capability_invalid", "分享授权 capability 只能是 ai 或 share", )); } } } normalized.sort(); Ok(normalized) } fn generate_local_share_grant_id(share_id: &str, target_user_id: &str, root: &Path) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); let mut hasher = DefaultHasher::new(); share_id.hash(&mut hasher); target_user_id.hash(&mut hasher); root.display().to_string().hash(&mut hasher); format!("share_grant_{now}_{:x}", hasher.finish()) } fn generate_local_folder_share_id( owner_user_id: &str, target_user_id: &str, root: &Path, ) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); let mut hasher = DefaultHasher::new(); owner_user_id.hash(&mut hasher); target_user_id.hash(&mut hasher); root.display().to_string().hash(&mut hasher); format!("folder_{now}_{:x}", hasher.finish()) } fn ensure_path_inside_owned_local_workspace( actor_id: &str, canonical_path: &Path, ) -> Result<(), WebError> { let mut cursor = canonical_path; loop { if let Ok(manifest) = load_local_workspace_manifest(cursor) { if manifest.owner_id.trim() == actor_id.trim() { return Ok(()); } return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_owner_workspace_required", "普通用户只能授权自己空间下的文件夹", )); } cursor = match cursor.parent() { Some(parent) => parent, None => break, }; } Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_owner_workspace_required", "普通用户只能授权自己空间下的文件夹", )) } fn share_grant_payload(grant: &LocalShareGrant) -> Value { json!({ "id": grant.id, "shareId": grant.share_id, "ownerUserId": grant.owner_user_id, "targetUserId": grant.target_user_id, "rootUri": grant.root_uri, "rootPath": grant.root_path, "documentId": grant.document_id, "allowedResourceIds": grant.allowed_resource_ids, "permission": grant.permission, "capabilities": grant.capabilities, "createdAt": grant.created_at, "revokedAt": grant.revoked_at, "active": grant.active, }) } fn share_link_payload(link: &ShareLinkRecord) -> Value { json!({ "id": link.id, "workspaceId": link.workspace_id, "resourceKind": link.resource_kind, "resourceId": link.resource_id, "permission": link.permission, "createdBy": link.created_by, "expiresAt": link.expires_at, "revokedAt": link.revoked_at, "createdAt": link.created_at, "updatedAt": link.updated_at, "revision": link.revision, }) } fn append_control_plane_outbox_event(state: &AppState, event_type: &str, payload: Value) { let delta = json!({ "kind": "control_plane_event", "eventType": event_type, "payload": payload, }); let _ = state.control_plane().append_outbox(OutboxEventInput { topic: "control".to_string(), event_type: event_type.to_string(), payload_json: serde_json::to_string(&delta).unwrap_or_else(|_| "{}".to_string()), }); let _ = state.stream_delta_tx.send(delta); } fn list_sqlite_share_links_for_context( state: &AppState, context: &RequestContext, query: ShareLinkListQuery, ) -> Result { let _policy = require_local_access_policy_admin(context)?; let workspace_id = query.workspace_id.trim(); if workspace_id.is_empty() { return Err(WebError::bad_request_code( "share_link_workspace_required", "必须提供 workspaceId", )); } let links = state .control_plane() .list_share_links(workspace_id) .map_err(|error| WebError::internal(format!("SQLite 分享链接读取失败: {error}")))?; Ok(json!({ "ok": true, "controlPlane": "sqlite", "links": links.iter().map(share_link_payload).collect::>(), })) } fn create_sqlite_share_link_for_context( state: &AppState, context: &RequestContext, request: LocalShareLinkRequest, ) -> Result { let _policy = require_local_access_policy_admin(context)?; let workspace_id = request.workspace_id.trim(); let resource_kind = request.resource_kind.trim(); let resource_id = request.resource_id.trim(); if workspace_id.is_empty() || resource_kind.is_empty() || resource_id.is_empty() { return Err(WebError::bad_request_code( "share_link_resource_required", "必须提供 workspaceId、resourceKind 和 resourceId", )); } let permission = normalize_share_grant_permission(&request.permission)?; let created = state .control_plane() .create_share_link(CreateShareLinkInput { id: None, workspace_id: workspace_id.to_string(), resource_kind: resource_kind.to_string(), resource_id: resource_id.to_string(), token: request.token, permission, created_by: context.auth.actor_id.trim().to_string(), expires_at: request.expires_at, }) .map_err(|error| WebError::internal(format!("SQLite 分享链接写入失败: {error}")))?; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(context.auth.actor_id.trim().to_string()), action: "control.share.created".to_string(), target_kind: "share_link".to_string(), target_id: Some(created.link.id.clone()), metadata_json: serde_json::to_string(&json!({ "workspaceId": created.link.workspace_id, "resourceKind": created.link.resource_kind, "resourceId": created.link.resource_id, "permission": created.link.permission, })) .unwrap_or_else(|_| "{}".to_string()), }); append_control_plane_outbox_event( state, "control.share.created", json!({ "link": share_link_payload(&created.link), }), ); Ok(json!({ "ok": true, "controlPlane": "sqlite", "token": created.token, "link": share_link_payload(&created.link), })) } fn revoke_sqlite_share_link_for_context( state: &AppState, context: &RequestContext, link_id: &str, ) -> Result { let _policy = require_local_access_policy_admin(context)?; let link_id = link_id.trim(); if link_id.is_empty() { return Err(WebError::bad_request_code( "share_link_id_required", "必须提供 linkId", )); } state .control_plane() .revoke_share_link(link_id) .map_err(|error| match error { control_plane::ControlPlaneError::NotFound(message) => { WebError::new(StatusCode::NOT_FOUND, "share_link_not_found", message) } other => WebError::internal(format!("SQLite 分享链接撤销失败: {other}")), })?; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(context.auth.actor_id.trim().to_string()), action: "control.share.revoked".to_string(), target_kind: "share_link".to_string(), target_id: Some(link_id.to_string()), metadata_json: "{}".to_string(), }); append_control_plane_outbox_event( state, "control.share.revoked", json!({ "linkId": link_id, }), ); Ok(json!({ "ok": true, "controlPlane": "sqlite", "revokedLinkId": link_id, })) } fn list_local_share_grants_for_context( context: &RequestContext, admin_required: bool, ) -> Result { let is_admin = is_local_access_policy_admin_context(context); if admin_required { require_share_grants_admin(context)?; } else { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_share_grants_auth_required", "查看分享授权需要先登录", )); } } let store = load_local_share_grants_store()?; let grants = store .grants .iter() .filter(|grant| { if is_admin { true } else { let actor_id = context.auth.actor_id.trim(); grant.owner_user_id.trim() == actor_id || grant.target_user_id.trim() == actor_id } }) .map(share_grant_payload) .collect::>(); Ok(json!({ "ok": true, "admin": is_admin, "grantsPath": local_share_grants_path().display().to_string(), "grants": grants, "store": { "grants": grants, } })) } fn add_local_share_grant_for_context( context: &RequestContext, mut request: LocalShareGrantRequest, ) -> Result { require_share_grants_admin(context)?; if request.owner_user_id.trim().is_empty() { request.owner_user_id = context.auth.actor_id.trim().to_string(); } add_local_share_grant_for_context_inner(context, request, true) } fn add_user_share_grant_for_context( context: &RequestContext, mut request: LocalShareGrantRequest, ) -> Result { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_share_grants_auth_required", "创建分享授权需要先登录", )); } request.owner_user_id = actor_id.to_string(); add_local_share_grant_for_context_inner(context, request, false) } fn add_local_share_grant_for_context_inner( _context: &RequestContext, request: LocalShareGrantRequest, admin_mode: bool, ) -> Result { let mut store = load_local_share_grants_store()?; let owner_user_id = request.owner_user_id.trim(); let target_user_id = request.target_user_id.trim(); if owner_user_id.is_empty() || owner_user_id == "anonymous" { return Err(WebError::bad_request_code( "local_share_grant_owner_required", "必须提供有效 ownerUserId", )); } if target_user_id.is_empty() || target_user_id == "anonymous" { return Err(WebError::bad_request_code( "local_share_grant_target_required", "必须提供有效 targetUserId", )); } let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; let share_id = request.share_id.trim(); let share_id = if share_id.is_empty() { generate_local_folder_share_id(owner_user_id, target_user_id, &canonical) } else { share_id.to_string() }; if !admin_mode { ensure_path_inside_owned_local_workspace(owner_user_id, &canonical)?; } let permission = normalize_share_grant_permission(&request.permission)?; let capabilities = normalize_share_grant_capabilities(&request.capabilities)?; let root_uri = file_uri_for_path(&canonical); let allowed_resource_ids = request .allowed_resource_ids .iter() .map(|item| item.trim().to_string()) .filter(|item| !item.is_empty()) .collect::>(); let grant_id = request.id.trim(); let grant_id = if grant_id.is_empty() { generate_local_share_grant_id(&share_id, target_user_id, &canonical) } else { grant_id.to_string() }; if store .grants .iter() .any(|grant| grant.id.trim() == grant_id || grant.share_id.trim() == share_id) { return Err(WebError::bad_request_code( "local_share_grant_exists", "分享授权已存在", )); } let grant = LocalShareGrant { id: grant_id, share_id, owner_user_id: owner_user_id.to_string(), target_user_id: target_user_id.to_string(), root_uri, root_path: canonical.display().to_string(), document_id: request.document_id.trim().to_string(), allowed_resource_ids, permission, capabilities, created_at: now_ms().to_string(), revoked_at: None, active: true, }; store.grants.push(grant.clone()); write_local_share_grants_store(&store)?; Ok(json!({ "ok": true, "grantsPath": local_share_grants_path().display().to_string(), "grant": share_grant_payload(&grant), "store": store, })) } fn revoke_local_share_grant_for_context( context: &RequestContext, share_id: &str, ) -> Result { require_share_grants_admin(context)?; revoke_local_share_grant_for_context_inner(context, share_id, true) } fn revoke_user_share_grant_for_context( context: &RequestContext, share_id: &str, ) -> Result { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_share_grants_auth_required", "撤销分享授权需要先登录", )); } revoke_local_share_grant_for_context_inner(context, share_id, false) } fn revoke_local_share_grant_for_context_inner( context: &RequestContext, share_id: &str, admin_mode: bool, ) -> Result { let mut store = load_local_share_grants_store()?; let share_id = share_id.trim(); if share_id.is_empty() { return Err(WebError::bad_request_code( "local_share_grant_share_id_required", "必须提供 shareId", )); } let now = now_ms().to_string(); let mut updated = None; for grant in &mut store.grants { if grant.share_id.trim() == share_id || grant.id.trim() == share_id { if !admin_mode && grant.owner_user_id.trim() != context.auth.actor_id.trim() { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_owner_required", "只能撤销自己创建的分享授权", )); } grant.active = false; grant.revoked_at = Some(now.clone()); updated = Some(grant.clone()); break; } } let grant = updated.ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "local_share_grant_not_found", "未找到要撤销的分享授权", ) })?; write_local_share_grants_store(&store)?; Ok(json!({ "ok": true, "grantsPath": local_share_grants_path().display().to_string(), "revokedShareId": share_id, "grant": share_grant_payload(&grant), "store": store, })) } fn require_active_share_grant_for_actor( context: &RequestContext, root_uri: &str, share_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_shared_cache_root_unavailable", format!("无法访问共享缓存 root: {error}"), ) })?; let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_share_grant_auth_required", "访问共享缓存需要先登录", )); } let store = load_local_share_grants_store()?; let share_id = share_id.trim(); let grant = store .grants .into_iter() .find(|grant| grant.share_id.trim() == share_id || grant.id.trim() == share_id) .ok_or_else(|| { WebError::new( StatusCode::FORBIDDEN, "local_share_grant_not_found", "未找到共享授权", ) })?; if !grant.active || grant.revoked_at.as_deref().unwrap_or("").trim().len() > 0 { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_revoked", "共享授权已撤销", )); } if grant.target_user_id.trim() != actor_id && grant.owner_user_id.trim() != actor_id { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_actor_mismatch", "当前用户不在该共享授权范围内", )); } let Some(grant_root) = local_share_grant_root(&grant) else { return Err(WebError::bad_request_code( "local_share_grant_root_invalid", "共享授权 root 无效", )); }; if grant_root != canonical_root { return Err(WebError::new( StatusCode::FORBIDDEN, "local_share_grant_root_mismatch", "共享授权 root 与请求 root 不一致", )); } Ok(grant) } fn local_share_grant_root(grant: &LocalShareGrant) -> Option { let root = grant.root_uri.trim(); let path = if !root.is_empty() { parse_file_root_uri(root).ok()? } else { let root_path = grant.root_path.trim(); if root_path.is_empty() { return None; } PathBuf::from(root_path) }; path.canonicalize().ok() } fn shared_cache_json_path(root: &Path) -> PathBuf { root.join(".mnote").join("share-cache.json") } fn sync_state_json_path(root: &Path) -> PathBuf { root.join(".mnote").join("sync-state.json") } fn sync_report_dir(root: &Path) -> PathBuf { root.join(".mnote").join("sync-reports") } fn read_json_or_default(path: &Path, default_value: Value) -> Value { fs::read_to_string(path) .ok() .and_then(|content| serde_json::from_str::(&content).ok()) .unwrap_or(default_value) } fn write_json_file(path: &Path, value: &Value) -> Result<(), WebError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { WebError::bad_request_code( "local_sidecar_dir_create_failed", format!("无法创建本地 sidecar 目录 {}: {error}", parent.display()), ) })?; } let content = serde_json::to_string_pretty(value) .map_err(|error| WebError::internal(format!("无法序列化本地 sidecar JSON: {error}")))?; fs::write(path, format!("{content}\n")).map_err(|error| { WebError::bad_request_code( "local_sidecar_write_failed", format!("无法写入本地 sidecar 文件 {}: {error}", path.display()), ) }) } fn record_shared_cache_for_context( context: &RequestContext, request: SharedCacheRecordRequest, ) -> Result { let grant = require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?; let root = parse_file_root_uri(&request.root_uri)? .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_shared_cache_root_unavailable", format!("无法访问共享缓存 root: {error}"), ) })?; let permission = normalize_share_grant_permission(&request.permission)?; if permission != grant.permission.trim() { return Err(WebError::new( StatusCode::FORBIDDEN, "local_shared_cache_permission_mismatch", "共享缓存权限必须来自 canonical share grant", )); } let cache_entry = json!({ "schema": "mnote.share_cache.v1", "shareId": grant.share_id, "permission": permission, "rootUri": grant.root_uri, "allowedResourceIds": grant.allowed_resource_ids, "remoteVersion": request.remote_version, "baseVersion": request.base_version, "sourceActor": request.source_actor, "syncedAt": now_ms(), }); let path = shared_cache_json_path(&root); let mut cache = read_json_or_default( &path, json!({"schema": "mnote.share_cache.v1", "shares": {}}), ); cache["shares"][grant.share_id.clone()] = cache_entry.clone(); write_json_file(&path, &cache)?; Ok(json!({ "ok": true, "path": path.display().to_string(), "share": cache_entry, })) } fn record_sync_pending_change_for_context( context: &RequestContext, request: SyncPendingChangeRequest, ) -> Result { let grant = require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?; if normalize_share_grant_permission(&grant.permission)? != "write" { return Err(WebError::new( StatusCode::FORBIDDEN, "local_sync_shared_read_write_forbidden", "shared_read 不能记录待同步写入", )); } if !grant .allowed_resource_ids .iter() .any(|resource_id| resource_id == &request.resource_id) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_sync_resource_not_shared", "待同步资源不在 share grant 授权范围内", )); } if !request.remote_version.trim().is_empty() && !request.base_version.trim().is_empty() && request.remote_version.trim() != request.base_version.trim() { return Err(WebError::new( StatusCode::CONFLICT, "local_sync_base_version_conflict", "同步前远端版本已变化,需要生成冲突报告", )); } let root = parse_file_root_uri(&request.root_uri)? .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_sync_root_unavailable", format!("无法访问同步 root: {error}"), ) })?; let path = sync_state_json_path(&root); let mut state = read_json_or_default( &path, json!({"schema": "mnote.sync_state.v1", "pendingChanges": []}), ); let pending = json!({ "shareId": grant.share_id, "resourceId": request.resource_id, "localVersion": request.local_version, "baseVersion": request.base_version, "remoteVersion": request.remote_version, "actorId": context.auth.actor_id, "changeSummary": request.change_summary, "createdAt": now_ms(), }); state["pendingChanges"] .as_array_mut() .ok_or_else(|| { WebError::bad_request_code( "local_sync_state_invalid", "sync-state pendingChanges 非数组", ) })? .push(pending.clone()); write_json_file(&path, &state)?; Ok(json!({ "ok": true, "path": path.display().to_string(), "pendingChange": pending, })) } fn write_sync_conflict_report_for_context( context: &RequestContext, request: SyncConflictReportRequest, ) -> Result { let grant = require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?; if !grant .allowed_resource_ids .iter() .any(|resource_id| resource_id == &request.resource_id) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_sync_report_resource_not_shared", "冲突报告资源不在 share grant 授权范围内", )); } let root = parse_file_root_uri(&request.root_uri)? .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_sync_report_root_unavailable", format!("无法访问同步报告 root: {error}"), ) })?; let report = json!({ "schema": "mnote.sync_conflict_report.v1", "source": "shared_sync", "shareId": grant.share_id, "resourceId": request.resource_id, "localVersion": request.local_version, "remoteVersion": request.remote_version, "baseVersion": request.base_version, "actorId": if request.actor_id.trim().is_empty() { context.auth.actor_id.clone() } else { request.actor_id }, "summary": request.summary, "createdAt": now_ms(), "suggestedActions": ["accept_local", "accept_remote", "open_diff_merge"], }); let dir = sync_report_dir(&root); fs::create_dir_all(&dir).map_err(|error| { WebError::bad_request_code( "local_sync_report_dir_create_failed", format!("无法创建同步报告目录 {}: {error}", dir.display()), ) })?; let path = dir.join(format!( "sync-conflict-{}-{}.json", sanitize_file_name(&grant.share_id, "share"), now_ms() )); write_json_file(&path, &report)?; Ok(json!({ "ok": true, "path": path.display().to_string(), "report": report, })) } fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value { let capabilities = serde_json::from_str::>(&grant.capabilities_json) .unwrap_or_else(|_| Vec::new()); json!({ "id": grant.id, "userId": grant.user_id, "workspaceId": grant.workspace_id, "rootUri": grant.root_uri, "rootPath": grant.root_path, "permission": grant.permission, "recursive": grant.recursive, "capabilities": capabilities, "source": grant.source, "status": grant.status, "createdBy": grant.created_by, "createdAt": grant.created_at, "updatedAt": grant.updated_at, "revision": grant.revision, }) } fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool { grant.source.trim() == "auto" && grant.permission.trim() == "write" && grant.recursive && grant.created_by.as_deref().map(str::trim) == Some(grant.user_id.as_str()) && grant.workspace_id.is_some() && grant.root_uri.starts_with("local://users/") && grant.root_uri.ends_with("/workspaces/my-space") } fn sqlite_access_policy_payload( state: &AppState, context: &RequestContext, ) -> Result { let policy = require_local_access_policy_admin(context)?; let grants = state .control_plane() .list_directory_grants() .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?; let grant_values = grants .iter() .map(control_plane_grant_payload) .collect::>(); Ok(json!({ "ok": true, "controlPlane": "sqlite", "policyPath": local_access_policy_path().display().to_string(), "policy": { "admins": policy.admins, "grants": grant_values, }, "effectiveAdmins": { "policy": policy.admins, "env": env_local_admin_ids(), } })) } fn sqlite_user_access_policy_payload( state: &AppState, context: &RequestContext, ) -> Result { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_access_policy_auth_required", "查看文件夹授权需要先登录", )); } let grants = state .control_plane() .list_directory_grants() .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?; let grant_values = grants .iter() .filter(|grant| { grant.user_id.trim() == actor_id || grant.created_by.as_deref().map(str::trim) == Some(actor_id) }) .map(control_plane_grant_payload) .collect::>(); Ok(json!({ "ok": true, "controlPlane": "sqlite", "policy": { "grants": grant_values, }, "grants": grant_values, })) } fn validate_local_access_root_for_context( context: &RequestContext, request: LocalAccessValidateRootRequest, ) -> Result { let policy = require_local_access_policy_admin(context)?; let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; Ok(json!({ "ok": true, "policyPath": local_access_policy_path().display().to_string(), "rootPath": canonical.display().to_string(), "rootUri": file_uri_for_path(&canonical), "admin": is_local_admin(&context.auth.actor_id, &context.auth.actor_type, &policy), })) } fn add_sqlite_user_access_grant_for_context( state: &AppState, context: &RequestContext, mut request: LocalAccessGrantRequest, ) -> Result { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_access_policy_auth_required", "创建文件夹授权需要先登录", )); } let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; ensure_path_inside_owned_local_workspace(actor_id, &canonical)?; request.root_uri = file_uri_for_path(&canonical); request.root_path = canonical.display().to_string(); add_sqlite_local_access_grant_for_context_inner(state, context, request, "user") } fn add_sqlite_local_access_grant_for_context( state: &AppState, context: &RequestContext, request: LocalAccessGrantRequest, ) -> Result { let _policy = require_local_access_policy_admin(context)?; add_sqlite_local_access_grant_for_context_inner(state, context, request, "admin") } fn add_sqlite_local_access_grant_for_context_inner( state: &AppState, context: &RequestContext, request: LocalAccessGrantRequest, source: &str, ) -> Result { let user_id = request.user_id.trim(); if user_id.is_empty() || user_id == "anonymous" { return Err(WebError::bad_request_code( "local_access_policy_user_required", "必须提供有效 userId", )); } let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?; let permission = normalize_local_access_permission(&request.permission)?; let capabilities = normalize_local_access_capabilities(&request.capabilities)?; let root_uri = file_uri_for_path(&canonical); let duplicates = state .control_plane() .find_directory_grants(DirectoryGrantLookup { grant_id: None, user_id: Some(user_id.to_string()), root_uri: Some(root_uri.clone()), include_revoked: false, }) .map_err(|error| WebError::internal(format!("SQLite 控制面授权查重失败: {error}")))?; if duplicates.iter().any(|grant| { grant.permission.trim() == permission && grant.recursive == request.recursive && serde_json::from_str::>(&grant.capabilities_json).unwrap_or_default() == capabilities }) { return Err(WebError::bad_request_code( "local_access_policy_grant_duplicate", "相同目录授权已存在", )); } let grant = state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: user_id.to_string(), workspace_id: None, root_uri, root_path: canonical.display().to_string(), permission, recursive: request.recursive, capabilities, source: source.to_string(), created_by: Some(context.auth.actor_id.trim().to_string()), }) .map_err(|error| WebError::internal(format!("SQLite 控制面授权写入失败: {error}")))?; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(context.auth.actor_id.trim().to_string()), action: "control.grant.created".to_string(), target_kind: "directory_grant".to_string(), target_id: Some(grant.id.clone()), metadata_json: serde_json::to_string(&json!({ "userId": grant.user_id, "rootUri": grant.root_uri, "permission": grant.permission, })) .unwrap_or_else(|_| "{}".to_string()), }); append_control_plane_outbox_event( state, "control.grant.created", json!({ "grant": control_plane_grant_payload(&grant), }), ); let grants = state .control_plane() .list_directory_grants() .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?; let grant_values = grants .iter() .map(control_plane_grant_payload) .collect::>(); Ok(json!({ "ok": true, "controlPlane": "sqlite", "policyPath": local_access_policy_path().display().to_string(), "grant": control_plane_grant_payload(&grant), "policy": { "grants": grant_values, }, })) } fn delete_sqlite_user_access_grant_for_context( state: &AppState, context: &RequestContext, grant_id: &str, ) -> Result { let actor_id = context.auth.actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_access_policy_auth_required", "撤销文件夹授权需要先登录", )); } let grant_id = grant_id.trim(); if grant_id.is_empty() { return Err(WebError::bad_request_code( "local_access_policy_grant_id_required", "必须提供 grantId", )); } let grants = state .control_plane() .find_directory_grants(DirectoryGrantLookup { grant_id: Some(grant_id.to_string()), user_id: None, root_uri: None, include_revoked: false, }) .map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?; let grant = grants.first().ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "local_access_policy_grant_not_found", "目录授权不存在或不属于当前用户", ) })?; if is_default_workspace_auto_grant(grant) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_access_policy_system_grant_readonly", "默认空间的系统授权不能撤销", )); } if grant.created_by.as_deref().map(str::trim) != Some(actor_id) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_access_policy_grant_owner_required", "普通用户只能撤销自己创建的文件夹授权", )); } state .control_plane() .revoke_directory_grant(grant_id, None) .map_err(|error| match error { control_plane::ControlPlaneError::NotFound(message) => WebError::new( StatusCode::NOT_FOUND, "local_access_policy_grant_not_found", message, ), other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")), })?; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(actor_id.to_string()), action: "control.grant.revoked".to_string(), target_kind: "directory_grant".to_string(), target_id: Some(grant_id.to_string()), metadata_json: "{}".to_string(), }); append_control_plane_outbox_event( state, "control.grant.revoked", json!({ "grantId": grant_id, }), ); Ok(json!({ "ok": true, "controlPlane": "sqlite", "deletedGrantId": grant_id, "policy": { "grants": state .control_plane() .list_directory_grants() .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))? .iter() .filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id)) .map(control_plane_grant_payload) .collect::>(), }, })) } fn delete_sqlite_local_access_grant_for_context( state: &AppState, context: &RequestContext, grant_id: &str, ) -> Result { let _policy = require_local_access_policy_admin(context)?; let grant_id = grant_id.trim(); if grant_id.is_empty() { return Err(WebError::bad_request_code( "local_access_policy_grant_id_required", "必须提供 grantId", )); } let grants = state .control_plane() .find_directory_grants(DirectoryGrantLookup { grant_id: Some(grant_id.to_string()), user_id: None, root_uri: None, include_revoked: false, }) .map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?; if grants .first() .map(is_default_workspace_auto_grant) .unwrap_or(false) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_access_policy_system_grant_readonly", "默认空间的系统授权不能撤销", )); } state .control_plane() .revoke_directory_grant(grant_id, None) .map_err(|error| match error { control_plane::ControlPlaneError::NotFound(message) => WebError::new( StatusCode::NOT_FOUND, "local_access_policy_grant_not_found", message, ), other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")), })?; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(context.auth.actor_id.trim().to_string()), action: "control.grant.revoked".to_string(), target_kind: "directory_grant".to_string(), target_id: Some(grant_id.to_string()), metadata_json: "{}".to_string(), }); append_control_plane_outbox_event( state, "control.grant.revoked", json!({ "grantId": grant_id, }), ); Ok(json!({ "ok": true, "controlPlane": "sqlite", "policyPath": local_access_policy_path().display().to_string(), "deletedGrantId": grant_id, "policy": { "grants": state .control_plane() .list_directory_grants() .map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))? .iter() .map(control_plane_grant_payload) .collect::>(), }, })) } fn is_local_admin(actor_id: &str, actor_type: &str, policy: &LocalAccessPolicy) -> bool { if actor_type.trim() == "admin" { return true; } let actor_id = actor_id.trim(); if actor_id.is_empty() { return false; } if policy.admins.iter().any(|admin| admin.trim() == actor_id) { return true; } std::env::var(ENV_LOCAL_ADMIN_USER_IDS) .ok() .map(|value| { value .split([',', ';', '\n', ' ']) .map(str::trim) .filter(|item| !item.is_empty()) .any(|item| item == actor_id) }) .unwrap_or(false) } fn local_access_policy_allows( policy: &LocalAccessPolicy, actor_id: &str, canonical_root: &Path, mode: LocalAccessMode, ) -> bool { let actor_id = actor_id.trim(); if actor_id.is_empty() { return false; } policy.grants.iter().any(|grant| { if grant.user_id.trim() != actor_id { return false; } if !local_access_permission_allows(grant.permission.as_str(), mode) { return false; } let Some(grant_root) = local_access_grant_root(grant) else { return false; }; if grant.recursive { canonical_root.starts_with(&grant_root) } else { canonical_root == grant_root } }) } fn local_access_permission_allows(permission: &str, mode: LocalAccessMode) -> bool { let permission = permission.trim(); if permission == "admin" { return true; } match mode { LocalAccessMode::Read => permission == "read" || permission == "write", LocalAccessMode::Write => permission == "write", } } fn local_access_grant_root(grant: &LocalAccessGrant) -> Option { let root = grant.root_uri.trim(); let path = if !root.is_empty() { parse_file_root_uri(root).ok()? } else { let root_path = grant.root_path.trim(); if root_path.is_empty() { return None; } PathBuf::from(root_path) }; path.canonicalize().ok() } pub(crate) fn create_default_local_workspace_for_actor( actor_id: &str, actor_type: &str, ) -> Result { create_default_local_workspace_for_actor_at_base( actor_id, actor_type, &default_local_workspace_base_dir(), ) } fn create_default_local_workspace_for_actor_at_base( actor_id: &str, actor_type: &str, base_dir: &Path, ) -> Result { let actor_id = actor_id.trim(); let actor_type = actor_type.trim(); if actor_id.is_empty() || actor_id == "anonymous" || actor_type.is_empty() || actor_type == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_workspace_auth_required", "创建本地工作区需要先登录", )); } fs::create_dir_all(base_dir).map_err(|error| { WebError::bad_request_code( "local_workspace_base_create_failed", format!( "无法创建本地工作区数据根目录 {}: {error}", base_dir.display() ), ) })?; let canonical_base = base_dir.canonicalize().map_err(|error| { WebError::bad_request_code( "local_workspace_base_unavailable", format!("无法访问本地工作区数据根目录: {error}"), ) })?; let actor_segment = encode_local_id_segment(actor_id); let workspace_root = canonical_base .join("users") .join(&actor_segment) .join("workspaces") .join(DEFAULT_LOCAL_WORKSPACE_SLUG); fs::create_dir_all(&workspace_root).map_err(|error| { WebError::bad_request_code( "local_workspace_create_failed", format!( "无法创建默认本地工作区 {}: {error}", workspace_root.display() ), ) })?; let canonical_root = workspace_root.canonicalize().map_err(|error| { WebError::bad_request_code( "local_workspace_unavailable", format!("无法访问默认本地工作区: {error}"), ) })?; if !canonical_root.starts_with(&canonical_base) { return Err(WebError::bad_request_code( "local_workspace_root_escape", "默认本地工作区不能越过受管数据根目录", )); } let manifest = ensure_default_workspace_manifest(actor_id, &canonical_root)?; ensure_default_workspace_directories(&canonical_root)?; Ok(json!({ "ok": true, "workspace": { "rootUri": file_uri_for_path(&canonical_root), "rootPath": canonical_root.display().to_string(), "baseDir": canonical_base.display().to_string(), "manifest": local_workspace_manifest_json(&manifest), } })) } pub async fn create_default_local_workspace( Extension(context): Extension, ) -> Result<(StatusCode, Json), WebError> { let payload = create_default_local_workspace_for_actor(&context.auth.actor_id, &context.auth.actor_type) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn get_local_access_policy( State(state): State, Extension(context): Extension, ) -> Result<(StatusCode, Json), WebError> { let payload = sqlite_access_policy_payload(&state, &context) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn get_user_access_policy( State(state): State, Extension(context): Extension, ) -> Result<(StatusCode, Json), WebError> { let payload = sqlite_user_access_policy_payload(&state, &context) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn validate_local_access_root( Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = validate_local_access_root_for_context(&context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn create_local_access_grant( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = add_sqlite_local_access_grant_for_context(&state, &context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn create_user_access_grant( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = add_sqlite_user_access_grant_for_context(&state, &context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn delete_local_access_grant( State(state): State, Extension(context): Extension, AxumPath(grant_id): AxumPath, ) -> Result<(StatusCode, Json), WebError> { let payload = delete_sqlite_local_access_grant_for_context(&state, &context, &grant_id) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn delete_user_access_grant( State(state): State, Extension(context): Extension, AxumPath(grant_id): AxumPath, ) -> Result<(StatusCode, Json), WebError> { let payload = delete_sqlite_user_access_grant_for_context(&state, &context, &grant_id) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn get_share_grants( Extension(context): Extension, ) -> Result<(StatusCode, Json), WebError> { let payload = list_local_share_grants_for_context(&context, true) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn get_user_share_grants( Extension(context): Extension, ) -> Result<(StatusCode, Json), WebError> { let payload = list_local_share_grants_for_context(&context, false) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn get_share_links( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let payload = list_sqlite_share_links_for_context(&state, &context, query) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn create_share_link( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = create_sqlite_share_link_for_context(&state, &context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn delete_share_link( State(state): State, Extension(context): Extension, AxumPath(link_id): AxumPath, ) -> Result<(StatusCode, Json), WebError> { let payload = revoke_sqlite_share_link_for_context(&state, &context, &link_id) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn create_share_grant( Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = add_local_share_grant_for_context(&context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn delete_share_grant( Extension(context): Extension, AxumPath(share_id): AxumPath, ) -> Result<(StatusCode, Json), WebError> { let payload = revoke_local_share_grant_for_context(&context, &share_id) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn create_user_share_grant( Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = add_user_share_grant_for_context(&context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn delete_user_share_grant( Extension(context): Extension, AxumPath(share_id): AxumPath, ) -> Result<(StatusCode, Json), WebError> { let payload = revoke_user_share_grant_for_context(&context, &share_id) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn record_shared_cache( Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = record_shared_cache_for_context(&context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn record_sync_pending_change( Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = record_sync_pending_change_for_context(&context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } pub async fn write_sync_conflict_report( Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, Json), WebError> { let payload = write_sync_conflict_report_for_context(&context, request) .map_err(|error| error.with_context(&context))?; Ok((StatusCode::OK, Json(payload))) } #[cfg(test)] pub(crate) fn initialize_local_workspace_for_actor( actor_id: &str, root_uri: &str, ) -> Result { let actor_id = actor_id.trim(); if actor_id.is_empty() || actor_id == "anonymous" { return Err(WebError::new( StatusCode::UNAUTHORIZED, "local_workspace_auth_required", "创建本地工作区需要先登录", )); } 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 manifest_path = local_workspace_manifest_path(&canonical_root); if manifest_path.exists() { let manifest = load_local_workspace_manifest(&canonical_root)?; if manifest.owner_id.trim() != actor_id { return Err(WebError::new( StatusCode::FORBIDDEN, "local_workspace_access_denied", "当前用户无权接管该本地工作区", )); } return Ok(local_workspace_manifest_json(&manifest)); } let manifest = LocalWorkspaceManifest { workspace_id: format!( "local-ws:{}", encode_local_id_segment(&format!("{}:{}", actor_id, canonical_root.display())) ), owner_id: actor_id.to_string(), created_at: now_ms().to_string(), capabilities: vec![ "local_files".to_string(), "tree_commands".to_string(), "markdown_edit".to_string(), "asset_upload".to_string(), ], }; write_local_workspace_manifest(&canonical_root, &manifest)?; Ok(local_workspace_manifest_json(&manifest)) } fn default_local_workspace_base_dir() -> PathBuf { std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(DEFAULT_LOCAL_WORKSPACE_BASE_DIR)) } fn ensure_default_workspace_manifest( actor_id: &str, root: &Path, ) -> Result { let manifest_path = local_workspace_manifest_path(root); if manifest_path.exists() { let manifest = load_local_workspace_manifest(root)?; if manifest.owner_id.trim() != actor_id { return Err(WebError::new( StatusCode::FORBIDDEN, "local_workspace_access_denied", "当前用户无权接管该本地工作区", )); } return Ok(manifest); } let manifest = LocalWorkspaceManifest { workspace_id: format!( "local-ws:{}:{}", encode_local_id_segment(actor_id), DEFAULT_LOCAL_WORKSPACE_SLUG ), owner_id: actor_id.to_string(), created_at: now_ms().to_string(), capabilities: vec![ "local_files".to_string(), "tree_commands".to_string(), "markdown_edit".to_string(), "asset_upload".to_string(), ], }; write_local_workspace_manifest(root, &manifest)?; Ok(manifest) } fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> { for relative in [".mnote/trash"] { let path = root.join(relative); if !path.starts_with(root) { return Err(WebError::bad_request_code( "local_workspace_root_escape", "默认本地工作区目录不能越过 root", )); } fs::create_dir_all(&path).map_err(|error| { WebError::bad_request_code( "local_workspace_create_failed", format!("无法创建默认本地工作区目录 {}: {error}", path.display()), ) })?; } Ok(()) } fn local_workspace_manifest_path(root: &Path) -> PathBuf { root.join(".mnote").join("workspace.json") } fn load_local_workspace_manifest(root: &Path) -> Result { let manifest_path = local_workspace_manifest_path(root); let content = fs::read_to_string(&manifest_path).map_err(|error| { WebError::new( StatusCode::FORBIDDEN, "local_workspace_manifest_missing", format!("缺少本地工作区清单 {}: {error}", manifest_path.display()), ) })?; let manifest = serde_json::from_str::(&content).map_err(|error| { WebError::bad_request_code( "local_workspace_manifest_invalid", format!("本地工作区清单格式非法: {error}"), ) })?; if manifest.workspace_id.trim().is_empty() || manifest.owner_id.trim().is_empty() { return Err(WebError::bad_request_code( "local_workspace_manifest_invalid", "本地工作区清单缺少 workspaceId / ownerId", )); } Ok(manifest) } fn write_local_workspace_manifest( root: &Path, manifest: &LocalWorkspaceManifest, ) -> Result<(), WebError> { let metadata_dir = root.join(".mnote"); fs::create_dir_all(&metadata_dir).map_err(|error| { WebError::bad_request_code( "local_workspace_manifest_write_failed", format!( "无法创建本地工作区元数据目录 {}: {error}", metadata_dir.display() ), ) })?; let manifest_path = local_workspace_manifest_path(root); let content = serde_json::to_string_pretty(manifest) .map_err(|error| WebError::internal(format!("本地工作区清单序列化失败: {error}")))?; fs::write(&manifest_path, content).map_err(|error| { WebError::bad_request_code( "local_workspace_manifest_write_failed", format!( "无法写入本地工作区清单 {}: {error}", manifest_path.display() ), ) }) } fn local_workspace_manifest_json(manifest: &LocalWorkspaceManifest) -> Value { json!({ "workspaceId": manifest.workspace_id, "ownerId": manifest.owner_id, "createdAt": manifest.created_at, "capabilities": manifest.capabilities, }) } pub fn load_local_folder_file_tree_snapshot( root_uri: &str, ) -> Result { load_local_folder_file_tree_scope_snapshot(root_uri, None, None) } pub fn load_local_folder_file_tree_snapshot_with_reveal( root_uri: &str, reveal_document_id: Option<&str>, ) -> Result { let reveal_relative_path = reveal_document_id .and_then(local_markdown_relative_path_from_document_id) .map(|path| path.replace('\\', "/")); load_local_folder_file_tree_scope_snapshot(root_uri, None, reveal_relative_path.as_deref()) } pub fn load_local_folder_file_tree_children_snapshot( root_uri: &str, parent_relative_path: &str, ) -> Result { load_local_folder_file_tree_scope_snapshot(root_uri, Some(parent_relative_path), None) } fn load_local_folder_file_tree_scope_snapshot( root_uri: &str, parent_relative_path: Option<&str>, reveal_relative_path: 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}"), ) })?; if !canonical_root.is_dir() { return Err(WebError::bad_request_code( "local_folder_not_directory", "本地 rootUri 必须指向目录", )); } let workspace_id = local_workspace_id(&canonical_root); let root_source_uri = file_uri_for_path(&canonical_root); let metadata = load_local_folder_metadata(&canonical_root)?; let parent_relative_path = parent_relative_path .map(str::trim) .filter(|value| !value.is_empty() && *value != ".") .unwrap_or(""); let scan_root = if parent_relative_path.is_empty() { canonical_root.clone() } else { resolve_metadata_relative_path(&canonical_root, parent_relative_path)? }; if !scan_root.is_dir() { return Err(WebError::bad_request_code( "local_folder_parent_not_directory", "FileTree 懒加载 parentRelativePath 必须指向目录", )); } let parent_node_id = if parent_relative_path.is_empty() { None } else { Some(local_node_id(parent_relative_path)) }; let depth = local_folder_relative_depth(parent_relative_path); let mut scan_result = scan_directory_shallow_with_revision( &canonical_root, &scan_root, parent_node_id, depth, &root_source_uri, &workspace_id, &metadata, )?; if parent_relative_path.is_empty() { append_file_tree_reveal_rows( &canonical_root, reveal_relative_path, &root_source_uri, &workspace_id, &metadata, &mut scan_result.rows, )?; } let items = scan_result .rows .iter() .map(local_folder_row_to_projection_item) .collect::>(); let dataset = json!({ "workspace": { "id": &workspace_id, "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, "parentRelativePath": parent_relative_path, "lazy": true, "watchRevision": scan_result.watch_revision, "items": items, }); Ok(ProjectionSnapshot { dataset, projection, }) } pub fn load_local_folder_page_tree_snapshot( root_uri: &str, ) -> Result { load_local_folder_page_tree_snapshot_for_scope(root_uri, None) } pub fn load_local_folder_page_tree_scope_snapshot( root_uri: &str, parent_relative_path: &str, ) -> Result { load_local_folder_page_tree_snapshot_for_scope(root_uri, Some(parent_relative_path)) } fn load_local_folder_page_tree_snapshot_for_scope( root_uri: &str, parent_relative_path: Option<&str>, ) -> Result { #[cfg(test)] LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); 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 workspace_id = local_workspace_id(&canonical_root); let root_source_uri = file_uri_for_path(&canonical_root); let parent_relative_path = parent_relative_path .map(str::trim) .filter(|value| !value.is_empty() && *value != ".") .unwrap_or(""); let scan_root = if parent_relative_path.is_empty() { canonical_root.clone() } else { resolve_metadata_relative_path(&canonical_root, parent_relative_path)? }; if !scan_root.is_dir() { return Err(WebError::bad_request_code( "local_folder_parent_not_directory", "PageTree scope parentRelativePath 必须指向目录", )); } let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; let cache_key = format!("{root_source_uri}\n{parent_relative_path}"); if let Ok(cache) = local_page_tree_snapshot_cache().lock() { if let Some(entry) = cache.get(&cache_key) { if entry.watch_revision.revision == watch_revision.revision && entry.watch_revision.entry_count == watch_revision.entry_count && entry.watch_revision.latest_modified_ms == watch_revision.latest_modified_ms { return Ok(entry.snapshot.clone()); } } } let metadata = load_local_folder_metadata(&canonical_root)?; let mut rows = Vec::new(); #[cfg(test)] LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); scan_markdown_page_tree( &canonical_root, &scan_root, None, 0, None, &metadata, &mut rows, &workspace_id, &root_source_uri, )?; 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 snapshot = 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, "parentRelativePath": parent_relative_path, "watchRevision": watch_revision, "items": items, }), }; if let Ok(mut cache) = local_page_tree_snapshot_cache().lock() { if cache.len() > 64 { cache.clear(); } cache.insert( cache_key, LocalPageTreeSnapshotCacheEntry { watch_revision, snapshot: snapshot.clone(), }, ); } Ok(snapshot) } 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 attachment_paths = uploaded_asset_markdown_relative_paths_for_document( &canonical_root, &metadata, document_id, &markdown_file.path, ); let content = crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths( &parsed.body, &attachment_paths, ); 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_value = metadata .page_options .get(document_id) .or_else(|| metadata.page_options.get(&markdown_file.relative_path)); let mut page_options = page_options_value .map(page_options_from_metadata) .unwrap_or_default(); if page_options_value .and_then(page_options_hide_title_header_value) .is_none() { // 我的空间(有 workspace.json 的托管工作区,包括用户本人的及分享的)默认显示页头标题。 // 普通本地文件夹(无托管清单)默认隐藏页头标题,避免文件名与正文 H1 重复。 let is_managed = load_local_workspace_manifest(&canonical_root).is_ok(); page_options.hide_title_header = !is_managed; } 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; let block_document = project_legacy_content_to_block_document(document_id, &content, &Value::Number(0.into())) .map_err(|error| WebError::internal(format!("{error:?}")))?; let conflict_detection_key = local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; 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(conflict_detection_key.clone()), file_version: Value::String(conflict_detection_key), block_document, block_projection_version: 1, projection_source: "local_markdown.content".into(), }, 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 { save_local_markdown_page_inner( root_uri, document_id, expected_conflict_detection_key, content, None, ) } fn save_local_markdown_page_inner( root_uri: &str, document_id: &str, expected_conflict_detection_key: Option<&str>, content: &Value, buffer_store: Option<&crate::document_buffer_store::BufferStore>, ) -> 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 workspace_id = local_workspace_id(&canonical_root); let relative_path_for_buffer = markdown_file .path .strip_prefix(&canonical_root) .ok() .map(|p| p.to_string_lossy().replace('\\', "/")) .unwrap_or_default(); 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 { let buffer_state = buffer_store.and_then(|store| { let ws_path = crate::document_buffer_store::build_local_folder_workspace_path( &workspace_id, root_uri, &relative_path_for_buffer, document_id, ); store.get_by_path(&ws_path) }); return Err(local_markdown_conflict_error( root_uri, document_id, expected_key, ¤t_conflict_key, buffer_state.as_ref(), )); } } let (frontmatter, _) = split_frontmatter(¤t); let body = editor_blocks_to_markdown_for_file(content, &canonical_root, &markdown_file.path); 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() ), ) })?; refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id); let next_conflict_key = local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; Ok(json!({ "ok": true, "documentId": document_id, "workspaceId": workspace_id, "relativePath": relative_path_for_buffer, "revision": now_ms(), "conflict_detection_key": next_conflict_key, "fileVersion": next_conflict_key, "executedCommand": "page.body.save", "canonicalCommand": "page.body.save", "sourceKind": "local_folder", })) } fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) { let _ = local_search_index::refresh_local_search_index(root, root_uri, workspace_id); } fn local_markdown_conflict_error( root_uri: &str, document_id: &str, editor_base_version: &str, current_disk_version: &str, buffer_state: Option<&core_protocol::DocumentBuffer>, ) -> WebError { let mut details = json!({ "conflict": { "code": "local_markdown_external_change", "documentId": document_id, "rootUri": root_uri, "currentDiskVersion": current_disk_version, "editorBaseVersion": editor_base_version, "externalActor": Value::Null, "dirtyState": Value::Null, "bufferFileVersion": Value::Null, "suggestedActions": [ "accept_disk", "keep_editor", "open_diff", "merge" ] } }); if let Some(buf) = buffer_state { if let Some(conflict) = details.get_mut("conflict").and_then(Value::as_object_mut) { conflict.insert("externalActor".into(), json!(&buf.external_actor)); conflict.insert("dirtyState".into(), json!(format!("{:?}", buf.dirty_state))); conflict.insert("bufferFileVersion".into(), json!(&buf.file_version)); } } WebError::new( StatusCode::CONFLICT, "local_markdown_external_change", "本地 Markdown 文件已被外部修改,请刷新后再保存", ) .with_details(details) } pub fn write_local_markdown_page_body( request: &core_protocol::PageBodyWriteRequest, buffer_store: Option<&crate::document_buffer_store::BufferStore>, ) -> Result { if request.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder { return Err(WebError::bad_request_code( "page_body_write_source_unsupported", "page.body.write 当前只支持 local_folder 本地写入", )); } if request.content_format.trim() != "editorBlocks" { return Err(WebError::bad_request_code( "page_body_write_content_format_unsupported", "page.body.write 当前只支持 editorBlocks 内容格式", )); } let mut result = if buffer_store.is_some() { save_local_markdown_page_inner( &request.root_uri, &request.document_id, request.expected_file_version.as_deref(), &request.content, buffer_store, )? } else { save_local_markdown_page( &request.root_uri, &request.document_id, request.expected_file_version.as_deref(), &request.content, )? }; // 保存成功后更新 BufferStore if let Some(store) = buffer_store { if let (Some(workspace_id), Some(relative_path), Some(file_version)) = ( result.get("workspaceId").and_then(Value::as_str), result.get("relativePath").and_then(Value::as_str), result.get("fileVersion").and_then(Value::as_str), ) { let ws_path = crate::document_buffer_store::build_local_folder_workspace_path( workspace_id, &request.root_uri, relative_path, &request.document_id, ); store.mark_saved( &ws_path, file_version.to_string(), format!("sha256:{file_version}"), ); } } if let Value::Object(map) = &mut result { map.insert("canonicalCommand".into(), json!("page.body.write")); map.insert("compatCommand".into(), json!("page.body.save")); map.insert("contentFormat".into(), json!(request.content_format)); map.insert( "editorSource".into(), request .editor_source .as_deref() .map(Value::from) .unwrap_or(Value::Null), ); map.insert( "baseContentHash".into(), request .base_content_hash .as_deref() .map(Value::from) .unwrap_or(Value::Null), ); } Ok(result) } pub async fn upload_local_markdown_asset( State(state): State, Extension(context): Extension, multipart: Multipart, ) -> Result<(StatusCode, Json), WebError> { let fields = read_local_asset_upload_multipart(multipart).await?; ensure_local_workspace_write_access_with_state(&state, &context, &fields.root_uri) .map_err(|error| error.with_context(&context))?; let asset = match fields.upload_intent.as_str() { "editor.markdown.attach" => { if fields.document_id.trim().is_empty() || fields.target_relative_path.is_some() { return Err(WebError::bad_request_code( "local_asset_upload_intent_invalid", "editor.markdown.attach 必须提供 documentId 且不能提供 targetRelativePath", )); } write_local_markdown_asset( &fields.root_uri, &fields.document_id, &fields.kind, fields.file, )? } "filetree.folder.drop" => { let Some(target_relative_path) = fields.target_relative_path.as_deref() else { return Err(WebError::bad_request_code( "local_asset_upload_intent_invalid", "filetree.folder.drop 必须提供 targetRelativePath", )); }; write_local_folder_file_upload( &fields.root_uri, target_relative_path, &fields.kind, fields.file, )? } _ => { return Err(WebError::bad_request_code( "local_asset_upload_intent_unsupported", "不支持的本地上传 intent", )); } }; Ok((StatusCode::OK, Json(json!({ "ok": true, "asset": asset })))) } pub async fn open_local_file( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Vec), WebError> { ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; let target = resolve_local_open_path(&query.root_uri, &query.path)?; let mut headers = HeaderMap::new(); if target.is_dir() { if !query.download.unwrap_or(false) { return Err(WebError::bad_request_code( "local_file_open_is_directory", "不能直接读取本地目录", ) .with_context(&context)); } let bytes = build_local_directory_tar_archive(&target).map_err(|error| { WebError::bad_request_code( "local_directory_download_failed", format!("无法打包本地目录 {}: {error}", target.display()), ) .with_context(&context) })?; headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/x-tar"), ); headers.insert( header::CONTENT_DISPOSITION, content_disposition_attachment_for_filename(&local_directory_tar_filename(&target)), ); return Ok((StatusCode::OK, headers, bytes)); } let bytes = fs::read(&target).map_err(|error| { WebError::bad_request_code( "local_file_open_read_failed", format!("无法读取本地文件 {}: {error}", target.display()), ) .with_context(&context) })?; headers.insert(header::CONTENT_TYPE, content_type_for_path(&target)); if query.download.unwrap_or(false) { headers.insert( header::CONTENT_DISPOSITION, content_disposition_attachment_for_path(&target), ); } Ok((StatusCode::OK, headers, bytes)) } pub async fn stat_local_file( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; let root_path = parse_file_root_uri(&query.root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) .with_context(&context) })?; let requested = Path::new(&query.path); if requested.is_absolute() || requested .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(WebError::bad_request_code( "local_file_stat_root_escape", "本地文件路径不能越过 root", ) .with_context(&context)); } let target = canonical_root.join(requested); let metadata = match fs::symlink_metadata(&target) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(( StatusCode::OK, HeaderMap::new(), Json(json!({ "ok": true, "result": { "rootUri": query.root_uri, "path": query.path, "exists": false, "sourceKind": "local_folder", } })), )); } Err(error) => { return Err(WebError::bad_request_code( "local_file_stat_failed", format!("无法读取本地文件状态 {}: {error}", target.display()), ) .with_context(&context)); } }; let canonical_target = target.canonicalize().map_err(|error| { WebError::bad_request_code( "local_file_stat_failed", format!("无法解析本地文件路径 {}: {error}", target.display()), ) .with_context(&context) })?; if !canonical_target.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_file_stat_root_escape", "本地文件路径不能越过 root", ) .with_context(&context)); } let file_name = canonical_target .file_name() .and_then(|value| value.to_str()) .unwrap_or("资源") .to_string(); Ok(( StatusCode::OK, HeaderMap::new(), Json(json!({ "ok": true, "result": { "rootUri": query.root_uri, "path": query.path, "exists": true, "isDirectory": metadata.is_dir(), "fileName": file_name, "contentType": content_type_for_path(&canonical_target).to_str().unwrap_or("application/octet-stream"), "sourceKind": "local_folder", } })), )) } pub async fn read_local_resource( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; let root = parse_file_root_uri(&query.root_uri)? .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) .with_context(&context) })?; let target = resolve_local_file_open_path(&query.root_uri, &query.path)?; let text = fs::read_to_string(&target).map_err(|error| { WebError::bad_request_code( "local_resource_read_failed", format!("无法读取本地资源文件 {}: {error}", target.display()), ) .with_context(&context) })?; let file_name = target .file_name() .and_then(|value| value.to_str()) .unwrap_or("资源") .to_string(); let content = if is_markdown_file(&file_name) { let metadata = load_local_folder_metadata(&root)?; let target_relative_path = normalize_relative_path(&root, &target)?; let attachment_paths = metadata .uploaded_assets .get(&target_relative_path) .map(|entry| { uploaded_asset_markdown_relative_paths_for_document( &root, &metadata, &entry.document_id, &target, ) }) .unwrap_or_default(); if attachment_paths.is_empty() { crate::routes::local_markdown_parser::markdown_to_blocks(&text) } else { crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths( &text, &attachment_paths, ) } } else { text_to_editor_blocks(&text, &file_name) }; let file_version = local_resource_conflict_detection_key(&root, &target)?; Ok(local_folder_ok_response( &context, json!({ "rootUri": query.root_uri, "path": query.path, "fileName": file_name, "contentType": content_type_for_path(&target).to_str().unwrap_or("application/octet-stream"), "text": text, "content": content, "contentFormat": "editorBlocks", "fileVersion": file_version, "conflictDetectionKey": file_version, "sourceKind": "local_folder", }), )) } pub async fn write_local_resource( State(state): State, Extension(context): Extension, Json(request): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let root_uri = request.root_uri.trim(); let path = request.path.trim(); if root_uri.is_empty() || path.is_empty() { return Err(WebError::bad_request_code( "local_resource_write_required_missing", "缺少 rootUri 或 path", ) .with_context(&context)); } ensure_local_workspace_write_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let root = parse_file_root_uri(root_uri)? .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_folder_unavailable", format!("无法访问本地文件夹: {error}"), ) .with_context(&context) })?; let target = resolve_local_file_open_path(root_uri, path)?; let current_key = local_resource_conflict_detection_key(&root, &target)?; if let Some(expected_key) = request .expected_file_version .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { if expected_key != current_key { return Err(WebError::new( StatusCode::CONFLICT, "local_resource_external_change", "本地资源文件已被外部修改,请刷新后再保存", ) .with_details(json!({ "conflict": { "code": "local_resource_external_change", "rootUri": root_uri, "path": path, "currentDiskVersion": current_key, "editorBaseVersion": expected_key, } })) .with_context(&context)); } } let content = local_resource_write_editor_blocks(&request); let file_name = target .file_name() .and_then(|value| value.to_str()) .unwrap_or("资源") .to_string(); let next_text = if is_markdown_file(&file_name) { editor_blocks_to_markdown_for_file(&content, &root, &target) } else { editor_blocks_to_plain_text(&content) }; fs::write(&target, next_text).map_err(|error| { WebError::bad_request_code( "local_resource_write_failed", format!("无法保存本地资源文件 {}: {error}", target.display()), ) .with_context(&context) })?; let next_key = local_resource_conflict_detection_key(&root, &target)?; Ok(local_folder_ok_response( &context, json!({ "ok": true, "rootUri": root_uri, "path": path, "fileName": file_name, "fileVersion": next_key, "conflictDetectionKey": next_key, "contentFormat": request.content_format.unwrap_or_else(|| "editorBlocks".into()), "executedCommand": "resource.file.write", "canonicalCommand": "resource.file.write", "sourceKind": "local_folder", }), )) } fn resolve_local_open_path(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 requested = Path::new(relative_path); if requested.is_absolute() || requested .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(WebError::bad_request_code( "local_file_open_root_escape", "本地文件路径不能越过 root", )); } let target = canonical_root .join(requested) .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_file_open_not_found", format!("找不到本地文件: {error}"), ) })?; if !target.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_file_open_root_escape", "本地文件路径不能越过 root", )); } Ok(target) } fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result { let target = resolve_local_open_path(root_uri, relative_path)?; if target.is_dir() { return Err(WebError::bad_request_code( "local_file_open_is_directory", "不能直接读取本地目录", )); } Ok(target) } async fn read_local_asset_upload_multipart( mut multipart: Multipart, ) -> Result { let mut file: Option = None; let mut root_uri = String::new(); let mut document_id = String::new(); let mut target_relative_path: Option = None; let mut upload_intent = String::new(); let mut kind = String::new(); while let Some(field) = multipart.next_field().await.map_err(|error| { WebError::bad_request_code( "local_asset_upload_bad_multipart", format!("上传表单解析失败: {error}"), ) })? { let name = field.name().unwrap_or_default().to_string(); if name == "file" { let file_name = field .file_name() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("附件") .to_string(); let content_type = field .content_type() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("application/octet-stream") .to_string(); let bytes = field .bytes() .await .map_err(|error| { WebError::bad_request_code( "local_asset_upload_file_read_failed", format!("读取上传文件失败: {error}"), ) })? .to_vec(); file = Some(LocalUploadFile { name: file_name, content_type, bytes, }); continue; } let value = field.text().await.map_err(|error| { WebError::bad_request_code( "local_asset_upload_field_read_failed", format!("读取上传字段失败: {error}"), ) })?; match name.as_str() { "rootUri" => root_uri = value.trim().to_string(), "documentId" => document_id = value.trim().to_string(), "targetRelativePath" | "targetDirectoryPath" => { target_relative_path = Some(value.trim().to_string()) } "uploadIntent" | "upload_intent" => upload_intent = value.trim().to_string(), "kind" => kind = value.trim().to_string(), _ => {} } } let file = file.ok_or_else(|| { WebError::bad_request_code("local_asset_upload_file_missing", "缺少 file") })?; if file.bytes.is_empty() || root_uri.is_empty() || upload_intent.is_empty() { return Err(WebError::bad_request_code( "local_asset_upload_required_missing", "缺少必要参数", )); } Ok(LocalAssetUploadFields { file, root_uri, document_id, target_relative_path, upload_intent, kind, }) } pub(crate) fn write_local_folder_file_upload( root_uri: &str, target_relative_path: &str, kind: &str, file: LocalUploadFile, ) -> 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 target_relative_path = target_relative_path.trim(); let requested = if target_relative_path.is_empty() { Path::new(".") } else { Path::new(target_relative_path) }; if requested.is_absolute() || requested .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(WebError::bad_request_code( "local_folder_upload_root_escape", "上传目标目录不能越过 root", )); } let target_dir = canonical_root .join(requested) .canonicalize() .map_err(|error| { WebError::bad_request_code( "local_folder_upload_target_missing", format!("找不到上传目标目录: {error}"), ) })?; if !target_dir.starts_with(&canonical_root) || !target_dir.is_dir() { return Err(WebError::bad_request_code( "local_folder_upload_target_invalid", "上传目标必须是本地 root 内的目录", )); } let sanitized_name = sanitize_file_name(&file.name, "附件"); let target = next_available_raw_path(&target_dir, &sanitized_name); fs::write(&target, &file.bytes).map_err(|error| { WebError::bad_request_code( "local_folder_upload_write_failed", format!("无法写入本地文件 {}: {error}", target.display()), ) })?; let root_relative_path = normalize_relative_path(&canonical_root, &target)?; let asset_type = local_upload_asset_type(kind, &file.content_type); Ok(json!({ "id": format!("local-file:{root_relative_path}"), "uploadIntent": "filetree.folder.drop", "asset_type": asset_type, "file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name), "mime_type": file.content_type, "file_size": file.bytes.len(), "file_url": root_relative_path, "sourcePath": root_relative_path, "sourceKind": "local_folder", "rootUri": file_uri_for_path(&canonical_root), "rootRelativePath": root_relative_path, "targetRelativePath": target_relative_path, "markdownRelativePath": Value::Null, "ownerDocumentId": Value::Null, })) } pub(crate) fn write_local_markdown_asset( root_uri: &str, document_id: &str, kind: &str, file: LocalUploadFile, ) -> 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_dir = markdown_file.path.parent().ok_or_else(|| { WebError::bad_request_code( "local_asset_upload_bad_markdown_path", "本地 Markdown 文件路径无父目录", ) })?; if !markdown_dir.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_folder_root_escape", "本地 Markdown 文件路径不在 root 内", )); } let page_resource_dir = markdown_page_resource_directory(&markdown_file.path).ok_or_else(|| { WebError::bad_request_code( "local_asset_upload_bad_markdown_path", "无法解析本地页面资源目录", ) })?; let asset_dir = page_resource_dir; if !asset_dir.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_folder_root_escape", "本地资源目录不能越过 root", )); } fs::create_dir_all(&asset_dir).map_err(|error| { WebError::bad_request_code( "local_asset_upload_create_dir_failed", format!("无法创建本地资源目录 {}: {error}", asset_dir.display()), ) })?; let sanitized_name = sanitize_file_name(&file.name, "附件"); let target = next_available_asset_path(&asset_dir, &sanitized_name); if !target.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_folder_root_escape", "本地资源文件不能越过 root", )); } fs::write(&target, &file.bytes).map_err(|error| { WebError::bad_request_code( "local_asset_upload_write_failed", format!("无法写入本地资源文件 {}: {error}", target.display()), ) })?; let root_relative_path = normalize_relative_path(&canonical_root, &target)?; let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?; let asset_type = local_upload_asset_type(kind, &file.content_type); let mut uploaded_assets = metadata.uploaded_assets; uploaded_assets.insert( root_relative_path.clone(), LocalUploadedAssetEntry { document_id: document_id.to_string(), relative_path: root_relative_path.clone(), file_name: target .file_name() .and_then(|value| value.to_str()) .unwrap_or(&sanitized_name) .to_string(), created_at_ms: now_ms(), }, ); write_uploaded_asset_index_metadata(&canonical_root, &uploaded_assets)?; Ok(json!({ "id": format!("local:asset:{root_relative_path}"), "uploadIntent": "editor.markdown.attach", "asset_type": asset_type, "file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name), "mime_type": file.content_type, "file_size": file.bytes.len(), "file_url": markdown_relative_path, "sourcePath": markdown_relative_path, "document_id": document_id, "documentId": document_id, "ownerDocumentId": document_id, "sourceKind": "local_folder", "rootUri": file_uri_for_path(&canonical_root), "rootRelativePath": root_relative_path, "markdownRelativePath": markdown_relative_path, })) } fn local_mindmap_relative_path_from_id(mindmap_id: &str) -> Option { let value = mindmap_id.trim(); value .strip_prefix("local-file:") .or_else(|| value.strip_prefix("local:asset:")) .map(str::trim) .filter(|path| !path.is_empty()) .map(ToOwned::to_owned) } fn ensure_relative_path_stays_in_root( root: &Path, relative_path: &str, ) -> Result { let requested = Path::new(relative_path); if requested.is_absolute() || requested .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(WebError::bad_request_code( "local_mindmap_root_escape", "本地思维导图路径不能越过 root", )); } let target = root.join(requested); if !target.starts_with(root) { return Err(WebError::bad_request_code( "local_mindmap_root_escape", "本地思维导图路径不能越过 root", )); } Ok(target) } fn local_mindmap_file_name(mindmap_id: &str) -> String { let sanitized = sanitize_file_name(mindmap_id, "mindmap"); if sanitized.to_ascii_lowercase().ends_with(".json") { sanitized } else { format!("{}.json", sanitize_file_stem(&sanitized, "mindmap")) } } fn is_local_mindmap_file_name(file_name: &str) -> bool { let trimmed = file_name.trim(); let lower = trimmed.to_ascii_lowercase(); lower.ends_with(".mindmap.json") || (trimmed.starts_with("思维导图") && lower.ends_with(".json")) } fn resolve_local_mindmap_path( root: &Path, metadata: &LocalFolderMetadata, document_id: &str, mindmap_id: &str, ) -> Result { if let Some(relative_path) = local_mindmap_relative_path_from_id(mindmap_id) { return ensure_relative_path_stays_in_root(root, &relative_path); } 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 page_resource_dir = markdown_page_resource_directory(&markdown_file.path).ok_or_else(|| { WebError::bad_request_code( "local_mindmap_bad_markdown_path", "无法解析本地页面思维导图目录", ) })?; Ok(page_resource_dir.join(local_mindmap_file_name(mindmap_id))) } fn local_mindmap_default_data() -> Value { json!({ "data": {"uid": "root", "text": "KMIND"}, "children": [] }) } pub(crate) fn read_local_mindmap_data( root_uri: &str, document_id: &str, mindmap_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}"), ) })?; let metadata = load_local_folder_metadata(&canonical_root)?; let target = resolve_local_mindmap_path(&canonical_root, &metadata, document_id, mindmap_id)?; if !target.exists() { return Ok(local_mindmap_default_data()); } if !target.is_file() { return Err(WebError::bad_request_code( "local_mindmap_not_file", "本地思维导图目标必须是文件", )); } let raw = fs::read_to_string(&target).map_err(|error| { WebError::bad_request_code( "local_mindmap_read_failed", format!("无法读取本地思维导图 {}: {error}", target.display()), ) })?; serde_json::from_str(&raw).map_err(|error| { WebError::bad_request_code( "local_mindmap_invalid", format!("本地思维导图 JSON 损坏 {}: {error}", target.display()), ) }) } pub(crate) fn write_local_mindmap_data( root_uri: &str, document_id: &str, mindmap_id: &str, data: Value, create_only: bool, ) -> 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 target = resolve_local_mindmap_path(&canonical_root, &metadata, document_id, mindmap_id)?; if !target.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_mindmap_root_escape", "本地思维导图路径不能越过 root", )); } let parent = target.parent().ok_or_else(|| { WebError::bad_request_code("local_mindmap_bad_path", "无法解析本地思维导图父目录") })?; fs::create_dir_all(parent).map_err(|error| { WebError::bad_request_code( "local_mindmap_create_dir_failed", format!("无法创建本地思维导图目录 {}: {error}", parent.display()), ) })?; if !(create_only && target.exists()) { write_json_atomic(&target, &data)?; } let relative_path = normalize_relative_path(&canonical_root, &target)?; Ok(json!({ "ok": true, "sourceKind": "local_folder", "documentId": document_id, "mindmapId": mindmap_id, "assetId": format!("local-file:{relative_path}"), "relativePath": relative_path, "fileName": target.file_name().and_then(|value| value.to_str()).unwrap_or("思维导图.json"), })) } 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 result = rename_local_markdown_page(&canonical_root, document_id, title)?; let workspace_id = local_workspace_id(&canonical_root); refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id); Ok(result) } pub fn execute_local_tree_command( root_uri: &str, action: &str, document_id: &str, parent_id: Option<&str>, title: Option<&str>, ) -> Result { execute_local_tree_command_with_sort(root_uri, action, document_id, parent_id, title, None) } pub fn execute_local_tree_command_with_sort( root_uri: &str, action: &str, document_id: &str, parent_id: Option<&str>, title: Option<&str>, sort_order: Option, ) -> 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 result = 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, sort_order), "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}"), )), }?; let result = add_local_file_operation_contract(root_uri, action, result); let workspace_id = local_workspace_id(&canonical_root); refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id); Ok(result) } fn local_tree_parent_relative_path_for_path(relative_path: &str) -> String { let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); if normalized.is_empty() || normalized == "." { return String::new(); } normalized .rsplit_once('/') .map(|(parent, _)| parent.to_string()) .unwrap_or_default() } fn push_local_tree_affected_parent( parents: &mut Vec, seen: &mut BTreeSet, relative_path: String, reason: &str, ) { let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); let key = if normalized == "." { String::new() } else { normalized }; if !seen.insert(key.clone()) { return; } parents.push(AffectedParent { relative_path: key, reason: reason.to_string(), }); } fn local_tree_result_string(result: &Value, key: &str) -> Option { result .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn local_file_operation_hash(values: &[&str]) -> u64 { let mut hasher = DefaultHasher::new(); for value in values { value.hash(&mut hasher); } hasher.finish() } fn local_file_operation_row_id( relative_path: &str, document_id: Option<&str>, resource_kind: Option<&str>, ) -> String { let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); if normalized.is_empty() { return String::new(); } let document_id = document_id.unwrap_or_default(); let resource_kind = resource_kind.unwrap_or_default(); if document_id.starts_with("local-md:") || is_markdown_file(&normalized) { return format!("local:markdown:{normalized}"); } if document_id.starts_with("local-dir:") || resource_kind == "local_directory" || resource_kind == "folder" { return format!("local:folder:{normalized}"); } format!("local:asset:{normalized}") } fn local_file_operation_resource( relative_path: &str, document_id: Option, resource_kind: Option<&str>, ) -> Option { let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); if normalized.is_empty() { return None; } let resource_kind = resource_kind.unwrap_or_default(); let object_kind = if document_id .as_deref() .is_some_and(|value| value.starts_with("local-md:")) || is_markdown_file(&normalized) { KernelObjectKind::Page } else if document_id .as_deref() .is_some_and(|value| value.starts_with("local-dir:")) || resource_kind == "local_directory" || resource_kind == "folder" { KernelObjectKind::Index } else { KernelObjectKind::Attachment }; Some(LocalFileOperationResource { document_id: document_id.clone(), relative_path: normalized, object_identity: KernelObjectIdentity { object_kind, document_id, block_id: None, asset_id: None, }, }) } fn local_tree_previous_relative_path(result: &Value) -> Option { local_tree_result_string(result, "previousRelativePath") .or_else(|| local_tree_result_string(result, "originalRelativePath")) .or_else(|| local_tree_result_string(result, "originalFilePath")) } fn add_local_tree_command_affected_parents(result: &Value, action: &str) -> Vec { let mut parents = Vec::new(); let mut seen = BTreeSet::new(); let normalized_action = action.trim(); if let Some(previous_path) = local_tree_previous_relative_path(result) { push_local_tree_affected_parent( &mut parents, &mut seen, local_tree_parent_relative_path_for_path(&previous_path), "old-parent", ); } if let Some(parent_path) = local_tree_result_string(result, "parentRelativePath") { push_local_tree_affected_parent(&mut parents, &mut seen, parent_path, "parent"); } if normalized_action != "delete" && normalized_action != "trash" && normalized_action != "archive" && normalized_action != "purge" { if let Some(relative_path) = local_tree_result_string(result, "relativePath") { push_local_tree_affected_parent( &mut parents, &mut seen, local_tree_parent_relative_path_for_path(&relative_path), "new-parent", ); } } if let Some(order_path) = local_tree_result_string(result, "orderRelativePath") { push_local_tree_affected_parent( &mut parents, &mut seen, local_tree_parent_relative_path_for_path(&order_path), "order-parent", ); } parents } fn add_local_file_operation_contract(root_uri: &str, action: &str, mut result: Value) -> Value { let resolved_action = local_tree_result_string(&result, "action").unwrap_or_else(|| action.trim().to_string()); let relative_path = local_tree_result_string(&result, "relativePath"); let previous_relative_path = local_tree_previous_relative_path(&result); let document_id = local_tree_result_string(&result, "documentId") .or_else(|| local_tree_result_string(&result, "id")); let previous_document_id = local_tree_result_string(&result, "previousDocumentId").or_else(|| { previous_relative_path .as_deref() .map(local_markdown_path_page_id) }); let resource_kind = local_tree_result_string(&result, "resourceKind"); let affected_parents = add_local_tree_command_affected_parents(&result, &resolved_action); let resource = relative_path.as_deref().and_then(|path| { local_file_operation_resource(path, document_id.clone(), resource_kind.as_deref()) }); let previous_resource = previous_relative_path.as_deref().and_then(|path| { local_file_operation_resource(path, previous_document_id.clone(), resource_kind.as_deref()) }); let is_remove = matches!( resolved_action.as_str(), "delete" | "trash" | "archive" | "purge" ); let reveal_target = if is_remove { None } else { relative_path.as_deref().map(|path| RevealTarget { relative_path: path.to_string(), row_id: local_file_operation_row_id( path, document_id.as_deref(), resource_kind.as_deref(), ), }) }; let select_target = if is_remove { previous_relative_path.as_deref().map(|path| { let parent = local_tree_parent_relative_path_for_path(path); RevealTarget { row_id: local_file_operation_row_id(&parent, None, Some("folder")), relative_path: parent, } }) } else { reveal_target.clone() }; let operation_hash = local_file_operation_hash(&[ &resolved_action, relative_path.as_deref().unwrap_or_default(), previous_relative_path.as_deref().unwrap_or_default(), document_id.as_deref().unwrap_or_default(), ]); let contract = LocalFileOperationResult { schema: "mnote.local_file_operation_result.v1".to_string(), operation_id: format!("local-op-{resolved_action}-{operation_hash:016x}"), batch_id: None, action: resolved_action, root_uri: root_uri.to_string(), resource, previous_resource, affected_parents, reveal_target, select_target, }; if let (Some(object), Ok(Value::Object(contract_object))) = (result.as_object_mut(), serde_json::to_value(contract)) { for (key, value) in contract_object { object.insert(key, value); } } result } /// 生成新页面文件名主体。 /// 默认标题“新页面”会追加本地时间 HHMMSS,例如“新页面061240”。 /// 候选名必须同时检查 `.md` 文件和同名目录,避免页面 bundle 错配。 fn determine_page_stem(directory: &Path, title: &str) -> String { let base = if title.trim() == "新页面" { let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); format!( "新页面{:02}{:02}{:02}", now.hour(), now.minute(), now.second() ) } else { sanitize_file_stem(title, "新页面") }; // 同时检查 .md 文件和同名目录,确保页面 bundle 使用同一个 stem。 let mut candidate = base.clone(); let mut index = 2u32; while directory.join(format!("{}.md", candidate)).exists() || directory.join(&candidate).exists() { candidate = format!("{}-{}", base, index); index += 1; } candidate } /// 生成新文件夹名。 /// 默认标题“新建文件夹”会追加本地时间 HHMMSS,与默认新建页面保持同类命名节奏。 fn determine_folder_stem(directory: &Path, title: &str) -> String { let base = if title.trim() == "新建文件夹" { let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); format!( "新建文件夹{:02}{:02}{:02}", now.hour(), now.minute(), now.second() ) } else { sanitize_file_stem(title, "新建文件夹") }; let mut candidate = base.clone(); let mut index = 2u32; while directory.join(&candidate).exists() || directory.join(format!("{}.md", candidate)).exists() { candidate = format!("{}-{}", base, index); index += 1; } candidate } fn determine_page_stem_for_rename( directory: &Path, title: &str, current_file: &Path, current_directory: Option<&Path>, ) -> String { let base = sanitize_file_stem(title, "新页面"); let mut candidate = base.clone(); let mut index = 2u32; loop { let file_candidate = directory.join(format!("{candidate}.md")); let directory_candidate = directory.join(&candidate); let file_conflict = file_candidate.exists() && file_candidate != current_file; let directory_conflict = directory_candidate.exists() && current_directory .map(|current| directory_candidate != current) .unwrap_or(true); if !file_conflict && !directory_conflict { return candidate; } candidate = format!("{}-{}", base, index); index += 1; } } fn determine_page_bundle_stem_for_rename( directory: &Path, title: &str, current_bundle_directory: &Path, ) -> String { let base = sanitize_file_stem(title, "新页面"); let mut candidate = base.clone(); let mut index = 2u32; loop { let bundle_candidate = directory.join(&candidate); let loose_markdown_candidate = directory.join(format!("{candidate}.md")); let bundle_conflict = bundle_candidate.exists() && bundle_candidate != current_bundle_directory; let loose_markdown_conflict = loose_markdown_candidate.exists(); if !bundle_conflict && !loose_markdown_conflict { return candidate; } candidate = format!("{}-{}", base, index); index += 1; } } fn markdown_file_stem(path: &Path) -> Option { path.file_stem() .and_then(|stem| stem.to_str()) .map(str::trim) .filter(|stem| !stem.is_empty()) .map(ToOwned::to_owned) } fn is_nested_page_bundle_markdown(path: &Path) -> bool { let Some(parent_name) = path .parent() .and_then(|parent| parent.file_name()) .and_then(|name| name.to_str()) else { return false; }; markdown_file_stem(path) .map(|stem| stem == parent_name) .unwrap_or(false) } fn nested_bundle_main_markdown(directory: &Path) -> Option { let name = directory.file_name().and_then(|value| value.to_str())?; let candidate = directory.join(format!("{name}.md")); if candidate.is_file() { Some(candidate) } else { None } } fn markdown_page_bundle_directory(markdown_path: &Path) -> Option { let parent = markdown_path.parent()?; let stem = markdown_file_stem(markdown_path)?; if parent .file_name() .and_then(|name| name.to_str()) .map(|name| name == stem) .unwrap_or(false) { return Some(parent.to_path_buf()); } None } fn markdown_page_resource_directory(markdown_path: &Path) -> Option { let parent = markdown_path.parent()?; let stem = markdown_file_stem(markdown_path)?; if parent .file_name() .and_then(|name| name.to_str()) .map(|name| name == stem) .unwrap_or(false) { return Some(parent.to_path_buf()); } let sibling_dir = parent.join(&stem); if sibling_dir.is_dir() { Some(sibling_dir) } else { Some(parent.join(sanitize_file_name(&stem, "page"))) } } 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 stem = determine_page_stem(&parent_directory, title); let dir_target = parent_directory.join(&stem); let target = dir_target.join(format!("{}.md", stem)); let relative_path = normalize_relative_path(root, &target)?; let page_id = local_markdown_path_page_id(&relative_path); let display_title = file_stem_title(&format!("{stem}.md")); let markdown = ""; fs::create_dir_all(&dir_target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法创建本地页面目录 {}: {error}", dir_target.display()), ) })?; fs::write(&target, markdown).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法创建本地 Markdown 文件 {}: {error}", target.display()), ) })?; Ok(json!({ "ok": true, "id": page_id, "documentId": page_id, "title": display_title, "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 = parent_directory.join(determine_folder_stem(&parent_directory, 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)?), "documentId": format!("local:asset:{}", normalize_relative_path(root, &target)?), "resourceKind": "local_file", "relativePath": normalize_relative_path(root, &target)?, "previousRelativePath": normalize_relative_path(root, file)?, "action": "rename", "sourceKind": "local_folder", })) } fn rename_local_markdown_page( root: &Path, document_id: &str, title: &str, ) -> Result { let 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 父目录") })?; if is_nested_page_bundle_markdown(&markdown_file.path) { return rename_nested_bundle_markdown_page(root, document_id, &markdown_file, title); } let new_stem = determine_page_stem_for_rename(parent, title, &markdown_file.path, None); let target = parent.join(format!("{new_stem}.md")); if target.exists() && target != markdown_file.path { return Err(WebError::bad_request_code( "local_tree_command_failed", format!( "目标 Markdown 文件 {} 已存在,禁止覆盖重命名", target.display() ), )); } 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)?; apply_file_order_path_rewrite(root, &old_relative_path, &new_relative_path)?; let new_document_id = local_markdown_path_page_id(&new_relative_path); Ok(json!({ "ok": true, "id": new_document_id, "documentId": new_document_id, "title": file_stem_title(&format!("{new_stem}.md")), "relativePath": new_relative_path, "previousDocumentId": document_id, "previousRelativePath": old_relative_path, "action": "rename", "sourceKind": "local_folder", })) } fn rename_nested_bundle_markdown_page( root: &Path, document_id: &str, markdown_file: &LocalFolderEntry, title: &str, ) -> Result { let bundle_dir = markdown_file.path.parent().ok_or_else(|| { WebError::bad_request_code("local_tree_command_failed", "无法解析页面目录") })?; if bundle_dir == root { return Err(WebError::bad_request_code( "local_tree_command_failed", "页面目录不能是 workspace root", )); } let bundle_parent = bundle_dir.parent().ok_or_else(|| { WebError::bad_request_code("local_tree_command_failed", "无法解析页面目录父级") })?; let new_stem = determine_page_bundle_stem_for_rename(bundle_parent, title, bundle_dir); let new_bundle_dir = bundle_parent.join(&new_stem); let new_markdown = new_bundle_dir.join(format!("{new_stem}.md")); if new_bundle_dir.exists() && new_bundle_dir != bundle_dir { return Err(WebError::bad_request_code( "local_tree_command_failed", format!( "目标页面目录 {} 已存在,禁止覆盖重命名", new_bundle_dir.display() ), )); } let old_relative_path = markdown_file.relative_path.clone(); if new_bundle_dir == bundle_dir { if new_markdown != markdown_file.path { fs::rename(&markdown_file.path, &new_markdown).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法重命名本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; } } else { fs::rename(bundle_dir, &new_bundle_dir).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法重命名本地页面目录 {}: {error}", bundle_dir.display()), ) })?; let moved_old_markdown = new_bundle_dir.join( markdown_file .path .file_name() .and_then(|name| name.to_str()) .unwrap_or("page.md"), ); if moved_old_markdown != new_markdown { fs::rename(&moved_old_markdown, &new_markdown).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法重命名本地 Markdown 文件 {}: {error}", moved_old_markdown.display() ), ) })?; } } let new_relative_path = normalize_relative_path(root, &new_markdown)?; let old_bundle_relative_path = markdown_file .path .parent() .and_then(|parent| normalize_relative_path(root, parent).ok()) .unwrap_or_else(|| old_relative_path.clone()); let new_bundle_relative_path = normalize_relative_path(root, &new_bundle_dir)?; apply_file_order_path_rewrite(root, &old_bundle_relative_path, &new_bundle_relative_path)?; let new_document_id = local_markdown_path_page_id(&new_relative_path); Ok(json!({ "ok": true, "id": new_document_id, "documentId": new_document_id, "title": file_stem_title(&format!("{new_stem}.md")), "relativePath": new_relative_path, "previousDocumentId": document_id, "previousRelativePath": old_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 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, "文件夹")); 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)?; let old_relative_path = normalize_relative_path(root, directory)?; apply_file_order_path_rewrite(root, &old_relative_path, &new_relative_path)?; Ok(json!({ "ok": true, "id": local_directory_group_id(&new_relative_path), "relativePath": new_relative_path, "previousRelativePath": old_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 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 copy_stem = sanitize_file_stem(copy_stem, "页面"); let target; if let Some(source_bundle) = markdown_page_bundle_directory(&markdown_file.path) { let target_bundle = next_available_directory_path(&target_directory, ©_stem); let target_stem = target_bundle .file_name() .and_then(|name| name.to_str()) .unwrap_or(©_stem) .to_string(); target = target_bundle.join(format!("{target_stem}.md")); copy_directory_recursively(&source_bundle, &target_bundle).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法复制本地页面目录 {} 到 {}: {error}", source_bundle.display(), target_bundle.display() ), ) })?; let copied_main = target_bundle.join( markdown_file .path .file_name() .and_then(|name| name.to_str()) .unwrap_or("page.md"), ); if copied_main != target { fs::rename(&copied_main, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法重命名复制后的 Markdown 文件 {}: {error}", copied_main.display() ), ) })?; } } else { target = next_available_path(&target_directory, ©_stem, "md"); 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, source_markdown).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法复制本地 Markdown 文件到 {}: {error}", target.display()), ) })?; } let new_relative_path = normalize_relative_path(root, &target)?; let new_page_id = local_markdown_path_page_id(&new_relative_path); 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>, sort_order: Option, ) -> Result { let original_relative_path = if let Some(directory) = resolve_local_directory_id(root, document_id)? { Some(normalize_relative_path(root, &directory)?) } else { load_local_folder_metadata(root) .ok() .and_then(|metadata| { find_markdown_by_page_id(root, &metadata, document_id) .ok() .flatten() }) .and_then(|entry| { markdown_page_bundle_directory(&entry.path) .and_then(|directory| normalize_relative_path(root, &directory).ok()) .or(Some(entry.relative_path)) }) }; let result = if let Some(directory) = resolve_local_directory_id(root, document_id)? { move_local_directory(root, &directory, parent_id)? } else if let Some(file) = resolve_local_raw_file_id(root, document_id)? { move_local_raw_file(root, document_id, &file, parent_id)? } else { move_local_markdown_page(root, document_id, parent_id)? }; if let Some(sort_order) = sort_order.filter(|value| *value >= 0) { if let Some(relative_path) = result.get("orderRelativePath").and_then(Value::as_str) { update_local_file_order_after_move( root, original_relative_path.as_deref(), relative_path, sort_order, )?; } } Ok(result) } fn move_local_raw_file( root: &Path, entry_id: &str, file: &Path, parent_id: Option<&str>, ) -> Result { 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 metadata = load_local_folder_metadata(root)?; let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; if file.parent() == Some(target_directory.as_path()) { return Ok(json!({ "ok": true, "id": entry_id, "documentId": entry_id, "resourceKind": "local_file", "relativePath": relative_path, "orderRelativePath": relative_path, "action": "move", "sourceKind": "local_folder", })); } let file_name = file .file_name() .and_then(|name| name.to_str()) .unwrap_or("local-file"); let target = next_available_raw_path(&target_directory, file_name); fs::rename(file, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法移动本地文件 {}: {error}", file.display()), ) })?; let new_relative_path = normalize_relative_path(root, &target)?; let new_id = format!("local:asset:{new_relative_path}"); Ok(json!({ "ok": true, "id": new_id, "documentId": new_id, "resourceKind": "local_file", "relativePath": new_relative_path, "orderRelativePath": new_relative_path, "previousDocumentId": entry_id, "previousRelativePath": relative_path, "action": "move", "sourceKind": "local_folder", })) } fn move_local_markdown_page( root: &Path, document_id: &str, parent_id: Option<&str>, ) -> Result { let 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 let Some(bundle_dir) = markdown_page_bundle_directory(&markdown_file.path) { return move_local_markdown_bundle( root, document_id, &markdown_file, &bundle_dir, &target_directory, ); } 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, "orderRelativePath": 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, "orderRelativePath": 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)?; let new_document_id = local_markdown_path_page_id(&new_relative_path); Ok(json!({ "ok": true, "id": new_document_id, "documentId": new_document_id, "relativePath": new_relative_path, "orderRelativePath": new_relative_path, "previousDocumentId": document_id, "previousRelativePath": old_relative_path, "action": "move", "sourceKind": "local_folder", })) } fn move_local_markdown_bundle( root: &Path, document_id: &str, markdown_file: &LocalFolderEntry, bundle_dir: &Path, target_directory: &Path, ) -> Result { if bundle_dir.parent() == Some(target_directory) { return Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "relativePath": markdown_file.relative_path, "orderRelativePath": normalize_relative_path(root, bundle_dir)?, "action": "move", "sourceKind": "local_folder", })); } if target_directory == bundle_dir || target_directory.starts_with(bundle_dir) { return Err(WebError::bad_request_code( "local_tree_command_failed", "禁止把本地页面移动到自身或后代", )); } let stem = bundle_dir .file_name() .and_then(|name| name.to_str()) .map(|name| sanitize_file_stem(name, "页面")) .unwrap_or_else(|| "页面".to_string()); let target_bundle = next_available_directory_path(target_directory, &stem); fs::rename(bundle_dir, &target_bundle).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法移动本地页面目录 {}: {error}", bundle_dir.display()), ) })?; let main_file_name = markdown_file .path .file_name() .and_then(|name| name.to_str()) .unwrap_or("page.md"); let moved_markdown = target_bundle.join(main_file_name); let target_stem = target_bundle .file_name() .and_then(|name| name.to_str()) .unwrap_or(&stem); let target_markdown = target_bundle.join(format!("{target_stem}.md")); if moved_markdown != target_markdown { fs::rename(&moved_markdown, &target_markdown).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法重命名移动后的 Markdown 文件 {}: {error}", moved_markdown.display() ), ) })?; } let new_relative_path = normalize_relative_path(root, &target_markdown)?; let new_bundle_relative_path = normalize_relative_path(root, &target_bundle)?; let new_document_id = local_markdown_path_page_id(&new_relative_path); Ok(json!({ "ok": true, "id": new_document_id, "documentId": new_document_id, "relativePath": new_relative_path, "orderRelativePath": new_bundle_relative_path, "previousDocumentId": document_id, "previousRelativePath": markdown_file.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 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)?, "orderRelativePath": 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); 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)?; Ok(json!({ "ok": true, "id": local_directory_group_id(&new_relative_path), "relativePath": new_relative_path, "orderRelativePath": new_relative_path, "previousRelativePath": normalize_relative_path(root, directory)?, "action": "move", "sourceKind": "local_folder", })) } fn trash_local_entry(root: &Path, entry_id: &str) -> Result { if let Some(directory) = resolve_local_directory_id(root, entry_id)? { // 若目录是页面 bundle(包含同名 .md),按 Markdown 页面生命周期入 trash if let Some(main_md) = nested_bundle_main_markdown(&directory) { let relative = normalize_relative_path(root, &main_md)?; let page_id = local_markdown_path_page_id(&relative); return trash_local_markdown_page(root, &page_id); } return trash_local_directory(root, entry_id, &directory); } 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 { let metadata = load_local_folder_metadata(root)?; if find_local_directory_trash_entry_key(&metadata, entry_id).is_some() { return restore_local_directory(root, entry_id); } 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 { let metadata = load_local_folder_metadata(root)?; if find_local_directory_trash_entry_key(&metadata, entry_id).is_some() { return purge_local_directory(root, entry_id); } 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 { 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 bundle_dir = markdown_page_bundle_directory(&markdown_file.path); let (source_path, target, resource_kind) = if let Some(bundle_dir) = bundle_dir.as_ref() { if bundle_dir == root { return Err(WebError::bad_request_code( "local_tree_command_failed", "不能删除本地 workspace root", )); } let directory_name = bundle_dir .file_name() .and_then(|name| name.to_str()) .unwrap_or("page"); ( bundle_dir.clone(), next_available_directory_path(&trash_dir, directory_name), "markdown_bundle", ) } else { let file_name = markdown_file .path .file_name() .and_then(|name| name.to_str()) .unwrap_or("page.md"); ( markdown_file.path.clone(), next_available_path(&trash_dir, &file_stem_title(file_name), "md"), "markdown", ) }; fs::rename(&source_path, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法移动到本地回收站 {}: {error}", source_path.display()), ) })?; let original_relative_path = if let Some(bundle_dir) = bundle_dir.as_ref() { normalize_relative_path(root, bundle_dir)? } else { markdown_file.relative_path.clone() }; let trash_relative_path = normalize_relative_path(root, &target)?; remove_file_order_path(&mut metadata.file_order, &original_relative_path); metadata.trash_entries.insert( document_id.to_string(), LocalTrashEntry { document_id: document_id.to_string(), resource_kind: resource_kind.to_string(), resource_scope: "local_folder".to_string(), original_file_path: original_relative_path.clone(), trashed_file_path: trash_relative_path.clone(), trash_entry_id: document_id.to_string(), original_relative_path: original_relative_path.clone(), trash_relative_path: trash_relative_path.clone(), deleted_at_ms: now_ms(), archived_at: now_ms(), purged_at: None, }, ); write_trash_index_metadata(root, &metadata.trash_entries)?; write_file_order_metadata(root, &metadata.file_order)?; Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "resourceKind": resource_kind, "originalRelativePath": original_relative_path, "trashPath": trash_relative_path, "action": "delete", "sourceKind": "local_folder", })) } fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result { 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(); remove_file_order_path(&mut metadata.file_order, &relative_path); 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)?; write_file_order_metadata(root, &metadata.file_order)?; Ok(json!({ "ok": true, "id": entry_id, "documentId": entry_id, "resourceKind": "local_file", "resourceScope": "local_folder", "originalRelativePath": relative_path, "originalFilePath": relative_path, "trashEntryId": trash_entry_id, "trashPath": trash_relative_path, "action": "delete", "canonicalCommand": "tree.resource.archive", "sourceKind": "local_folder", })) } fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> 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 relative_path = normalize_relative_path(root, directory)?; if relative_path == ".mnote" || relative_path.starts_with(".mnote/") { return Err(WebError::bad_request_code( "local_tree_command_failed", "不能删除 MNote 元数据目录", )); } 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 directory_name = directory .file_name() .and_then(|name| name.to_str()) .unwrap_or("local-folder"); let target = next_available_directory_path(&trash_dir, directory_name); fs::rename(directory, &target).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法移动本地文件夹到回收站 {}: {error}", directory.display() ), ) })?; let trash_relative_path = normalize_relative_path(root, &target)?; let trash_entry_id = local_directory_trash_entry_id(&relative_path); let now = now_ms(); remove_file_order_path(&mut metadata.file_order, &relative_path); metadata.trash_entries.insert( trash_entry_id.clone(), LocalTrashEntry { document_id: entry_id.to_string(), resource_kind: "local_directory".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)?; write_file_order_metadata(root, &metadata.file_order)?; Ok(json!({ "ok": true, "id": entry_id, "documentId": entry_id, "resourceKind": "local_directory", "resourceScope": "local_folder", "originalRelativePath": relative_path, "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 { 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_entry.resource_kind == "markdown_bundle" { return restore_local_markdown_bundle( root, document_id, &mut metadata, trash_entry, &trash_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)?; let restored_document_id = local_markdown_path_page_id(&restored_relative_path); metadata.trash_entries.remove(document_id); write_trash_index_metadata(root, &metadata.trash_entries)?; Ok(json!({ "ok": true, "id": restored_document_id, "documentId": restored_document_id, "relativePath": restored_relative_path, "previousDocumentId": document_id, "action": "restore", "sourceKind": "local_folder", })) } fn restore_local_markdown_bundle( root: &Path, document_id: &str, metadata: &mut LocalFolderMetadata, trash_entry: LocalTrashEntry, trash_path: &Path, ) -> Result { if !trash_path.is_dir() { return Err(WebError::bad_request_code( "local_trash_entry_not_found", "本地回收站页面目录不存在,无法恢复", )); } let original_bundle_path = resolve_metadata_relative_path(root, &trash_entry.original_relative_path)?; let parent = original_bundle_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_bundle_path .file_name() .and_then(|name| name.to_str()) .map(|name| sanitize_file_stem(name, "页面")) .unwrap_or_else(|| "页面".to_string()); let target_bundle = if original_bundle_path.exists() { next_available_directory_path(parent, &stem) } else { original_bundle_path }; fs::rename(trash_path, &target_bundle).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法从本地回收站恢复页面目录 {}: {error}", trash_path.display() ), ) })?; let target_stem = target_bundle .file_name() .and_then(|name| name.to_str()) .unwrap_or(&stem); let restored_markdown = target_bundle.join(format!("{target_stem}.md")); if !restored_markdown.is_file() { return Err(WebError::bad_request_code( "local_trash_entry_not_found", "恢复后的页面目录缺少同名 Markdown 文件", )); } let restored_relative_path = normalize_relative_path(root, &restored_markdown)?; let restored_document_id = local_markdown_path_page_id(&restored_relative_path); metadata.trash_entries.remove(document_id); write_trash_index_metadata(root, &metadata.trash_entries)?; Ok(json!({ "ok": true, "id": restored_document_id, "documentId": restored_document_id, "relativePath": restored_relative_path, "previousDocumentId": document_id, "action": "restore", "sourceKind": "local_folder", })) } fn restore_local_raw_file(root: &Path, entry_id: &str) -> Result { 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 restore_local_directory(root: &Path, entry_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let trash_key = find_local_directory_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_dir() { 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_directory_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_directory", "resourceScope": "local_folder", "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 { let mut metadata = load_local_folder_metadata(root)?; if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? { let order_relative_path = markdown_page_bundle_directory(&markdown_file.path) .and_then(|bundle_dir| normalize_relative_path(root, &bundle_dir).ok()) .unwrap_or_else(|| markdown_file.relative_path.clone()); if let Some(bundle_dir) = markdown_page_bundle_directory(&markdown_file.path) { fs::remove_dir_all(&bundle_dir).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!("无法永久删除本地页面目录 {}: {error}", bundle_dir.display()), ) })?; } else { fs::remove_file(&markdown_file.path).map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法永久删除本地 Markdown 文件 {}: {error}", markdown_file.path.display() ), ) })?; } remove_file_order_path(&mut metadata.file_order, &order_relative_path); write_file_order_metadata(root, &metadata.file_order)?; return Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "originalRelativePath": order_relative_path, "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 页面", ) })?; remove_file_order_path( &mut metadata.file_order, &trash_entry.original_relative_path, ); let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?; if trash_path.exists() { let remove_result = if trash_path.is_dir() { fs::remove_dir_all(&trash_path) } else { fs::remove_file(&trash_path) }; remove_result.map_err(|error| { WebError::bad_request_code( "local_tree_command_failed", format!( "无法永久删除本地回收站对象 {}: {error}", trash_path.display() ), ) })?; } write_trash_index_metadata(root, &metadata.trash_entries)?; write_file_order_metadata(root, &metadata.file_order)?; Ok(json!({ "ok": true, "id": document_id, "documentId": document_id, "originalRelativePath": trash_entry.original_relative_path, "action": "purge", "sourceKind": "local_folder", })) } fn purge_local_directory(root: &Path, entry_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let trash_key = find_local_directory_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", "找不到要永久删除的本地文件夹回收站记录", ) })?; remove_file_order_path( &mut metadata.file_order, &trash_entry.original_relative_path, ); 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_dir_all(&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)?; write_file_order_metadata(root, &metadata.file_order)?; Ok(json!({ "ok": true, "id": entry_id, "documentId": entry_id, "resourceKind": "local_directory", "resourceScope": "local_folder", "originalRelativePath": trash_entry.original_relative_path, "trashEntryId": trash_key, "action": "purge", "canonicalCommand": "tree.resource.purge", "sourceKind": "local_folder", })) } fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result { 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", "找不到要永久删除的本地资源回收站记录", ) })?; remove_file_order_path( &mut metadata.file_order, &trash_entry.original_relative_path, ); 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", "originalRelativePath": trash_entry.original_relative_path, "trashEntryId": trash_key, "action": "purge", "canonicalCommand": "tree.resource.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-file:") .or_else(|| trimmed.strip_prefix("local:asset:")) .or_else(|| trimmed.strip_prefix("local:node:")) 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", "本地文件不存在", )) } } fn local_file_trash_entry_id(relative_path: &str) -> String { format!("local-file:{relative_path}") } fn local_directory_trash_entry_id(relative_path: &str) -> String { format!("local-dir-trash:{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 is_local_directory_trash_entry(entry: &LocalTrashEntry) -> bool { entry.resource_kind == "local_directory" || entry.trash_entry_id.starts_with("local-dir-trash:") } 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 { 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()) } fn find_local_directory_trash_entry_key( metadata: &LocalFolderMetadata, entry_id: &str, ) -> Option { let trimmed = entry_id.trim(); if metadata .trash_entries .get(trimmed) .map(is_local_directory_trash_entry) .unwrap_or(false) { return Some(trimmed.to_string()); } let relative_path = if let Some(encoded) = trimmed.strip_prefix("local-dir:") { decode_local_id_segment(encoded).ok()? } else { trimmed .strip_prefix("local:node:") .or_else(|| trimmed.strip_prefix("local:folder:")) .unwrap_or(trimmed) .to_string() }; let expected_key = local_directory_trash_entry_id(&relative_path); if metadata .trash_entries .get(&expected_key) .map(is_local_directory_trash_entry) .unwrap_or(false) { return Some(expected_key); } metadata .trash_entries .iter() .find(|(_, entry)| { is_local_directory_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 { 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)) } pub(crate) fn local_root_has_workspace_manifest(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(load_local_workspace_manifest(&canonical_root).is_ok()) } 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_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"))?, uploaded_assets: load_uploaded_asset_index_map( &root.join(".mnote").join("uploaded-assets.json"), )?, file_order: load_file_order_metadata(&root.join(".mnote").join("file-order.json"))?, }) } pub(crate) fn load_local_trash_entries( root_uri: &str, ) -> Result, WebError> { 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 必须指向目录", )); } Ok(load_local_folder_metadata(&canonical_root)?.trash_entries) } 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 load_uploaded_asset_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 load_file_order_metadata(path: &Path) -> Result>, WebError> { if !path.exists() { return Ok(BTreeMap::new()); } let value = read_metadata_json(path)?; let source = value.get("parents").unwrap_or(&value); let orders: BTreeMap> = serde_json::from_value(source.clone()) .map_err(|error| metadata_invalid(path, format!("文件树排序索引损坏: {error}")))?; Ok(orders .into_iter() .map(|(parent, children)| { ( normalize_file_order_parent_key(&parent), normalize_file_order_children(children), ) }) .collect()) } 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", )); } Ok(local_markdown_path_page_id(relative_path)) } 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_uploaded_asset_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("uploaded-assets.json"); let value = json!({ "version": 1, "entries": entries, }); write_json_atomic(&path, &value) } fn write_file_order_metadata( root: &Path, file_order: &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("file-order.json"); let value = json!({ "version": 1, "parents": file_order, }); 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, max_depth: Option, root_source_uri: &str, workspace_id: &str, metadata: &LocalFolderMetadata, rows: &mut Vec, ) -> Result<(), WebError> { let mut entries = read_sorted_entries(directory, root)?; let parent_key = file_order_parent_key_for_directory(root, directory)?; sort_entries_with_file_order(&mut entries, metadata, &parent_key); 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 { if has_visible_child(&entry.path, root)? { 1 } else { 0 } } else { 0 }; let uploaded_asset = metadata.uploaded_assets.get(&entry.relative_path); let row_kind = if entry.is_dir { "folder".to_string() } else if uploaded_asset.is_some() { "asset".to_string() } else if is_markdown_file(&entry.file_name) { "markdown".to_string() } else { "asset".to_string() }; let document_id = local_folder_entry_document_id(root, &entry, uploaded_asset); 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: false, document_id, capabilities: local_entry_capabilities(&entry), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), }); if entry.is_dir && !entry.is_symlink && max_depth.map_or(true, |limit| depth < limit) { scan_directory( root, &entry.path, Some(node_id), depth + 1, max_depth, root_source_uri, workspace_id, metadata, rows, )?; } } Ok(()) } fn scan_directory_shallow_with_revision( root: &Path, directory: &Path, parent_node_id: Option, depth: u32, root_source_uri: &str, workspace_id: &str, metadata: &LocalFolderMetadata, ) -> Result { let mut entries = read_sorted_entries(directory, root)?; let parent_key = file_order_parent_key_for_directory(root, directory)?; sort_entries_with_file_order(&mut entries, metadata, &parent_key); let mut rows = Vec::new(); let mut hasher = DefaultHasher::new(); let mut latest_modified_ms = 0u128; for (position, entry) in entries.iter().enumerate() { entry.relative_path.hash(&mut hasher); entry.file_name.hash(&mut hasher); entry.is_dir.hash(&mut hasher); entry.is_symlink.hash(&mut hasher); entry.is_readonly.hash(&mut hasher); if let Ok(meta) = fs::symlink_metadata(&entry.path) { meta.len().hash(&mut 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(&mut hasher); if modified_ms > latest_modified_ms { latest_modified_ms = modified_ms; } } } } 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 { if has_visible_child(&entry.path, root)? { 1 } else { 0 } } else { 0 }; let uploaded_asset = metadata.uploaded_assets.get(&entry.relative_path); let row_kind = if entry.is_dir { "folder".to_string() } else if uploaded_asset.is_some() { "asset".to_string() } else if is_markdown_file(&entry.file_name) { "markdown".to_string() } else { "asset".to_string() }; let document_id = local_folder_entry_document_id(root, entry, uploaded_asset); 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: false, document_id, capabilities: local_entry_capabilities(entry), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), }); } Ok(LocalFolderScanResult { watch_revision: LocalFolderWatchRevision { root_uri: root_source_uri.to_string(), revision: format!("{:016x}", hasher.finish()), entry_count: rows.len(), latest_modified_ms, }, rows, }) } fn local_folder_entry_document_id( root: &Path, entry: &LocalFolderEntry, uploaded_asset: Option<&LocalUploadedAssetEntry>, ) -> Option { if let Some(asset) = uploaded_asset { return Some(asset.document_id.clone()); } if is_markdown_file(&entry.file_name) { return Some(local_markdown_path_page_id(&entry.relative_path)); } if entry.is_dir && !entry.is_symlink { if let Some(markdown_path) = nested_bundle_main_markdown(&entry.path) { if let Ok(relative_path) = normalize_relative_path(root, &markdown_path) { return Some(local_markdown_path_page_id(&relative_path)); } } } None } fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option { let encoded = document_id.trim().strip_prefix("local-md:")?; decode_local_id_segment(encoded).ok() } fn ancestor_directories_for_relative_path(relative_path: &str) -> Vec { let normalized = relative_path.trim().trim_matches('/'); if normalized.is_empty() || normalized == "." { return Vec::new(); } let mut ancestors = Vec::new(); let mut current = Path::new(normalized).parent(); while let Some(parent) = current { let value = parent .components() .map(|component| component.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/"); if value.is_empty() || value == "." { break; } ancestors.push(value); current = parent.parent(); } ancestors.reverse(); ancestors } fn append_file_tree_reveal_rows( root: &Path, reveal_relative_path: Option<&str>, root_source_uri: &str, workspace_id: &str, metadata: &LocalFolderMetadata, rows: &mut Vec, ) -> Result<(), WebError> { let Some(reveal_relative_path) = reveal_relative_path .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(()); }; let mut ancestors = ancestor_directories_for_relative_path(reveal_relative_path); if let Some(bundle_parent) = same_name_markdown_bundle_parent(reveal_relative_path) { ancestors.retain(|ancestor| ancestor != &bundle_parent); } if ancestors.is_empty() { return Ok(()); } let ancestor_set = ancestors.iter().cloned().collect::>(); let mut seen = rows .iter() .map(|row| row.row_id.clone()) .collect::>(); for ancestor in &ancestors { let directory = resolve_metadata_relative_path(root, ancestor)?; if !directory.is_dir() { continue; } let depth = local_folder_relative_depth(ancestor); let mut scoped_rows = Vec::new(); scan_directory( root, &directory, Some(local_node_id(ancestor)), depth, Some(depth), root_source_uri, workspace_id, metadata, &mut scoped_rows, )?; for row in scoped_rows { if seen.insert(row.row_id.clone()) { rows.push(row); } } } for row in rows.iter_mut() { if ancestor_set.contains(&row.relative_path) { row.expanded_by_default = true; } } Ok(()) } fn same_name_markdown_bundle_parent(relative_path: &str) -> Option { let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); if !normalized.to_ascii_lowercase().ends_with(".md") { return None; } let (parent, file_name) = normalized.rsplit_once('/')?; if parent.is_empty() { return None; } let parent_name = parent.rsplit('/').next().unwrap_or(parent); let file_stem = &file_name[..file_name.len().saturating_sub(3)]; if file_stem == parent_name { Some(parent.to_string()) } else { None } } 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 has_visible_child(directory: &Path, root: &Path) -> Result { let read_dir = fs::read_dir(directory).map_err(|error| { WebError::bad_request_code( "local_folder_scan_failed", format!("无法读取本地目录 {}: {error}", directory.display()), ) })?; 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 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) { return Ok(true); } } Ok(false) } 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 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 local_folder_relative_depth(relative_path: &str) -> u32 { let trimmed = relative_path.trim().trim_matches('/'); if trimmed.is_empty() || trimmed == "." { return 0; } trimmed .split('/') .filter(|segment| !segment.trim().is_empty()) .count() as u32 } 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 normalize_file_order_parent_key(parent: &str) -> String { let trimmed = parent.trim().trim_matches('/'); if trimmed.is_empty() || trimmed == "." { ".".to_string() } else { trimmed.to_string() } } fn normalize_file_order_children(children: Vec) -> Vec { let mut seen = std::collections::BTreeSet::new(); children .into_iter() .map(|child| child.trim().trim_matches('/').to_string()) .filter(|child| !child.is_empty() && seen.insert(child.clone())) .collect() } fn file_order_parent_key_for_directory(root: &Path, directory: &Path) -> Result { if directory == root { return Ok(".".to_string()); } Ok(normalize_file_order_parent_key(&normalize_relative_path( root, directory, )?)) } fn parent_key_for_relative_path(relative_path: &str) -> String { Path::new(relative_path) .parent() .and_then(|parent| { let value = parent .components() .map(|component| component.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/"); if value.is_empty() { None } else { Some(value) } }) .map(|value| normalize_file_order_parent_key(&value)) .unwrap_or_else(|| ".".to_string()) } fn sort_entries_with_file_order( entries: &mut [LocalFolderEntry], metadata: &LocalFolderMetadata, parent_key: &str, ) { let Some(order) = metadata.file_order.get(parent_key) else { return; }; let index = order .iter() .enumerate() .map(|(position, relative_path)| (relative_path.as_str(), position)) .collect::>(); entries.sort_by(|a, b| { let a_index = index.get(a.relative_path.as_str()).copied(); let b_index = index.get(b.relative_path.as_str()).copied(); match (a_index, b_index) { (Some(left), Some(right)) => left.cmp(&right), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => compare_local_entries(a, b), } }); } fn ordered_child_paths_for_parent( root: &Path, parent_directory: &Path, ) -> Result, WebError> { let mut entries = read_sorted_entries(parent_directory, root)?; let parent_key = file_order_parent_key_for_directory(root, parent_directory)?; let metadata = load_local_folder_metadata(root)?; sort_entries_with_file_order(&mut entries, &metadata, &parent_key); Ok(entries .into_iter() .map(|entry| entry.relative_path) .collect::>()) } fn reorder_child_paths(children: &mut Vec, child_path: &str, sort_order: i64) { children.retain(|candidate| candidate != child_path); let index = usize::try_from(sort_order) .unwrap_or(usize::MAX) .min(children.len()); children.insert(index, child_path.to_string()); } fn update_local_file_order_after_move( root: &Path, original_relative_path: Option<&str>, new_relative_path: &str, sort_order: i64, ) -> Result<(), WebError> { let mut metadata = load_local_folder_metadata(root)?; if let Some(original) = original_relative_path .map(str::trim) .filter(|value| !value.is_empty()) { let original_parent = parent_key_for_relative_path(original); if original_parent != parent_key_for_relative_path(new_relative_path) { if let Some(children) = metadata.file_order.get_mut(&original_parent) { children.retain(|candidate| candidate != original); } } } let parent_key = parent_key_for_relative_path(new_relative_path); let parent_directory = if parent_key == "." { root.to_path_buf() } else { resolve_metadata_relative_path(root, &parent_key)? }; let mut children = ordered_child_paths_for_parent(root, &parent_directory)?; reorder_child_paths(&mut children, new_relative_path, sort_order); metadata.file_order.insert(parent_key, children); write_file_order_metadata(root, &metadata.file_order) } fn rewrite_file_order_path( file_order: &mut BTreeMap>, old_relative_path: &str, new_relative_path: &str, ) { for children in file_order.values_mut() { for child in children.iter_mut() { if child == old_relative_path { *child = new_relative_path.to_string(); } else if child.starts_with(&format!("{old_relative_path}/")) { *child = format!("{}{}", new_relative_path, &child[old_relative_path.len()..]); } } } let old_parent_prefix = format!("{old_relative_path}/"); let parent_rewrites = file_order .keys() .filter_map(|parent| { if parent == old_relative_path { Some((parent.clone(), new_relative_path.to_string())) } else if parent.starts_with(&old_parent_prefix) { Some(( parent.clone(), format!( "{}{}", new_relative_path, &parent[old_relative_path.len()..] ), )) } else { None } }) .collect::>(); for (old_parent, new_parent) in parent_rewrites { if let Some(children) = file_order.remove(&old_parent) { file_order.insert(new_parent, children); } } } fn remove_file_order_path(file_order: &mut BTreeMap>, relative_path: &str) { let child_prefix = format!("{relative_path}/"); for children in file_order.values_mut() { children.retain(|child| child != relative_path && !child.starts_with(&child_prefix)); } let parent_keys = file_order .keys() .filter(|parent| parent.as_str() == relative_path || parent.starts_with(&child_prefix)) .cloned() .collect::>(); for parent in parent_keys { file_order.remove(&parent); } } fn apply_file_order_path_rewrite( root: &Path, old_relative_path: &str, new_relative_path: &str, ) -> Result<(), WebError> { let mut metadata = load_local_folder_metadata(root)?; rewrite_file_order_path( &mut metadata.file_order, old_relative_path, new_relative_path, ); write_file_order_metadata(root, &metadata.file_order) } fn scan_markdown_page_tree( root: &Path, directory: &Path, parent_node_id: Option, depth: u32, skip_markdown_relative_path: Option, metadata: &LocalFolderMetadata, rows: &mut Vec, workspace_id: &str, root_source_uri: &str, ) -> Result { let mut entries = read_sorted_entries(directory, root)?; let parent_key = file_order_parent_key_for_directory(root, directory)?; sort_entries_with_file_order(&mut entries, metadata, &parent_key); let mut directory_rows = Vec::::new(); let mut contains_markdown = false; for (position, entry) in entries.into_iter().enumerate() { if skip_markdown_relative_path .as_deref() .map(|skip| skip == entry.relative_path) .unwrap_or(false) { continue; } if entry.is_dir && !entry.is_symlink { if let Some(nested_main) = nested_bundle_main_markdown(&entry.path) { let nested_relative = normalize_relative_path(root, &nested_main)?; let markdown = fs::read_to_string(&nested_main).unwrap_or_default(); let parsed = parse_markdown_page( &markdown, nested_main .file_name() .and_then(|name| name.to_str()) .unwrap_or("page.md"), ); let page_id = local_markdown_path_page_id(&nested_relative); let mut child_rows = Vec::new(); let child_contains_markdown = scan_markdown_page_tree( root, &entry.path, Some(page_id.clone()), depth + 1, Some(nested_relative.clone()), metadata, &mut child_rows, workspace_id, root_source_uri, )?; let child_count = child_rows.len() as u32; directory_rows.push(LocalFolderRow { node_id: page_id.clone(), row_id: format!("local:page:{nested_relative}"), 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: nested_relative, source_uri: file_uri_for_path(&nested_main), child_count, expandable: child_contains_markdown, expanded_by_default: child_contains_markdown && depth < 2, document_id: Some(page_id), capabilities: local_entry_capabilities(&entry), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), }); directory_rows.extend(child_rows); contains_markdown = true; continue; } // VSCode 风格 loose Markdown 也可作为页面节点管理; // 若同级存在同名目录,该目录中的 Markdown 作为该页面的子页面投影。 let sibling_md = directory.join(format!("{}.md", entry.file_name)); let has_sibling_md = sibling_md.exists() && sibling_md.starts_with(root) && sibling_md.is_file(); let parent_for_children = if has_sibling_md { let sibling_relative = normalize_relative_path(root, &sibling_md)?; Some(local_markdown_path_page_id(&sibling_relative)) } else { Some(local_directory_group_id(&entry.relative_path)) }; let mut child_rows = Vec::new(); let child_contains_markdown = scan_markdown_page_tree( root, &entry.path, parent_for_children, depth + 1, None, metadata, &mut child_rows, workspace_id, root_source_uri, )?; if child_contains_markdown { if !has_sibling_md { directory_rows.push(LocalFolderRow { node_id: local_directory_group_id(&entry.relative_path), 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), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), }); } directory_rows.extend(child_rows); contains_markdown = true; } continue; } let uploaded_asset = metadata.uploaded_assets.get(&entry.relative_path); if let Some(asset) = uploaded_asset { directory_rows.push(LocalFolderRow { node_id: local_node_id(&entry.relative_path), row_id: format!("local:asset:{}", entry.relative_path), parent_node_id: parent_node_id.clone(), title: entry.file_name.clone(), depth, position: position as u32, row_kind: "asset".to_string(), icon_hint: icon_hint_for_entry(&entry), 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(asset.document_id.clone()), capabilities: local_entry_capabilities(&entry), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), }); 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_path_page_id(&entry.relative_path); let md_stem = entry .path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let sibling_dir = directory.join(&md_stem); let has_sibling_dir = sibling_dir.is_dir() && sibling_dir.starts_with(root); 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: has_sibling_dir, expanded_by_default: has_sibling_dir && depth < 2, document_id: Some(page_id), capabilities: local_entry_capabilities(&entry), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), }); 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> { if let Some(entry) = find_markdown_by_path_document_id(root, document_id)? { return Ok(Some(entry)); } 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 path_page_id = local_markdown_path_page_id(&entry.relative_path); if path_page_id == document_id { return Ok(Some(entry)); } } Ok(None) } walk(root, root, metadata, document_id) } fn find_markdown_by_path_document_id( root: &Path, document_id: &str, ) -> Result, WebError> { let Some(relative_path) = local_markdown_relative_path_from_document_id(document_id) else { return Ok(None); }; let target = resolve_metadata_relative_path(root, &relative_path)?; let metadata = match fs::symlink_metadata(&target) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => { return Err(WebError::bad_request_code( "local_folder_scan_failed", format!("无法读取本地 Markdown 元数据 {}: {error}", target.display()), )); } }; let file_name = target .file_name() .and_then(|value| value.to_str()) .unwrap_or_default() .to_string(); let normalized_relative_path = normalize_relative_path(root, &target)?; if metadata.is_dir() || should_ignore_entry(&normalized_relative_path, &file_name) || !is_markdown_file(&file_name) || local_markdown_path_page_id(&normalized_relative_path) != document_id { return Ok(None); } Ok(Some(LocalFolderEntry { path: target, relative_path: normalized_relative_path, file_name, is_dir: false, is_symlink: metadata.file_type().is_symlink(), is_readonly: metadata.permissions().readonly(), })) } fn uploaded_asset_markdown_relative_paths_for_document( root: &Path, metadata: &LocalFolderMetadata, document_id: &str, markdown_path: &Path, ) -> BTreeSet { let Some(markdown_dir) = markdown_path.parent() else { return BTreeSet::new(); }; metadata .uploaded_assets .values() .filter(|entry| entry.document_id == document_id) .filter_map(|entry| { let path = root.join(&entry.relative_path); normalize_markdown_relative_asset_path(markdown_dir, &path).ok() }) .collect() } 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["documentId"] = Value::String(document_id.clone()); item["resourceMeta"]["documentId"] = Value::String(document_id.clone()); } if row.row_kind == "asset" && !row.relative_path.trim().is_empty() { let asset_id = format!("local-file:{}", row.relative_path); item["assetId"] = Value::String(asset_id.clone()); item["resourceMeta"]["assetId"] = Value::String(asset_id); } // Phase A1: 为 File/Page/Resource tree 输出统一 workspacePath let object_kind = match row.row_kind.as_str() { "markdown" | "document" => KernelObjectKind::Page, "folder" | "index" => { if row .document_id .as_deref() .is_some_and(|value| value.starts_with("local-md:")) { KernelObjectKind::Page } else { KernelObjectKind::Index } } "asset" => match row.icon_hint.as_str() { "mindmap" => KernelObjectKind::Mindmap, "onlyoffice" | "office" => KernelObjectKind::OnlyOffice, _ => KernelObjectKind::Attachment, }, _ => KernelObjectKind::Attachment, }; let workspace_path = ObjectWorkspacePath { workspace_id: row.workspace_id.clone(), source_kind: WorkspaceSourceKind::LocalFolder, root_uri: row.root_source_uri.clone(), relative_path: row.relative_path.clone(), object_identity: KernelObjectIdentity { object_kind, document_id: row.document_id.clone(), block_id: None, asset_id: None, }, resource_kind: Some(row.row_kind.clone()), }; if let Ok(path_value) = serde_json::to_value(&workspace_path) { item["resourceMeta"]["workspacePath"] = path_value; } 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_path_page_id(relative_path: &str) -> String { format!("local-md:{}", encode_local_id_segment(relative_path)) } 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; } if let Some(hide_title_header) = page_options_hide_title_header_value(value) { options.hide_title_header = hide_title_header; } options } fn page_options_hide_title_header_value(value: &Value) -> Option { value .get("hideTitleHeader") .or_else(|| value.get("hide_title_header")) .and_then(Value::as_bool) } pub(crate) fn local_workspace_id(root: &Path) -> String { if let Ok(manifest) = load_local_workspace_manifest(root) { if !manifest.workspace_id.trim().is_empty() { return manifest.workspace_id; } } 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 is_local_mindmap_file_name(&entry.file_name) { "mindmap" } else 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( "doc" | "docx" | "odt" | "rtf" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" | "csv" ) ) { "office" } else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) { "book" } 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) } pub(crate) 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 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 copy_directory_recursively(source: &Path, target: &Path) -> std::io::Result<()> { fs::create_dir_all(target)?; for entry in fs::read_dir(source)? { let entry = entry?; let source_path = entry.path(); let target_path = target.join(entry.file_name()); let metadata = fs::symlink_metadata(&source_path)?; if metadata.file_type().is_symlink() { continue; } if metadata.is_dir() { copy_directory_recursively(&source_path, &target_path)?; } else if metadata.is_file() { fs::copy(&source_path, &target_path)?; } } Ok(()) } 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_asset_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 1..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 normalize_markdown_relative_asset_path( markdown_dir: &Path, asset_path: &Path, ) -> Result { let relative = asset_path.strip_prefix(markdown_dir).map_err(|_| { WebError::bad_request_code( "local_asset_upload_relative_path_failed", "本地资源文件不在 Markdown 文件目录内", ) })?; Ok(relative .components() .map(|component| component.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/")) } fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str { let normalized_kind = kind.trim().to_ascii_lowercase(); if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") { "image" } else { "file" } } 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())) } // 过渡实现:写侧手写 Markdown 回写入口函数。当前通过 editor_blocks_to_markdown_with_rewrite // 将 editor block document 转为 Markdown 文本,AST 迁移 complete 后应替换为 // AST→Markdown 反向映射(见 design/03-rust-web/process/3-13 方案 Step 3)。 fn editor_blocks_to_markdown_for_file( content: &Value, root: &Path, markdown_path: &Path, ) -> String { editor_blocks_to_markdown_with_rewrite(content, Some((root, markdown_path))) } fn local_resource_write_editor_blocks(request: &LocalResourceWriteRequest) -> Value { let format = request .content_format .as_deref() .unwrap_or_default() .trim() .to_ascii_lowercase(); let source = if request.content.is_null() { request.tiptap_document.as_ref().unwrap_or(&Value::Null) } else { &request.content }; if format == "tiptapdocument" || is_tiptap_document(source) { tiptap_document_to_editor_blocks(source) } else { source.clone() } } fn is_tiptap_document(value: &Value) -> bool { value.get("type").and_then(Value::as_str) == Some("doc") && value.get("content").and_then(Value::as_array).is_some() } fn tiptap_document_to_editor_blocks(value: &Value) -> Value { let nodes = value .get("content") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let blocks = nodes .iter() .enumerate() .map(|(index, node)| { let node_type = node .get("type") .and_then(Value::as_str) .unwrap_or("paragraph"); let content = node.get("content").cloned().unwrap_or_else(|| json!([])); let text = extract_block_text(&content); let block_type = match node_type { "heading" => "heading", "codeBlock" | "code_block" | "code" => "codeBlock", "blockquote" => "quote", "bulletListItem" | "listItem" => "bulletListItem", "orderedListItem" | "numberedListItem" => "numberedListItem", _ => "paragraph", }; let mut block = json!({ "id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(&format!("{node_type}:{text}"))), "type": block_type, "content": content, }); if node_type == "heading" { let level = node .get("attrs") .and_then(|attrs| attrs.get("level")) .and_then(Value::as_u64) .unwrap_or(1) .clamp(1, 6); block["props"] = json!({ "level": level }); } block }) .collect::>(); Value::Array(blocks) } fn text_to_editor_blocks(text: &str, file_name: &str) -> Value { if is_code_like_file(file_name) { return json!([{ "id": "resource-block-1", "type": "code_block", "content": text, "props": { "language": file_extension(file_name).unwrap_or_default() } }]); } let blocks = text .split('\n') .enumerate() .map(|(index, line)| { json!({ "id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(line)), "type": "paragraph", "content": line }) }) .collect::>(); Value::Array(blocks) } fn editor_blocks_to_plain_text(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 text = blocks .iter() .map(extract_block_text) .collect::>() .join("\n"); if text.ends_with('\n') { text } else { format!("{text}\n") } } fn file_extension(file_name: &str) -> Option { Path::new(file_name) .extension() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) } fn is_code_like_file(file_name: &str) -> bool { matches!( file_extension(file_name).as_deref(), Some( "rs" | "ts" | "tsx" | "js" | "jsx" | "json" | "css" | "scss" | "html" | "xml" | "py" | "go" | "java" | "kt" | "swift" | "c" | "h" | "cpp" | "hpp" | "sh" | "bash" | "zsh" | "toml" | "yaml" | "yml" | "sql" ) ) } fn stable_hash_hex(value: &str) -> String { let mut hasher = DefaultHasher::new(); value.hash(&mut hasher); format!("{:016x}", hasher.finish()) } // 过渡实现:写侧手写 Markdown 回写核心函数。逐 block 遍历,通过 // inline_nodes_to_markdown 将行内节点转为 Markdown 文本。 // AST 迁移 complete 后应替换为 block→Markdown AST→文本 的两次映射。 fn editor_blocks_to_markdown_with_rewrite( content: &Value, local_file_context: Option<(&Path, &Path)>, ) -> 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(|content| inline_nodes_to_markdown(content, local_file_context)) .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" | "image" | "mindmap" | "table" ) { lines.push(String::new()); continue; } match block_type { "image" => { let props = block.get("props").and_then(Value::as_object); let src = props .and_then(|props| props.get("src").or_else(|| props.get("sourcePath"))) .and_then(Value::as_str) .unwrap_or("") .trim(); let alt = props .and_then(|props| props.get("alt").or_else(|| props.get("title"))) .and_then(Value::as_str) .unwrap_or(text.as_str()) .trim(); if src.is_empty() { lines.push(text); } else { let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context) .unwrap_or_else(|| src.to_string()); lines.push(format!( "![{}]({})", alt.replace(']', r"\]"), markdown_link_target(&src) )); } } "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 { let url = rewrite_local_open_url_to_markdown_relative(url, local_file_context) .unwrap_or_else(|| url.to_string()); let label = if name.is_empty() { url.as_str() } else { name }; lines.push(format!("[{}]({})", label, markdown_link_target(&url))); } } "mindmap" => { let props = block.get("props").and_then(Value::as_object); let source = props .and_then(|props| { props .get("sourcePath") .or_else(|| props.get("mindmapId")) .or_else(|| props.get("mindmap_id")) }) .and_then(Value::as_str) .unwrap_or("") .trim(); let label = props .and_then(|props| props.get("name").or_else(|| props.get("title"))) .and_then(Value::as_str) .unwrap_or("思维导图") .trim(); if source.is_empty() { lines.push(text); } else { let source = rewrite_local_mindmap_source_to_markdown_relative( source, local_file_context, ) .unwrap_or_else(|| { if is_local_mindmap_file_name(source) || source.contains('/') { source.to_string() } else { local_mindmap_file_name(source) } }); let label = if label.is_empty() { "思维导图" } else { label }; lines.push(format!("[{}]({})", label, markdown_link_target(&source))); } } "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 markdown_link_target(target: &str) -> String { let trimmed = target.trim(); if trimmed.is_empty() { return String::new(); } if trimmed .chars() .any(|ch| ch.is_whitespace() || matches!(ch, '(' | ')' | '<' | '>')) { format!("<{}>", trimmed.replace('>', "%3E")) } else { trimmed.to_string() } } fn rewrite_local_mindmap_source_to_markdown_relative( value: &str, local_file_context: Option<(&Path, &Path)>, ) -> Option { if let Some(relative_path) = local_mindmap_relative_path_from_id(value) { let (root, markdown_path) = local_file_context?; let asset_path = root.canonicalize().ok()?.join(relative_path); let markdown_dir = markdown_path.parent()?; return normalize_markdown_relative_asset_path(markdown_dir, &asset_path).ok(); } rewrite_local_open_url_to_markdown_relative(value, local_file_context) } fn rewrite_local_open_url_to_markdown_relative( value: &str, local_file_context: Option<(&Path, &Path)>, ) -> Option { let (root, markdown_path) = local_file_context?; let parsed = if value.starts_with('/') { Url::parse(&format!("http://localhost{}", value)).ok()? } else { Url::parse(value).ok()? }; if parsed.path() != "/api/local-folder/files/open" { return None; } let root_uri = parsed .query_pairs() .find(|(key, _)| key == "rootUri") .map(|(_, value)| value.into_owned())?; let asset_relative_path = parsed .query_pairs() .find(|(key, _)| key == "path") .map(|(_, value)| value.into_owned())?; let parsed_root = parse_file_root_uri(&root_uri).ok()?.canonicalize().ok()?; let canonical_root = root.canonicalize().ok()?; if parsed_root != canonical_root { return None; } let asset_path = canonical_root.join(asset_relative_path); let markdown_dir = markdown_path.parent()?; normalize_markdown_relative_asset_path(markdown_dir, &asset_path).ok() } fn content_type_for_path(path: &Path) -> HeaderValue { let extension = path .extension() .and_then(|value| value.to_str()) .unwrap_or("") .to_ascii_lowercase(); let content_type = match extension.as_str() { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml; charset=utf-8", "md" | "markdown" => "text/markdown; charset=utf-8", "txt" | "log" => "text/plain; charset=utf-8", "json" => "application/json; charset=utf-8", "pdf" => "application/pdf", "doc" => "application/msword", "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "ppt" => "application/vnd.ms-powerpoint", "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", "xls" => "application/vnd.ms-excel", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "odt" => "application/vnd.oasis.opendocument.text", "odp" => "application/vnd.oasis.opendocument.presentation", "ods" => "application/vnd.oasis.opendocument.spreadsheet", _ => "application/octet-stream", }; HeaderValue::from_static(content_type) } fn percent_encode_content_disposition_filename(value: &str) -> String { let mut encoded = String::new(); for byte in value.as_bytes() { match *byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~' => encoded.push(*byte as char), _ => encoded.push_str(&format!("%{byte:02X}")), } } encoded } fn content_disposition_attachment_for_path(path: &Path) -> HeaderValue { let filename = path .file_name() .and_then(|value| value.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("download"); content_disposition_attachment_for_filename(filename) } fn content_disposition_attachment_for_filename(filename: &str) -> HeaderValue { let filename = filename.trim(); let filename = if filename.is_empty() { "download" } else { filename }; let ascii_fallback = filename .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ' ') { ch } else { '_' } }) .collect::(); let ascii_fallback = if ascii_fallback.trim().is_empty() { "download".to_string() } else { ascii_fallback }; let quoted = ascii_fallback.replace('\\', "\\\\").replace('"', "\\\""); let encoded = percent_encode_content_disposition_filename(filename); HeaderValue::from_str(&format!( "attachment; filename=\"{quoted}\"; filename*=UTF-8''{encoded}" )) .unwrap_or_else(|_| HeaderValue::from_static("attachment")) } fn local_directory_tar_filename(path: &Path) -> String { let name = path .file_name() .and_then(|value| value.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("folder"); format!("{name}.tar") } fn tar_octal(value: u64, width: usize) -> Vec { let mut encoded = format!("{value:o}").into_bytes(); let max_digits = width.saturating_sub(1); if encoded.len() > max_digits { encoded = vec![b'7'; max_digits]; } let mut out = vec![b'0'; max_digits.saturating_sub(encoded.len())]; out.extend(encoded); out.push(0); out } fn split_tar_path(path: &str) -> Result<(&str, &str), std::io::Error> { let bytes = path.as_bytes(); if bytes.len() <= 100 { return Ok(("", path)); } let mut best: Option = None; for (index, ch) in path.char_indices() { if ch == '/' { let prefix_len = path[..index].as_bytes().len(); let name_len = path[index + 1..].as_bytes().len(); if prefix_len <= 155 && name_len <= 100 { best = Some(index); } } } if let Some(index) = best { return Ok((&path[..index], &path[index + 1..])); } Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "tar entry path is too long", )) } fn append_tar_header( out: &mut Vec, path: &str, size: u64, typeflag: u8, mode: u64, mtime: u64, ) -> Result<(), std::io::Error> { let normalized = path.trim_start_matches('/').replace('\\', "/"); let (prefix, name) = split_tar_path(&normalized)?; let mut header = [0u8; 512]; header[0..name.as_bytes().len()].copy_from_slice(name.as_bytes()); let mode = tar_octal(mode, 8); header[100..108].copy_from_slice(&mode); header[108..116].copy_from_slice(&tar_octal(0, 8)); header[116..124].copy_from_slice(&tar_octal(0, 8)); header[124..136].copy_from_slice(&tar_octal(size, 12)); header[136..148].copy_from_slice(&tar_octal(mtime, 12)); header[148..156].fill(b' '); header[156] = typeflag; header[257..263].copy_from_slice(b"ustar\0"); header[263..265].copy_from_slice(b"00"); if !prefix.is_empty() { header[345..345 + prefix.as_bytes().len()].copy_from_slice(prefix.as_bytes()); } let checksum = header.iter().map(|byte| u32::from(*byte)).sum::(); let checksum_bytes = format!("{checksum:06o}\0 ").into_bytes(); header[148..156].copy_from_slice(&checksum_bytes); out.extend_from_slice(&header); Ok(()) } fn append_tar_padding(out: &mut Vec, size: usize) { let remainder = size % 512; if remainder > 0 { out.extend(std::iter::repeat(0).take(512 - remainder)); } } fn append_local_directory_tar_entries( out: &mut Vec, root: &Path, current: &Path, base_name: &str, ) -> Result<(), std::io::Error> { let metadata = fs::symlink_metadata(current)?; if metadata.file_type().is_symlink() { return Ok(()); } let relative = current.strip_prefix(root).unwrap_or(current); let mut entry_name = if relative.as_os_str().is_empty() { base_name.to_string() } else { format!( "{base_name}/{}", relative.to_string_lossy().replace('\\', "/") ) }; let mtime = metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_secs()) .unwrap_or(0); if metadata.is_dir() { if !entry_name.ends_with('/') { entry_name.push('/'); } append_tar_header(out, &entry_name, 0, b'5', 0o755, mtime)?; let mut children = fs::read_dir(current)?.collect::, _>>()?; children.sort_by_key(|entry| entry.file_name()); for child in children { append_local_directory_tar_entries(out, root, &child.path(), base_name)?; } return Ok(()); } if metadata.is_file() { append_tar_header(out, &entry_name, metadata.len(), b'0', 0o644, mtime)?; let bytes = fs::read(current)?; out.extend_from_slice(&bytes); append_tar_padding(out, bytes.len()); } Ok(()) } fn build_local_directory_tar_archive(directory: &Path) -> Result, std::io::Error> { let canonical = directory.canonicalize()?; let base_name = canonical .file_name() .and_then(|value| value.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("folder") .to_string(); let mut out = Vec::new(); append_local_directory_tar_entries(&mut out, &canonical, &canonical, &base_name)?; out.extend_from_slice(&[0u8; 1024]); Ok(out) } 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(|content| inline_nodes_to_markdown(content, None)) .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")) } // 过渡实现:手写行内节点→Markdown 回写函数。从 editor block 的 content 数组中 // 逐个节点提取 text + styles,按 legacy 样式格式输出为内联 Markdown。 // AST + 中间 IR 迁移 complete 后应统一走 MarkdownInline→Markdown 反向映射。 fn inline_nodes_to_markdown(value: &Value, local_file_context: Option<(&Path, &Path)>) -> 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(|node| inline_node_to_markdown(node, local_file_context)) .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), local_file_context, ); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { return inline_nodes_to_markdown(content, local_file_context); } } String::new() } fn inline_node_to_markdown(node: &Value, local_file_context: Option<(&Path, &Path)>) -> 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), local_file_context, ); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { return inline_nodes_to_markdown(content, local_file_context); } } 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, local_file_context: Option<(&Path, &Path)>, ) -> 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 { let href = rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href); 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 local_resource_conflict_detection_key(root: &Path, target: &Path) -> Result { let content = fs::read(target).map_err(|error| { WebError::bad_request_code( "local_resource_read_failed", format!("无法读取本地资源文件 {}: {error}", target.display()), ) })?; let meta = fs::metadata(target).map_err(|error| { WebError::bad_request_code( "local_resource_stat_failed", format!("无法读取本地资源文件状态 {}: {error}", target.display()), ) })?; let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH)); let relative_path = target .strip_prefix(root) .ok() .map(|path| path.to_string_lossy().replace('\\', "/")) .unwrap_or_else(|| target.to_string_lossy().to_string()); let mut hasher = DefaultHasher::new(); content.hash(&mut hasher); let content_hash = hasher.finish(); Ok(format!( "local-resource:{relative_path}:{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::{ add_sqlite_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base, create_local_access_grant, create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant, delete_share_link, delete_user_access_grant, editor_blocks_to_markdown_for_file, encode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor, ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state, execute_local_tree_command, execute_local_tree_command_with_sort, get_local_access_policy, get_share_grants, get_share_links, get_user_access_policy, initialize_local_page_id, initialize_local_workspace_for_actor, load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id, open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate, save_local_markdown_page, update_local_markdown_title, validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data, write_local_resource, write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest, }; use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use axum::extract::{Extension, Path as AxumPath, Query, State}; use axum::http::{HeaderMap, Method, StatusCode}; use axum::Json; use control_plane::{DirectoryGrantInput, UpsertUserInput}; use serde_json::{json, Value}; use std::sync::Mutex; fn env_lock() -> &'static Mutex<()> { crate::test_support::hermes_env_lock() } 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 } fn request_context(actor_id: &str, actor_type: &str) -> RequestContext { let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", actor_id.parse().unwrap()); headers.insert("x-mnote-actor-type", actor_type.parse().unwrap()); RequestContext::from_http_parts( &Method::POST, &"/api/admin/access-policy".parse().expect("uri"), &headers, ) } fn test_state() -> AppState { AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: None, enable_legacy_next_compat: false, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) } fn init_workspace(root: &std::path::Path) { initialize_local_workspace_for_actor("user_test", &format!("file://{}", root.display())) .expect("init local workspace"); } #[test] fn local_folder_file_tree_children_snapshot_scans_parent_once() { let root = temp_root("mnote-filetree-children-shallow-scan"); init_workspace(&root); std::fs::create_dir_all(root.join("design").join("03-rust-web")).expect("create rust web"); std::fs::create_dir_all(root.join("design").join("05-editor-mainline")) .expect("create editor"); std::fs::write(root.join("design").join("README.md"), "# Design\n").expect("write readme"); std::fs::write( root.join("design").join("03-rust-web").join("plan.md"), "# Plan\n", ) .expect("write nested"); let root_uri = format!("file://{}", root.display()); let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "design").expect("snapshot"); let items = snapshot .projection .get("items") .and_then(Value::as_array) .expect("items"); let titles = items .iter() .filter_map(|item| item.get("title").and_then(Value::as_str)) .collect::>(); assert_eq!( snapshot .projection .get("parentRelativePath") .and_then(Value::as_str), Some("design") ); assert_eq!(items.len(), 3); assert!(titles.contains(&"03-rust-web")); assert!(titles.contains(&"05-editor-mainline")); assert!(titles.contains(&"README.md")); assert!(!titles.contains(&"plan.md")); assert_eq!( snapshot .projection .get("watchRevision") .and_then(|revision| revision.get("entryCount")) .and_then(Value::as_u64), Some(items.len() as u64) ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes() { let root = temp_root("mnote-page-tree-snapshot-cache"); init_workspace(&root); std::fs::write(root.join("page.md"), "# Page\n").expect("write md"); let root_uri = format!("file://{}", root.display()); super::reset_local_page_tree_snapshot_test_loads(); let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot"); let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot"); assert_eq!(first.projection, second.projection); assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 1); std::fs::write(root.join("next.md"), "# Next\n").expect("write next"); let third = load_local_folder_page_tree_snapshot(&root_uri).expect("third snapshot"); assert_ne!(second.projection, third.projection); assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 2); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_identity_uses_path_even_when_frontmatter_has_mnote_id() { let root = temp_root("mnote-local-frontmatter-path-id"); init_workspace(&root); 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"); let first_json = first.projection.to_string(); assert!(first_json.contains("local-md:page.md")); assert!(!first_json.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-md:docs~2Frenamed.md")); 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_does_not_override_path_derived_id() { let root = temp_root("mnote-local-page-ids"); init_workspace(&root); 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-md:docs~2Fpage.md")); 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_returns_path_id_without_writing_page_ids() { let root = temp_root("mnote-local-page-id-init"); init_workspace(&root); 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_eq!(page_id, "local-md:draft.md"); assert!(!root.join(".mnote").join("page-ids.json").exists()); 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-options.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,"hideTitleHeader":false}}}"#, ) .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); assert!(!aggregate.layout.page_options.hide_title_header); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_page_aggregate_accepts_path_id_when_frontmatter_has_mnote_id() { let root = temp_root("mnote-local-path-id-frontmatter"); init_workspace(&root); std::fs::create_dir_all(root.join("pages")).expect("create pages"); std::fs::write( root.join("pages").join("初始化的新页面.md"), "---\ntitle: 初始化的新页面\nmnote_id: initial-page\n---\n# 初始化的新页面\n\n正文\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); let path_id = format!( "local-md:{}", encode_local_id_segment("pages/初始化的新页面.md") ); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, &path_id).expect("aggregate"); assert_eq!(aggregate.title, "初始化的新页面"); 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_aggregate_exposes_file_version_alias() { let root = temp_root("mnote-local-file-version-aggregate"); std::fs::write(root.join("README.md"), "# Versioned\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 body = serde_json::to_value(&aggregate.body).expect("body json"); assert_eq!(body["fileVersion"], body["conflictDetectionKey"]); assert!(body["fileVersion"] .as_str() .expect("file version") .starts_with("local-md:local-md:README.md:")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_returns_file_version_alias() { let root = temp_root("mnote-local-file-version-save"); std::fs::write(root.join("README.md"), "# Old\n").expect("write md"); let root_uri = format!("file://{}", root.display()); let result = save_local_markdown_page( &root_uri, "local-md:README.md", None, &serde_json::json!([ {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"New"}]} ]), ) .expect("save"); assert_eq!(result["fileVersion"], result["conflict_detection_key"]); assert!(result["fileVersion"] .as_str() .expect("file version") .starts_with("local-md:local-md:README.md:")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_write_contract_returns_page_body_write_command() { let root = temp_root("mnote-local-page-body-write-contract"); std::fs::write(root.join("README.md"), "---\ntitle: Contract\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 request = core_protocol::PageBodyWriteRequest { document_id: "local-md:README.md".into(), workspace_id: "local-ws:test".into(), source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, root_uri: root_uri.clone(), expected_file_version: aggregate.body.file_version.as_str().map(ToOwned::to_owned), base_content_hash: Some("sha256:test-base".into()), content_format: "editorBlocks".into(), content: serde_json::json!([ {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Written"}]} ]), editor_source: Some("unit-test".into()), }; let result = write_local_markdown_page_body(&request, None).expect("write"); assert_eq!(result["canonicalCommand"], "page.body.write"); assert_eq!(result["compatCommand"], "page.body.save"); assert_eq!(result["contentFormat"], "editorBlocks"); assert_eq!(result["editorSource"], "unit-test"); assert_eq!(result["fileVersion"], result["conflict_detection_key"]); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("# Written")); 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_page_aggregate_preserves_uploaded_markdown_assets_as_media_blocks() { let root = temp_root("mnote-local-uploaded-md-assets-media"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); let root_uri = format!("file://{}", root.display()); let document_id = "local-md:Page~2FPage.md"; let first = write_local_markdown_asset( &root_uri, document_id, "attachment", LocalUploadFile { name: "notes.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Notes one\n".to_vec(), }, ) .expect("upload first md asset"); let second = write_local_markdown_asset( &root_uri, document_id, "attachment", LocalUploadFile { name: "notes.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Notes two\n".to_vec(), }, ) .expect("upload second md asset"); assert_eq!(first["sourcePath"], "notes.md"); assert_eq!(second["sourcePath"], "notes-1.md"); save_local_markdown_page( &root_uri, document_id, None, &json!([ {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Page"}]}, {"type":"media","props":{"name":"notes.md","sourcePath":"notes.md"}}, {"type":"media","props":{"name":"notes-1.md","sourcePath":"notes-1.md"}} ]), ) .expect("save uploaded md links"); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate"); let media_paths = aggregate .body .content .as_array() .expect("blocks") .iter() .filter(|block| block["type"].as_str() == Some("media")) .map(|block| { block["props"]["sourcePath"] .as_str() .unwrap_or_default() .to_string() }) .collect::>(); assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() { let root = temp_root("mnote-local-uploaded-md-inline-link"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); let root_uri = format!("file://{}", root.display()); let document_id = "local-md:Page~2FPage.md"; let asset = write_local_markdown_asset( &root_uri, document_id, "attachment", LocalUploadFile { name: "notes.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Notes\n".to_vec(), }, ) .expect("upload md asset"); let href = format!( "http://127.0.0.1:3000/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md", root_uri ); save_local_markdown_page( &root_uri, document_id, None, &json!([ { "type": "paragraph", "content": [{ "type": "text", "text": "notes.md", "marks": [{ "type": "link", "attrs": { "href": href, "class": "mnote-uploaded-attachment" } }] }] } ]), ) .expect("save inline attachment link"); assert_eq!(asset["sourcePath"], "notes.md"); let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); assert!(saved.contains("[notes.md](notes.md)")); assert!(!saved.contains("/api/local-folder/files/open")); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate"); let media = aggregate .body .content .as_array() .expect("blocks") .iter() .find(|block| block["type"].as_str() == Some("media")) .expect("uploaded md inline link should reload as media block"); assert_eq!(media["props"]["sourcePath"], "notes.md"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() { let root = temp_root("mnote-local-uploaded-md-relative-open-link"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); let root_uri = format!("file://{}", root.display()); let document_id = "local-md:Page~2FPage.md"; write_local_markdown_asset( &root_uri, document_id, "attachment", LocalUploadFile { name: "notes.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Notes\n".to_vec(), }, ) .expect("upload md asset"); let href = format!( "/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md", root_uri ); save_local_markdown_page( &root_uri, document_id, None, &json!([ { "type": "paragraph", "content": [{ "type": "text", "text": "notes.md", "marks": [{ "type": "link", "attrs": { "href": href, "class": "mnote-uploaded-attachment-row" } }] }] } ]), ) .expect("save relative open url"); let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); assert!(saved.contains("[notes.md](notes.md)")); let _ = std::fs::remove_dir_all(&root); } #[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-md:README.md", 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_fails_with_error_code_on_readonly_file() { let root = temp_root("mnote-local-markdown-save-readonly-error"); std::fs::write( root.join("README.md"), "---\ntitle: Error Test\n---\n# Original\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); // 把文件设为只读,使 fs::write 失败。 let mut perms = std::fs::metadata(root.join("README.md")) .expect("metadata") .permissions(); perms.set_readonly(true); std::fs::set_permissions(root.join("README.md"), perms).expect("set readonly"); let error = save_local_markdown_page( &root_uri, "local-md:README.md", None, &serde_json::json!([ {"type":"paragraph","content":[{"type":"text","text":"新内容","styles":{}}]} ]), ) .expect_err("save 应在只读文件上失败"); assert_eq!( error.code(), "local_markdown_write_failed", "错误码应为 local_markdown_write_failed,实际: {}", error.code() ); assert!( error.message().contains("无法保存本地 Markdown 文件"), "错误信息应包含中文解释和文件路径,实际: {}", error.message() ); // 验证磁盘上的原始内容未被清除。 let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!( saved.contains("# Original"), "原文应保留,实际内容: {}", saved ); 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); } #[test] fn local_markdown_save_table_handles_colspan_degradation() { // Tiptap table cell 可能包含 colspan/rowspan 等 GFM pipe table // 无法表达的结构。降级策略:提取文本内容,忽略合并单元格属性, // 输出标准 pipe table,不丢文字。 let root = temp_root("mnote-local-markdown-save-table-colspan"); std::fs::write(root.join("README.md"), "---\ntitle: Colspan\n---\n# Old\n") .expect("write md"); let root_uri = format!("file://{}", root.display()); // 第二行第二个单元格有 colspan=2,GFM pipe table 不支持,应降级为普通单元格。 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":"A","styles":{}}]}]}, {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]}, {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]} ]}, {"type":"tableRow","content":[ {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"x","styles":{}}]}]}, {"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"y+z","styles":{}}]}]} ]} ]}}} ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); // 降级后 colspan=2 的单元格变成普通单列,GFM 表头行 3 列但数据行 2 列 → 右列留空。 // 重点:文字不丢。 assert!( saved.contains("y+z"), "colspan 单元格文字不应丢失: {}", saved ); // 不会 panic,表格行数对齐由 editor_block_table_to_markdown 的 padding 逻辑保证。 let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_writes_image_blocks_as_markdown_images() { let root = temp_root("mnote-local-markdown-save-image-block"); std::fs::write(root.join("README.md"), "---\ntitle: Image\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": "image", "props": { "src": "README.assets/photo.png", "alt": "示例图片", "title": "示例图片" } } ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("![示例图片](README.assets/photo.png)")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_rewrites_local_file_open_url_to_markdown_relative_path() { let root = temp_root("mnote-local-markdown-save-image-open-url"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("Page.md"), "").expect("write md"); std::fs::write(root.join("Page").join("屏幕截图 2025.png"), b"jpg").expect("write image"); let root_uri = format!("file://{}", root.display()); let image_url = format!( "http://localhost:3000/api/local-folder/files/open?rootUri={}&path=Page%2F%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%202025.png", root_uri ); save_local_markdown_page( &root_uri, "local-md:Page~2FPage.md", None, &serde_json::json!([ { "type": "image", "props": { "src": image_url, "alt": "示例图片" } } ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); assert!(saved.contains("![示例图片](<屏幕截图 2025.png>)")); assert!(!saved.contains("/api/local-folder/files/open")); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page~2FPage.md") .expect("aggregate"); let image = aggregate.body.content.as_array().expect("blocks")[0].clone(); assert_eq!(image["type"], "image"); assert_eq!(image["props"]["src"], "屏幕截图 2025.png"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_mindmap_blocks_roundtrip_next_to_page() { let root = temp_root("mnote-local-markdown-mindmap-roundtrip"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("Page.md"), "").expect("write md"); let root_uri = format!("file://{}", root.display()); let write_result = write_local_mindmap_data( &root_uri, "local-md:Page~2FPage.md", "思维导图123456.json", serde_json::json!({"data":{"text":"中心主题"},"children":[]}), false, ) .expect("write mindmap"); assert_eq!(write_result["relativePath"], "Page/思维导图123456.json"); assert!(root.join("Page").join("思维导图123456.json").is_file()); save_local_markdown_page( &root_uri, "local-md:Page~2FPage.md", None, &serde_json::json!([ { "type": "mindmap", "props": { "name": "主页思维导图", "mindmapId": "思维导图123456.json", "rootNodeId": "root" } } ]), ) .expect("save"); let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); assert!(saved.contains("[主页思维导图](思维导图123456.json)")); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page~2FPage.md") .expect("aggregate"); let mindmap = aggregate.body.content.as_array().expect("blocks")[0].clone(); assert_eq!(mindmap["type"], "mindmap"); assert_eq!(mindmap["props"]["mindmapId"], "思维导图123456.json"); assert_eq!(mindmap["props"]["sourcePath"], "思维导图123456.json"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_workspace_access_rejects_owner_mismatch() { let root = temp_root("mnote-local-workspace-owner-mismatch"); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); std::fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-demo","ownerId":"user_a","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("write workspace manifest"); let root_uri = format!("file://{}", root.display()); let error = ensure_local_workspace_access_for_actor("user_b", "user", &root_uri) .expect_err("owner mismatch must be rejected"); assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN); assert!(error.message().contains("无权")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_workspace_access_allows_admin_without_manifest() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-workspace-admin-any-root"); let root_uri = format!("file://{}", root.display()); std::env::set_var("MNOTE_ADMIN_USER_IDS", "user_admin"); let allowed = ensure_local_workspace_access_for_actor("user_admin", "user", &root_uri) .expect("admin can write any local directory"); assert_eq!(allowed, root.canonicalize().expect("canonical root")); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_workspace_access_policy_grants_read_and_write_separately() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-workspace-policy-root"); let policy_root = temp_root("mnote-local-workspace-policy-config"); let policy_file = policy_root.join("access-policy.json"); let root_uri = format!("file://{}", root.display()); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); std::fs::write( &policy_file, serde_json::json!({ "grants": [ { "userId": "user_reader", "rootUri": root_uri, "permission": "read", "recursive": true } ] }) .to_string(), ) .expect("write policy"); ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri) .expect("read grant can read local directory"); let write_error = ensure_local_workspace_access_for_actor("user_reader", "user", &root_uri) .expect_err("read grant must not write"); assert_eq!(write_error.status(), axum::http::StatusCode::FORBIDDEN); std::fs::write( &policy_file, serde_json::json!({ "grants": [ { "userId": "user_writer", "rootUri": root_uri, "permission": "write", "recursive": true } ] }) .to_string(), ) .expect("write policy"); ensure_local_workspace_access_for_actor("user_writer", "user", &root_uri) .expect("write grant can write local directory"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&policy_root); } #[test] fn local_path_read_access_rejects_root_escape() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-path-read-root"); let outside = temp_root("mnote-local-path-read-outside"); init_workspace(&root); std::fs::write(root.join("inside.md"), "inside").expect("write inside"); std::fs::write(outside.join("outside.md"), "outside").expect("write outside"); let context = request_context("user_test", "user"); let root_uri = format!("file://{}", root.display()); let inside = ensure_local_path_read_access(&context, &root_uri, "inside.md") .expect("relative file inside root can be read"); assert_eq!( inside, root.join("inside.md").canonicalize().expect("inside") ); let escape_error = ensure_local_path_read_access( &context, &root_uri, &outside.join("outside.md").display().to_string(), ) .expect_err("absolute path outside root must be rejected"); assert_eq!(escape_error.status(), StatusCode::BAD_REQUEST); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&outside); } #[tokio::test] async fn local_access_policy_admin_api_rejects_non_admin_and_validates_root() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-access-policy-validate-root"); let policy_root = temp_root("mnote-local-access-policy-validate-config"); let policy_file = policy_root.join("access-policy.json"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); let non_admin_error = get_local_access_policy( State(test_state()), Extension(request_context("user_1", "user")), ) .await .expect_err("non-admin cannot read access policy"); assert_eq!(non_admin_error.status(), StatusCode::FORBIDDEN); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); let (_, Json(payload)) = validate_local_access_root( Extension(request_context("admin_1", "user")), Json(LocalAccessValidateRootRequest { root_uri: format!("file://{}", root.display()), root_path: String::new(), }), ) .await .expect("admin can validate root"); assert_eq!(payload["ok"], true); assert_eq!( payload["rootPath"], root.canonicalize() .expect("canonical root") .display() .to_string() ); let invalid_error = validate_local_access_root( Extension(request_context("admin_1", "user")), Json(LocalAccessValidateRootRequest { root_uri: format!("file://{}", root.join("missing").display()), root_path: String::new(), }), ) .await .expect_err("missing root should be rejected"); assert_eq!(invalid_error.status(), StatusCode::BAD_REQUEST); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&policy_root); } #[tokio::test] async fn local_access_policy_admin_api_creates_and_deletes_grant() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-access-policy-grant-root"); let policy_root = temp_root("mnote-local-access-policy-grant-config"); let policy_file = policy_root.join("access-policy.json"); let root_uri = format!("file://{}", root.display()); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("admin_1".into()), email: Some("admin-1@example.com".into()), username: "admin_1".into(), display_name: "admin_1".into(), role: Some("admin".into()), password_hash: None, }) .expect("upsert admin"); state .control_plane() .upsert_user(UpsertUserInput { id: Some("user_reader".into()), email: Some("user-reader@example.com".into()), username: "user_reader".into(), display_name: "user_reader".into(), role: None, password_hash: None, }) .expect("upsert reader"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); let mut control_delta_rx = state.stream_delta_tx.subscribe(); let (_, Json(created)) = create_local_access_grant( State(state.clone()), Extension(request_context("admin_1", "user")), Json(LocalAccessGrantRequest { id: "grant_reader".into(), user_id: "user_reader".into(), root_uri: root_uri.clone(), root_path: String::new(), permission: "read".into(), recursive: true, capabilities: vec!["ai".into(), "share".into()], }), ) .await .expect("admin can create read grant"); let created_grant_id = created["grant"]["id"] .as_str() .expect("created grant id") .to_string(); assert_eq!(created["grant"]["permission"], "read"); assert_eq!(created["controlPlane"], "sqlite"); let created_events = state .control_plane() .drain_outbox(10) .expect("drain created event"); assert_eq!(created_events.len(), 1); assert_eq!(created_events[0].event_type, "control.grant.created"); let created_delta = control_delta_rx .try_recv() .expect("grant created broadcast delta"); assert_eq!(created_delta["kind"], "control_plane_event"); assert_eq!(created_delta["eventType"], "control.grant.created"); ensure_local_workspace_read_access_with_state( &state, &request_context("user_reader", "user"), &root_uri, ) .expect("created read grant can read"); let write_error = ensure_local_workspace_write_access_with_state( &state, &request_context("user_reader", "user"), &root_uri, ) .expect_err("created read grant cannot write"); assert_eq!(write_error.status(), StatusCode::FORBIDDEN); let duplicate_error = add_sqlite_local_access_grant_for_context( &state, &request_context("admin_1", "user"), LocalAccessGrantRequest { id: "grant_reader".into(), user_id: "user_reader".into(), root_uri: root_uri.clone(), root_path: String::new(), permission: "read".into(), recursive: true, capabilities: vec!["ai".into(), "share".into()], }, ) .expect_err("duplicate grant id should be rejected"); assert_eq!(duplicate_error.status(), StatusCode::BAD_REQUEST); let (_, Json(deleted)) = super::delete_local_access_grant( State(state.clone()), Extension(request_context("admin_1", "user")), AxumPath(created_grant_id.clone()), ) .await .expect("admin can delete grant"); assert_eq!(deleted["deletedGrantId"], created_grant_id); let revoked_events = state .control_plane() .drain_outbox(10) .expect("drain revoked event"); assert_eq!(revoked_events.len(), 1); assert_eq!(revoked_events[0].event_type, "control.grant.revoked"); let revoked_delta = control_delta_rx .try_recv() .expect("grant revoked broadcast delta"); assert_eq!(revoked_delta["kind"], "control_plane_event"); assert_eq!(revoked_delta["eventType"], "control.grant.revoked"); let read_error = ensure_local_workspace_read_access_with_state( &state, &request_context("user_reader", "user"), &root_uri, ) .expect_err("deleted grant should remove read access"); assert_eq!(read_error.status(), StatusCode::FORBIDDEN); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&policy_root); } #[tokio::test] async fn share_grant_admin_api_creates_reads_and_revokes_without_local_access() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-share-grant-workspace-root"); let config_root = temp_root("mnote-share-grant-config-root"); let access_policy_file = config_root.join("access-policy.json"); let share_grants_file = config_root.join("share-grants.json"); let root_uri = format!("file://{}", root.display()); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &access_policy_file); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); let non_admin_error = get_share_grants(Extension(request_context("user_1", "user"))) .await .expect_err("non-admin cannot read share grants"); assert_eq!(non_admin_error.status(), StatusCode::FORBIDDEN); let (_, Json(created)) = create_share_grant( Extension(request_context("admin_1", "user")), Json(LocalShareGrantRequest { id: "share_grant_1".into(), share_id: "share_1".into(), owner_user_id: "owner_1".into(), target_user_id: "target_1".into(), root_uri: root_uri.clone(), root_path: String::new(), document_id: "doc_1".into(), allowed_resource_ids: vec!["doc_1".into()], permission: "read".into(), capabilities: vec!["ai".into(), "share".into()], }), ) .await .expect("admin can create share grant"); assert_eq!(created["grant"]["id"], "share_grant_1"); assert_eq!(created["grant"]["shareId"], "share_1"); assert_eq!(created["grant"]["active"], true); let read_error = ensure_local_workspace_read_access_for_actor("target_1", "user", &root_uri) .expect_err("share grant must not grant local filesystem read access"); assert_eq!(read_error.status(), StatusCode::FORBIDDEN); let write_error = ensure_local_workspace_access_for_actor("target_1", "user", &root_uri) .expect_err("share grant must not grant local filesystem write access"); assert_eq!(write_error.status(), StatusCode::FORBIDDEN); let (_, Json(listed)) = get_share_grants(Extension(request_context("admin_1", "user"))) .await .expect("admin can list share grants"); assert_eq!(listed["grants"][0]["id"], "share_grant_1"); assert_eq!(listed["grants"][0]["active"], true); let (_, Json(revoked)) = super::delete_share_grant( Extension(request_context("admin_1", "user")), AxumPath("share_1".into()), ) .await .expect("admin can revoke by share id"); assert_eq!(revoked["revokedShareId"], "share_1"); assert_eq!(revoked["grant"]["active"], false); assert!( revoked["grant"]["revokedAt"] .as_str() .unwrap_or_default() .len() > 0 ); let stored = std::fs::read_to_string(&share_grants_file).expect("read share grants file"); let stored_json: Value = serde_json::from_str(&stored).expect("share grants json"); assert_eq!(stored_json["grants"][0]["active"], false); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&config_root); } #[tokio::test] async fn user_share_grant_only_allows_owned_workspace_folder() { let _guard = env_lock().lock().expect("env lock"); let owned_root = temp_root("mnote-user-folder-grant-owned-root"); let outside_root = temp_root("mnote-user-folder-grant-outside-root"); let config_root = temp_root("mnote-user-folder-grant-config-root"); let share_grants_file = config_root.join("share-grants.json"); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); std::fs::create_dir_all(owned_root.join("docs")).expect("owned docs"); initialize_local_workspace_for_actor( "user_owner", &format!("file://{}", owned_root.display()), ) .expect("init owned workspace"); let (_, Json(created)) = create_user_share_grant( Extension(request_context("user_owner", "user")), Json(LocalShareGrantRequest { target_user_id: "user_target".into(), root_path: owned_root.join("docs").display().to_string(), permission: "write".into(), ..Default::default() }), ) .await .expect("user can grant owned folder"); assert_eq!(created["grant"]["ownerUserId"], "user_owner"); assert_eq!(created["grant"]["targetUserId"], "user_target"); assert_eq!(created["grant"]["permission"], "write"); assert!(created["grant"]["shareId"] .as_str() .unwrap_or_default() .starts_with("folder_")); let outside_error = create_user_share_grant( Extension(request_context("user_owner", "user")), Json(LocalShareGrantRequest { target_user_id: "user_target_2".into(), root_path: outside_root.display().to_string(), permission: "read".into(), ..Default::default() }), ) .await .expect_err("user cannot grant outside owned workspace"); assert_eq!(outside_error.status(), StatusCode::FORBIDDEN); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = std::fs::remove_dir_all(&owned_root); let _ = std::fs::remove_dir_all(&outside_root); let _ = std::fs::remove_dir_all(&config_root); } #[tokio::test] async fn user_access_policy_grant_uses_sqlite_directory_grants_for_owned_folder() { let _guard = env_lock().lock().expect("env lock"); let owned_root = temp_root("mnote-user-access-policy-owned-root"); let outside_root = temp_root("mnote-user-access-policy-outside-root"); std::fs::create_dir_all(owned_root.join("docs")).expect("owned docs"); initialize_local_workspace_for_actor( "user_owner", &format!("file://{}", owned_root.display()), ) .expect("init owned workspace"); let state = test_state(); for user_id in ["user_owner", "user_target"] { state .control_plane() .upsert_user(UpsertUserInput { id: Some(user_id.into()), email: Some(format!("{user_id}@example.com")), username: user_id.into(), display_name: user_id.into(), role: None, password_hash: None, }) .expect("upsert test user"); } let (_, Json(created)) = create_user_access_grant( State(state.clone()), Extension(request_context("user_owner", "user")), Json(LocalAccessGrantRequest { user_id: "user_target".into(), root_path: owned_root.join("docs").display().to_string(), permission: "write".into(), recursive: true, ..Default::default() }), ) .await .expect("user can grant owned folder to target user"); let grant_id = created["grant"]["id"] .as_str() .expect("grant id") .to_string(); assert_eq!(created["controlPlane"], "sqlite"); assert_eq!(created["grant"]["userId"], "user_target"); assert_eq!(created["grant"]["createdBy"], "user_owner"); assert_eq!(created["grant"]["permission"], "write"); assert_eq!(created["grant"]["source"], "user"); let docs_uri = format!("file://{}", owned_root.join("docs").display()); ensure_local_workspace_write_access_with_state( &state, &request_context("user_target", "user"), &docs_uri, ) .expect("target user can write granted folder"); let (_, Json(listed)) = get_user_access_policy( State(state.clone()), Extension(request_context("user_owner", "user")), ) .await .expect("owner can list self-created directory grants"); assert_eq!(listed["controlPlane"], "sqlite"); assert_eq!(listed["grants"][0]["id"], grant_id); assert_eq!(listed["grants"][0]["permission"], "write"); let (_, Json(target_listed)) = get_user_access_policy( State(state.clone()), Extension(request_context("user_target", "user")), ) .await .expect("target can list incoming directory grants"); assert_eq!(target_listed["controlPlane"], "sqlite"); assert_eq!(target_listed["grants"][0]["id"], grant_id); assert_eq!(target_listed["grants"][0]["userId"], "user_target"); let target_delete_error = delete_user_access_grant( State(state.clone()), Extension(request_context("user_target", "user")), AxumPath(grant_id.clone()), ) .await .expect_err("target cannot revoke grant created by owner"); assert_eq!(target_delete_error.status(), StatusCode::FORBIDDEN); let (_, Json(deleted)) = delete_user_access_grant( State(state.clone()), Extension(request_context("user_owner", "user")), AxumPath(grant_id.clone()), ) .await .expect("owner can revoke self-created grant"); assert_eq!(deleted["deletedGrantId"], grant_id); let revoked_error = ensure_local_workspace_write_access_with_state( &state, &request_context("user_target", "user"), &docs_uri, ) .expect_err("revoked grant removes target write access"); assert_eq!(revoked_error.status(), StatusCode::FORBIDDEN); let outside_error = create_user_access_grant( State(state.clone()), Extension(request_context("user_owner", "user")), Json(LocalAccessGrantRequest { user_id: "user_target".into(), root_path: outside_root.display().to_string(), permission: "read".into(), recursive: true, ..Default::default() }), ) .await .expect_err("user cannot grant folder outside owned workspace"); assert_eq!(outside_error.status(), StatusCode::FORBIDDEN); let _ = std::fs::remove_dir_all(&owned_root); let _ = std::fs::remove_dir_all(&outside_root); } #[tokio::test] async fn user_access_policy_cannot_revoke_default_workspace_auto_grant() { let _guard = env_lock().lock().expect("env lock"); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("shujuan".into()), email: Some("shujuan@example.com".into()), username: "shujuan".into(), display_name: "shujuan".into(), role: None, password_hash: None, }) .expect("upsert user"); state .control_plane() .ensure_default_workspace("shujuan") .expect("default workspace"); let grant_id = state .control_plane() .list_directory_grants() .expect("list grants") .into_iter() .find(|grant| grant.user_id == "shujuan" && grant.source == "auto") .map(|grant| grant.id) .expect("default auto grant"); let error = delete_user_access_grant( State(state.clone()), Extension(request_context("shujuan", "user")), AxumPath(grant_id), ) .await .expect_err("default grant must be readonly"); assert_eq!(error.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn admin_share_grant_allows_any_local_folder_with_minimal_fields() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-admin-folder-grant-root"); let config_root = temp_root("mnote-admin-folder-grant-config-root"); let share_grants_file = config_root.join("share-grants.json"); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); let (_, Json(created)) = create_share_grant( Extension(request_context("admin_1", "user")), Json(LocalShareGrantRequest { target_user_id: "user_target".into(), root_path: root.display().to_string(), permission: "read".into(), ..Default::default() }), ) .await .expect("admin can grant any local folder"); assert_eq!(created["grant"]["ownerUserId"], "admin_1"); assert_eq!(created["grant"]["targetUserId"], "user_target"); assert_eq!(created["grant"]["permission"], "read"); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&config_root); } #[tokio::test] async fn share_link_api_creates_lists_and_revokes_sqlite_record() { let _guard = env_lock().lock().expect("env lock"); let state = test_state(); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); state .control_plane() .upsert_user(UpsertUserInput { id: Some("admin_1".into()), email: Some("admin-1@example.com".into()), username: "admin_1".into(), display_name: "admin_1".into(), role: Some("admin".into()), password_hash: None, }) .expect("upsert admin"); let workspace = state .control_plane() .ensure_default_workspace("admin_1") .expect("default workspace"); let mut control_delta_rx = state.stream_delta_tx.subscribe(); let (_, Json(created)) = create_share_link( State(state.clone()), Extension(request_context("admin_1", "user")), Json(LocalShareLinkRequest { workspace_id: workspace.id.clone(), resource_kind: "page".into(), resource_id: "local-md:README.md".into(), token: Some("visible-token".into()), permission: "read".into(), expires_at: None, }), ) .await .expect("admin can create share link"); let link_id = created["link"]["id"].as_str().expect("link id").to_string(); assert_eq!(created["controlPlane"], "sqlite"); assert_eq!(created["token"], "visible-token"); assert!(created["link"].get("tokenHash").is_none()); assert_eq!(created["link"]["permission"], "read"); let created_events = state .control_plane() .drain_outbox(10) .expect("drain created event"); assert_eq!(created_events.len(), 1); assert_eq!(created_events[0].event_type, "control.share.created"); let created_delta = control_delta_rx .try_recv() .expect("share created broadcast delta"); assert_eq!(created_delta["kind"], "control_plane_event"); assert_eq!(created_delta["eventType"], "control.share.created"); let stored = state .control_plane() .list_share_links(&workspace.id) .expect("list sqlite share links"); assert_eq!(stored.len(), 1); assert_eq!(stored[0].id, link_id); assert_ne!(stored[0].token_hash, "visible-token"); assert!(state .control_plane() .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) .expect("resolve share link") .is_some()); let (_, Json(listed)) = get_share_links( State(state.clone()), Extension(request_context("admin_1", "user")), Query(ShareLinkListQuery { workspace_id: workspace.id.clone(), }), ) .await .expect("admin can list share links"); assert_eq!(listed["links"][0]["id"], link_id); assert!(listed["links"][0].get("tokenHash").is_none()); let (_, Json(revoked)) = delete_share_link( State(state.clone()), Extension(request_context("admin_1", "user")), AxumPath(link_id.clone()), ) .await .expect("admin can revoke share link"); assert_eq!(revoked["revokedLinkId"], link_id); let revoked_events = state .control_plane() .drain_outbox(10) .expect("drain revoked event"); assert_eq!(revoked_events.len(), 1); assert_eq!(revoked_events[0].event_type, "control.share.revoked"); let revoked_delta = control_delta_rx .try_recv() .expect("share revoked broadcast delta"); assert_eq!(revoked_delta["kind"], "control_plane_event"); assert_eq!(revoked_delta["eventType"], "control.share.revoked"); assert!(state .control_plane() .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) .expect("resolve revoked share link") .is_none()); assert_eq!( state .control_plane() .list_audit_log(10) .expect("audit log") .iter() .filter(|entry| entry.action.starts_with("control.share.")) .count(), 2 ); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); } #[tokio::test] async fn shared_cache_and_sync_state_record_permissions_and_conflict_report() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-shared-cache-sync-root"); let config_root = temp_root("mnote-shared-cache-sync-config"); let access_policy_file = config_root.join("access-policy.json"); let share_grants_file = config_root.join("share-grants.json"); let root_uri = format!("file://{}", root.display()); std::fs::create_dir_all(root.join(".mnote")).expect("metadata"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &access_policy_file); std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file); std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1"); let (read_status, Json(read_share_payload)) = create_share_grant( Extension(request_context("admin_1", "user")), Json(LocalShareGrantRequest { id: "share_grant_sync_read".into(), share_id: "share_sync_read".into(), owner_user_id: "owner_1".into(), target_user_id: "target_1".into(), root_uri: root_uri.clone(), root_path: String::new(), document_id: "local-md:README.md".into(), allowed_resource_ids: vec!["local-md:README.md".into()], permission: "read".into(), capabilities: vec!["ai".into(), "share".into()], }), ) .await .expect("create read share"); assert_eq!(read_status, StatusCode::OK); assert_eq!(read_share_payload["grant"]["shareId"], "share_sync_read"); let (write_status, Json(write_share_payload)) = create_share_grant( Extension(request_context("admin_1", "user")), Json(LocalShareGrantRequest { id: "share_grant_sync_write".into(), share_id: "share_sync_write".into(), owner_user_id: "owner_1".into(), target_user_id: "target_1".into(), root_uri: root_uri.clone(), root_path: String::new(), document_id: "local-md:README.md".into(), allowed_resource_ids: vec!["local-md:README.md".into()], permission: "write".into(), capabilities: vec!["ai".into(), "share".into()], }), ) .await .expect("create write share"); assert_eq!(write_status, StatusCode::OK); assert_eq!(write_share_payload["grant"]["shareId"], "share_sync_write"); let (_, Json(cache_payload)) = record_shared_cache( Extension(request_context("target_1", "user")), Json(SharedCacheRecordRequest { root_uri: root_uri.clone(), share_id: "share_sync_read".into(), permission: "read".into(), remote_version: "remote-1".into(), base_version: "base-1".into(), source_actor: "owner_1".into(), }), ) .await .expect("record cache"); assert_eq!(cache_payload["share"]["remoteVersion"], "remote-1"); let cache_json = std::fs::read_to_string(root.join(".mnote").join("share-cache.json")).expect("cache"); assert!(cache_json.contains("share_sync_read")); assert!(cache_json.contains("remote-1")); let read_pending_error = record_sync_pending_change( Extension(request_context("target_1", "user")), Json(SyncPendingChangeRequest { root_uri: root_uri.clone(), share_id: "share_sync_read".into(), resource_id: "local-md:README.md".into(), local_version: "local-1".into(), base_version: "remote-1".into(), remote_version: "remote-1".into(), change_summary: "read share write attempt".into(), }), ) .await .expect_err("shared_read cannot write pending changes"); assert_eq!(read_pending_error.status(), StatusCode::FORBIDDEN); let (_, Json(pending_payload)) = record_sync_pending_change( Extension(request_context("target_1", "user")), Json(SyncPendingChangeRequest { root_uri: root_uri.clone(), share_id: "share_sync_write".into(), resource_id: "local-md:README.md".into(), local_version: "local-2".into(), base_version: "remote-1".into(), remote_version: "remote-1".into(), change_summary: "写入共享页面".into(), }), ) .await .expect("shared_write can record pending change"); assert_eq!( pending_payload["pendingChange"]["resourceId"], "local-md:README.md" ); let sync_state = std::fs::read_to_string(root.join(".mnote").join("sync-state.json")).expect("sync"); assert!(sync_state.contains("share_sync_write")); assert!(sync_state.contains("写入共享页面")); let conflict_error = record_sync_pending_change( Extension(request_context("target_1", "user")), Json(SyncPendingChangeRequest { root_uri: root_uri.clone(), share_id: "share_sync_write".into(), resource_id: "local-md:README.md".into(), local_version: "local-3".into(), base_version: "remote-1".into(), remote_version: "remote-2".into(), change_summary: "冲突写入".into(), }), ) .await .expect_err("remote changed since base"); assert_eq!(conflict_error.status(), StatusCode::CONFLICT); let (_, Json(report_payload)) = write_sync_conflict_report( Extension(request_context("target_1", "user")), Json(SyncConflictReportRequest { root_uri, share_id: "share_sync_write".into(), resource_id: "local-md:README.md".into(), local_version: "local-3".into(), remote_version: "remote-2".into(), base_version: "remote-1".into(), actor_id: "target_1".into(), summary: "同步冲突".into(), }), ) .await .expect("write conflict report"); assert_eq!(report_payload["report"]["source"], "shared_sync"); assert_eq!(report_payload["report"]["shareId"], "share_sync_write"); assert!(root.join(".mnote").join("sync-reports").is_dir()); std::env::remove_var("MNOTE_ADMIN_USER_IDS"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); std::env::remove_var("MNOTE_SHARE_GRANTS_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&config_root); } #[test] fn create_default_local_workspace_uses_managed_data_root() { let base = temp_root("mnote-local-managed-data-root"); let payload = create_default_local_workspace_for_actor_at_base("user@example.com", "user", &base) .expect("create default workspace"); let root_uri = payload["workspace"]["rootUri"] .as_str() .expect("root uri") .to_string(); let root_path = payload["workspace"]["rootPath"] .as_str() .expect("root path"); let expected_root = base .join("users") .join("user~40example.com") .join("workspaces") .join("my-space"); assert_eq!(std::path::Path::new(root_path), expected_root.as_path()); assert!(expected_root.join(".mnote").join("workspace.json").exists()); assert!( !expected_root.join("初始化的新页面").exists(), "默认工作区不应创建'初始化的新页面'目录" ); assert!(!expected_root.join("pages").exists()); assert!(!expected_root.join("assets").exists()); assert!(!expected_root.join("mindmaps").exists()); assert!(!expected_root.join("ai-sessions").exists()); assert_eq!( payload["workspace"]["manifest"]["workspaceId"], "local-ws:user~40example.com:my-space" ); assert_eq!( payload["workspace"]["manifest"]["ownerId"], "user@example.com" ); assert!(payload["workspace"]["manifest"]["capabilities"] .as_array() .expect("capabilities") .iter() .any(|value| value.as_str() == Some("markdown_edit"))); ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri) .expect("owner can access managed workspace"); let second = create_default_local_workspace_for_actor_at_base("user@example.com", "user", &base) .expect("idempotent create"); assert_eq!( second["workspace"]["rootUri"], payload["workspace"]["rootUri"] ); let _ = std::fs::remove_dir_all(&base); } #[test] fn local_file_tree_empty_root_does_not_emit_workspace_folder_row() { let root = temp_root("mnote-local-empty-root-file-tree"); init_workspace(&root); let root_uri = format!("file://{}", root.display()); let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree"); let items = snapshot.projection["items"].as_array().expect("items"); assert!( items.is_empty(), "只剩 .mnote 的空 workspace 不应投影出 root 文件夹行: {items:?}" ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_file_tree_snapshot_loads_root_level_only() { let root = temp_root("mnote-local-file-tree-root-only"); init_workspace(&root); std::fs::create_dir_all(root.join("docs").join("nested")).expect("create nested dirs"); std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme"); std::fs::write(root.join("docs").join("nested").join("deep.md"), "# Deep\n") .expect("write deep"); std::fs::write(root.join("root.md"), "# Root\n").expect("write root"); let root_uri = format!("file://{}", root.display()); let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree"); let items = snapshot.projection["items"].as_array().expect("items"); let titles = items .iter() .filter_map(|item| item["title"].as_str()) .collect::>(); assert!(titles.contains(&"docs")); assert!(titles.contains(&"root.md")); assert!(!titles.contains(&"README.md")); assert!(!titles.contains(&"deep.md")); let docs = items .iter() .find(|item| item["title"].as_str() == Some("docs")) .expect("docs row"); assert_eq!(docs["parentNodeId"].as_str(), None); assert_eq!(docs["expandable"].as_bool(), Some(true)); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_refreshes_search_index_after_rename() { let root = temp_root("mnote-local-tree-refresh-search-index"); init_workspace(&root); std::fs::write( root.join("search-target.md"), "# Search Target\nrename-token\n", ) .expect("write md"); let root_uri = format!("file://{}", root.display()); crate::routes::local_search_index::refresh_local_search_index( &root, &root_uri, "local-ws-search-index-test", ) .expect("initial index"); let before = std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) .expect("before index"); assert!(before.contains("search-target.md")); execute_local_tree_command( &root_uri, "rename", "local-md:search-target.md", None, Some("Renamed Target"), ) .expect("rename"); let after = std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) .expect("after index"); assert!(after.contains("Renamed Target.md"), "{after}"); assert!(!after.contains("search-target.md"), "{after}"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_page_title_comes_from_file_name_not_body_heading() { let root = temp_root("mnote-local-title-file-name"); init_workspace(&root); std::fs::write(root.join("File Name.md"), "# Body Heading\n").expect("write md"); let root_uri = format!("file://{}", root.display()); let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree"); let items = snapshot.projection["items"].as_array().expect("items"); let row = items .iter() .find(|item| { item["resourceMeta"]["documentId"].as_str() == Some("local-md:File~20Name.md") }) .expect("page row"); // 本地 Markdown 标题来自文件名,正文 H1 只作为正文内容。 assert_eq!(row["title"].as_str(), Some("File Name")); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md") .expect("aggregate"); assert_eq!(aggregate.title, "File Name"); // init_workspace 创建的是托管工作区(我的空间),默认显示页头标题。 assert!(!aggregate.layout.page_options.hide_title_header); let _ = std::fs::remove_dir_all(&root); } #[cfg(unix)] #[test] fn local_markdown_path_id_resolves_without_scanning_sibling_directories() { use std::os::unix::fs::PermissionsExt; let root = temp_root("mnote-local-path-id-direct-resolve"); init_workspace(&root); std::fs::create_dir_all(root.join("aaa-unreadable")).expect("create unreadable dir"); std::fs::write(root.join("Target.md"), "# Target\n").expect("write target"); std::fs::set_permissions( root.join("aaa-unreadable"), std::fs::Permissions::from_mode(0o000), ) .expect("chmod unreadable"); let root_uri = format!("file://{}", root.display()); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Target.md") .expect("path id should resolve directly"); std::fs::set_permissions( root.join("aaa-unreadable"), std::fs::Permissions::from_mode(0o700), ) .expect("restore unreadable dir permissions"); let _ = std::fs::remove_dir_all(&root); assert_eq!(aggregate.title, "Target"); } #[test] fn local_markdown_non_managed_folder_default_hides_title_header() { // 非托管本地文件夹:没有 .mnote/workspace.json,应该默认隐藏标题。 let root = temp_root("mnote-local-non-managed-title-default"); let _ = std::fs::create_dir_all(root.join(".mnote")); std::fs::write(root.join("Page.md"), "# Page Heading\n").expect("write md"); let root_uri = format!("file://{}", root.display()); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page.md") .expect("aggregate"); assert_eq!(aggregate.title, "Page"); assert!(aggregate.layout.page_options.hide_title_header); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_document_title_save_renames_nested_bundle_directory_and_markdown() { let root = temp_root("mnote-local-title-save-renames-file"); init_workspace(&root); std::fs::create_dir_all(root.join("Old")).expect("create Old dir"); std::fs::write(root.join("Old").join("Old.md"), "# Body Heading\n").expect("write Old.md"); std::fs::write(root.join("Old").join("Child.md"), "# Child\n").expect("write child"); let root_uri = format!("file://{}", root.display()); let result = update_local_markdown_title(&root_uri, "local-md:Old~2FOld.md", "New") .expect("save title"); assert!( root.join("New").join("New.md").is_file(), "New/New.md should exist" ); assert!( root.join("New").join("Child.md").is_file(), "New/Child.md should exist" ); assert!(!root.join("Old").exists(), "Old/ should not exist"); assert_eq!(result["documentId"].as_str(), Some("local-md:New~2FNew.md")); assert_eq!( result["previousDocumentId"].as_str(), Some("local-md:Old~2FOld.md") ); assert_eq!(result["title"].as_str(), Some("New")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_manages_loose_markdown_like_regular_file() { let root = temp_root("mnote-local-loose-markdown-file-management"); init_workspace(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("README.md"), "# Loose\n").expect("write loose md"); let root_uri = format!("file://{}", root.display()); let renamed = execute_local_tree_command( &root_uri, "rename", "local-md:docs~2FREADME.md", None, Some("Renamed"), ) .expect("rename loose markdown"); assert!(root.join("docs").join("Renamed.md").is_file()); assert!(!root.join("docs").join("README.md").exists()); assert_eq!( renamed["documentId"].as_str(), Some("local-md:docs~2FRenamed.md") ); let moved = execute_local_tree_command(&root_uri, "move", "local-md:docs~2FRenamed.md", None, None) .expect("move loose markdown"); assert!(root.join("Renamed.md").is_file()); assert!(!root.join("docs").join("Renamed.md").exists()); assert_eq!(moved["documentId"].as_str(), Some("local-md:Renamed.md")); let copied = execute_local_tree_command(&root_uri, "copy", "local-md:Renamed.md", None, None) .expect("copy loose markdown"); assert!(root.join("Renamed 2.md").is_file()); assert_eq!( copied["documentId"].as_str(), Some("local-md:Renamed~202.md") ); let deleted = execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None) .expect("delete loose markdown"); assert!(!root.join("Renamed.md").exists()); assert!(root .join(".mnote") .join("trash") .join("Renamed.md") .is_file()); assert_eq!(deleted["resourceKind"].as_str(), Some("markdown")); let restored = execute_local_tree_command(&root_uri, "restore", "local-md:Renamed.md", None, None) .expect("restore loose markdown"); assert!(root.join("Renamed.md").is_file()); assert_eq!(restored["documentId"].as_str(), Some("local-md:Renamed.md")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_resource_markdown_write_targets_asset_not_page_body() { let root = temp_root("mnote-local-resource-md-write"); init_workspace(&root); std::fs::write(root.join("Page.md"), "# Page\n\n正文\n").expect("write page"); std::fs::create_dir_all(root.join("Page.assets")).expect("create assets"); let asset_path = root.join("Page.assets").join("note.md"); std::fs::write(&asset_path, "# Asset\n\n旧内容\n").expect("write asset"); let blocks = serde_json::json!([ {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Asset"}]}, {"type":"paragraph","content":[{"type":"text","text":"新内容"}]} ]); let next = editor_blocks_to_markdown_for_file(&blocks, &root, &asset_path); std::fs::write(&asset_path, next).expect("write asset next"); let asset_text = std::fs::read_to_string(&asset_path).expect("read asset"); let page_text = std::fs::read_to_string(root.join("Page.md")).expect("read page"); assert!(asset_text.contains("新内容")); assert!(page_text.contains("正文")); assert!(!page_text.contains("新内容")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_resource_write_accepts_tiptap_document_payload() { let request = LocalResourceWriteRequest { root_uri: "file:///tmp/mnote".into(), path: "Page.assets/note.md".into(), content_format: Some("tiptapDocument".into()), tiptap_document: Some(serde_json::json!({ "type": "doc", "content": [ { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "资源标题" }] }, { "type": "paragraph", "content": [{ "type": "text", "text": "资源正文" }] } ] })), ..Default::default() }; let blocks = local_resource_write_editor_blocks(&request); let markdown = editor_blocks_to_markdown_for_file( &blocks, std::path::Path::new("/tmp"), std::path::Path::new("/tmp/note.md"), ); assert!(markdown.contains("## 资源标题")); assert!(markdown.contains("资源正文")); } #[tokio::test] async fn local_resource_read_preserves_uploaded_markdown_attachment_links() { let root = temp_root("mnote-local-resource-read-uploaded-md-links"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); let root_uri = format!("file://{}", root.display()); let document_id = "local-md:Page~2FPage.md"; let resource = write_local_markdown_asset( &root_uri, document_id, "attachment", LocalUploadFile { name: "resource.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Resource\n".to_vec(), }, ) .expect("upload resource markdown"); let first = write_local_markdown_asset( &root_uri, document_id, "attachment", LocalUploadFile { name: "notes.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Notes one\n".to_vec(), }, ) .expect("upload first linked markdown"); let second = write_local_markdown_asset( &root_uri, document_id, "attachment", LocalUploadFile { name: "notes.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Notes two\n".to_vec(), }, ) .expect("upload second linked markdown"); assert_eq!(resource["sourcePath"], "resource.md"); assert_eq!(first["sourcePath"], "notes.md"); assert_eq!(second["sourcePath"], "notes-1.md"); std::fs::write( root.join("Page").join("resource.md"), "# Resource\n\n[notes.md](notes.md)\n\n[notes-1.md](notes-1.md)\n", ) .expect("write resource links"); let state = test_state(); state .control_plane() .upsert_user(UpsertUserInput { id: Some("resource_reader".into()), email: Some("resource-reader@example.com".into()), username: "resource_reader".into(), display_name: "resource_reader".into(), role: None, password_hash: None, }) .expect("upsert resource reader"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "resource_reader".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root .canonicalize() .expect("canonical root") .display() .to_string(), permission: "read".into(), recursive: true, capabilities: vec![], source: "test".into(), created_by: None, }) .expect("grant read"); let context = request_context("resource_reader", "user"); let (_, _, payload) = read_local_resource( State(state), Extension(context), Query(LocalResourceReadQuery { root_uri: root_uri.clone(), path: "Page/resource.md".into(), }), ) .await .expect("read resource markdown"); let media_paths = payload["result"]["content"] .as_array() .expect("content blocks") .iter() .filter(|block| block["type"].as_str() == Some("media")) .map(|block| { block["props"]["sourcePath"] .as_str() .unwrap_or_default() .to_string() }) .collect::>(); assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_delete_folder_moves_directory_to_trash() { let root = temp_root("mnote-local-delete-folder"); init_workspace(&root); std::fs::create_dir_all(root.join("Folder").join("Nested")).expect("create folder"); std::fs::write(root.join("Folder").join("Nested").join("note.txt"), "note") .expect("write nested file"); let root_uri = format!("file://{}", root.display()); let folder_id = format!("local-dir:{}", encode_local_id_segment("Folder")); let result = execute_local_tree_command(&root_uri, "delete", &folder_id, None, None) .expect("delete folder"); assert!( !root.join("Folder").exists(), "Folder should move away from root" ); let trash_path = result["trashPath"].as_str().expect("trashPath"); assert!( root.join(trash_path) .join("Nested") .join("note.txt") .is_file(), "folder contents should exist in trash" ); assert_eq!(result["resourceKind"].as_str(), Some("local_directory")); assert_eq!( result["trashEntryId"].as_str(), Some("local-dir-trash:Folder") ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_delete_page_bundle_directory_uses_page_trash() { let root = temp_root("mnote-local-delete-page-bundle-dir"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page bundle"); std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write markdown"); std::fs::write(root.join("Page").join("asset.txt"), "asset").expect("write asset"); let root_uri = format!("file://{}", root.display()); let folder_id = format!("local-dir:{}", encode_local_id_segment("Page")); let page_id = local_markdown_path_page_id("Page/Page.md"); let result = execute_local_tree_command(&root_uri, "delete", &folder_id, None, None) .expect("delete page bundle"); assert!( !root.join("Page").exists(), "页面 bundle 目录应整体移入垃圾箱" ); assert_eq!(result["id"].as_str(), Some(page_id.as_str())); assert_eq!(result["documentId"].as_str(), Some(page_id.as_str())); assert_eq!(result["resourceKind"].as_str(), Some("markdown_bundle")); assert_ne!( result["resourceKind"].as_str(), Some("local_directory"), "页面 bundle 目录不能作为普通资源目录进入垃圾箱" ); let trash_path = result["trashPath"].as_str().expect("trashPath"); assert!(root.join(trash_path).join("Page.md").is_file()); assert!(root.join(trash_path).join("asset.txt").is_file()); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_delete_local_file_id_uses_trash_index() { let root = temp_root("mnote-local-delete-local-file-id"); init_workspace(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset"); let root_uri = format!("file://{}", root.display()); let result = execute_local_tree_command( &root_uri, "delete", "local-file:docs/photo.png", None, None, ) .expect("delete local-file asset"); assert_eq!(result["resourceKind"].as_str(), Some("local_file")); assert_eq!( result["trashEntryId"].as_str(), Some("local-file:docs/photo.png") ); assert!(!root.join("docs").join("photo.png").exists()); assert!(root.join(".mnote").join("trash").join("photo.png").exists()); let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index"); assert!(trash_index.contains("local-file:docs/photo.png")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_delete_chinese_named_asset_in_subdir_uses_trash_index() { let root = temp_root("mnote-local-delete-chinese-asset"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create Page dir"); std::fs::write(root.join("Page").join("资源.ext"), b"chinese asset content") .expect("write asset"); let root_uri = format!("file://{}", root.display()); let asset_id = "local-file:Page/资源.ext"; let result = execute_local_tree_command(&root_uri, "delete", asset_id, None, None) .expect("delete chinese-named asset"); assert_eq!(result["resourceKind"].as_str(), Some("local_file")); assert_eq!(result["trashEntryId"].as_str(), Some(asset_id)); assert!(!root.join("Page").join("资源.ext").exists()); assert!(root.join(".mnote").join("trash").join("资源.ext").exists()); let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index"); assert!( trash_index.contains(asset_id), "trash index should contain {asset_id}: {trash_index}" ); let restored = execute_local_tree_command(&root_uri, "restore", asset_id, None, None) .expect("restore chinese-named asset"); assert_eq!(restored["documentId"].as_str(), Some(asset_id)); assert!(root.join("Page").join("资源.ext").is_file()); assert!(!root.join(".mnote").join("trash").join("资源.ext").exists()); execute_local_tree_command(&root_uri, "delete", asset_id, None, None) .expect("re-delete chinese-named asset"); let purged = execute_local_tree_command(&root_uri, "purge", asset_id, None, None) .expect("purge chinese-named asset"); assert_eq!(purged["ok"].as_bool(), Some(true)); assert!(!root.join(".mnote").join("trash").join("资源.ext").exists()); let trash_index_after_purge = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index after purge"); assert!( !trash_index_after_purge.contains(asset_id), "trash index should not contain purged {asset_id}: {trash_index_after_purge}" ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_move_with_sort_order_persists_file_order() { let root = temp_root("mnote-local-move-sort-order"); init_workspace(&root); std::fs::write(root.join("alpha.md"), "# Alpha\n").expect("write alpha"); std::fs::write(root.join("beta.md"), "# Beta\n").expect("write beta"); std::fs::write(root.join("source.md"), "# Source\n").expect("write source"); let root_uri = format!("file://{}", root.display()); let doc_id = "local-md:source.md"; let result = execute_local_tree_command_with_sort(&root_uri, "move", doc_id, None, None, Some(0)) .expect("move with sort_order should succeed"); assert!(result.get("_unsupportedFields").is_none()); assert!(root.join("source.md").is_file()); let order_index = std::fs::read_to_string(root.join(".mnote").join("file-order.json")).expect("order"); assert!( order_index.contains("\"source.md\""), "file-order 应记录排序后的 source.md: {order_index}" ); let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree snapshot after move"); let items = snapshot.projection["items"] .as_array() .expect("projection items"); let root_rows = items .iter() .filter(|item| item["parentNodeId"].is_null()) .map(|item| { item["resourceMeta"]["extra"]["source"]["relativePath"] .as_str() .unwrap_or("") }) .collect::>(); assert_eq!(root_rows.first().copied(), Some("source.md")); let result_without_sort = execute_local_tree_command(&root_uri, "move", doc_id, None, None) .expect("move without sort_order"); assert!(result_without_sort.get("_unsupportedFields").is_none()); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_move_returns_affected_parents() { let root = temp_root("mnote-local-move-affected-parents"); init_workspace(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("source.md"), "# Source\n").expect("write source"); let root_uri = format!("file://{}", root.display()); let result = execute_local_tree_command( &root_uri, "move", "local-md:source.md", Some("local-dir:docs"), None, ) .expect("move should succeed"); let affected = result["affectedParents"] .as_array() .expect("affected parents"); assert!( affected .iter() .any(|parent| parent["relativePath"].as_str() == Some("") && parent["reason"].as_str() == Some("old-parent")), "affectedParents 应包含旧父级 root: {affected:?}" ); assert!( affected .iter() .any(|parent| parent["relativePath"].as_str() == Some("docs") && parent["reason"].as_str() == Some("new-parent")), "affectedParents 应包含新父级 docs: {affected:?}" ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_file_tree_marks_page_bundle_folder_with_document_id() { let root = temp_root("mnote-local-page-bundle-folder-document-id"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page bundle"); std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page"); std::fs::write(root.join("Page").join("notes.txt"), "note").expect("write child"); let root_uri = format!("file://{}", root.display()); let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree"); let items = snapshot .projection .get("items") .and_then(Value::as_array) .expect("items"); let page_folder = items .iter() .find(|item| item["title"].as_str() == Some("Page")) .expect("page bundle folder row"); assert_eq!(page_folder["rowKind"].as_str(), Some("folder")); assert_eq!( page_folder["documentId"].as_str(), Some("local-md:Page~2FPage.md") ); assert_eq!( page_folder["resourceMeta"]["documentId"].as_str(), Some("local-md:Page~2FPage.md") ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_move_raw_file_into_folder() { let root = temp_root("mnote-local-move-raw-file"); init_workspace(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("report.pdf"), "pdf").expect("write report"); let root_uri = format!("file://{}", root.display()); let result = execute_local_tree_command( &root_uri, "move", "local:asset:report.pdf", Some("local:folder:docs"), None, ) .expect("move raw file"); assert!(root.join("docs").join("report.pdf").is_file()); assert!(!root.join("report.pdf").exists()); assert_eq!(result["resourceKind"].as_str(), Some("local_file")); assert_eq!(result["relativePath"].as_str(), Some("docs/report.pdf")); assert_eq!(result["previousRelativePath"].as_str(), Some("report.pdf")); assert_eq!( result["documentId"].as_str(), Some("local:asset:docs/report.pdf") ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_rename_returns_operation_contract() { let root = temp_root("mnote-local-rename-operation-contract"); init_workspace(&root); std::fs::write(root.join("Old.md"), "# Old\n").expect("write old"); let root_uri = format!("file://{}", root.display()); let result = execute_local_tree_command(&root_uri, "rename", "local-md:Old.md", None, Some("New")) .expect("rename should succeed"); assert_eq!(result["schema"], "mnote.local_file_operation_result.v1"); assert_eq!(result["action"], "rename"); assert_eq!(result["rootUri"], root_uri); assert!( result["operationId"] .as_str() .is_some_and(|value| value.starts_with("local-op-rename-")), "operationId 应稳定标记 action: {result:?}" ); assert_eq!(result["resource"]["relativePath"], "New.md"); assert_eq!(result["resource"]["documentId"], "local-md:New.md"); assert_eq!(result["resource"]["objectIdentity"]["objectKind"], "page"); assert_eq!(result["previousResource"]["relativePath"], "Old.md"); assert_eq!(result["revealTarget"]["relativePath"], "New.md"); assert_eq!(result["revealTarget"]["rowId"], "local:markdown:New.md"); assert_eq!(result["selectTarget"]["rowId"], "local:markdown:New.md"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_delete_returns_parent_focus_contract() { let root = temp_root("mnote-local-delete-operation-contract"); init_workspace(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::write(root.join("docs/remove.md"), "# Remove\n").expect("write remove"); let root_uri = format!("file://{}", root.display()); let result = execute_local_tree_command( &root_uri, "delete", "local-md:docs~2Fremove.md", None, None, ) .expect("delete should succeed"); assert_eq!(result["schema"], "mnote.local_file_operation_result.v1"); let affected = result["affectedParents"] .as_array() .expect("affected parents"); assert!( affected .iter() .any(|parent| parent["relativePath"].as_str() == Some("docs") && parent["reason"].as_str() == Some("old-parent")), "delete affectedParents 应包含旧父级 docs: {affected:?}" ); assert_eq!(result["previousResource"]["relativePath"], "docs/remove.md"); assert_eq!(result["selectTarget"]["relativePath"], "docs"); assert_eq!(result["selectTarget"]["rowId"], "local:folder:docs"); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_save_refreshes_search_index_after_write() { let root = temp_root("mnote-local-save-refresh-search-index"); init_workspace(&root); std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md"); let root_uri = format!("file://{}", root.display()); crate::routes::local_search_index::refresh_local_search_index( &root, &root_uri, "local-ws-search-index-test", ) .expect("initial index"); save_local_markdown_page( &root_uri, "local-md:README.md", None, &serde_json::json!([ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "After" }] }, { "id": "p_1", "type": "paragraph", "content": [{ "type": "text", "text": "new-token" }] } ]), ) .expect("save markdown"); let index = std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json")) .expect("index"); assert!(index.contains("new-token"), "{index}"); assert!(!index.contains("old-token"), "{index}"); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_file_open_rejects_root_escape() { let root = temp_root("mnote-local-file-open-root-escape"); std::fs::write(root.join("README.txt"), "hello").expect("write file"); init_workspace(&root); let root_uri = format!("file://{}", root.display()); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_test".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &Method::GET, &"/api/local-folder/files/open".parse().expect("uri"), &headers, ); let query = LocalFileOpenQuery { root_uri, path: "../escape.txt".into(), download: None, }; let error = open_local_file(State(test_state()), Extension(context), Query(query)) .await .expect_err("should reject root escape"); assert_eq!(error.status(), StatusCode::BAD_REQUEST); assert!(error.message().contains("root")); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_file_open_allows_read_grant() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-file-open-read-grant-root"); let policy_root = temp_root("mnote-local-file-open-read-grant-config"); let policy_file = policy_root.join("access-policy.json"); let root_uri = format!("file://{}", root.display()); std::fs::write(root.join("README.txt"), "hello").expect("write file"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); std::fs::write( &policy_file, serde_json::json!({ "grants": [ { "userId": "user_reader", "rootUri": root_uri, "permission": "read", "recursive": true } ] }) .to_string(), ) .expect("write policy"); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_reader".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &Method::GET, &"/api/local-folder/files/open".parse().expect("uri"), &headers, ); let (_, _, bytes) = open_local_file( State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, path: "README.txt".into(), download: None, }), ) .await .expect("read grant can open local file"); assert_eq!(bytes, b"hello"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&policy_root); } #[tokio::test] async fn local_file_open_allows_sqlite_directory_read_grant_without_json_policy() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-file-open-sqlite-read-grant-root"); let policy_root = temp_root("mnote-local-file-open-sqlite-read-grant-config"); let policy_file = policy_root.join("missing-access-policy.json"); let state = test_state(); let root_uri = format!("file://{}", root.display()); std::fs::write(root.join("README.txt"), "hello sqlite").expect("write file"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); state .control_plane() .upsert_user(UpsertUserInput { id: Some("sqlite_reader".into()), email: Some("sqlite-reader@example.com".into()), username: "sqlite_reader".into(), display_name: "sqlite_reader".into(), role: None, password_hash: None, }) .expect("upsert sqlite reader"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "sqlite_reader".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root .canonicalize() .expect("canonical root") .display() .to_string(), permission: "read".into(), recursive: true, capabilities: vec![], source: "test".into(), created_by: None, }) .expect("grant sqlite read"); let context = request_context("sqlite_reader", "user"); let (_, _, bytes) = open_local_file( State(state), Extension(context), Query(LocalFileOpenQuery { root_uri, path: "README.txt".into(), download: None, }), ) .await .expect("sqlite read grant can open local file"); assert_eq!(bytes, b"hello sqlite"); assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&policy_root); } #[tokio::test] async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root"); let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config"); let policy_file = policy_root.join("missing-access-policy.json"); let state = test_state(); let canonical_root = root.canonicalize().expect("canonical root"); let canonical_root_path = canonical_root.display().to_string(); let root_uri = format!("file://{canonical_root_path}"); std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); state .control_plane() .upsert_user(UpsertUserInput { id: Some("legacy_sqlite_reader".into()), email: Some("legacy-sqlite-reader@example.com".into()), username: "legacy_sqlite_reader".into(), display_name: "legacy_sqlite_reader".into(), role: None, password_hash: None, }) .expect("upsert sqlite reader"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "legacy_sqlite_reader".into(), workspace_id: None, root_uri: canonical_root_path.clone(), root_path: canonical_root_path, permission: "read".into(), recursive: true, capabilities: vec![], source: "legacy-test".into(), created_by: None, }) .expect("grant legacy sqlite read"); let context = request_context("legacy_sqlite_reader", "user"); let (_, _, bytes) = open_local_file( State(state), Extension(context), Query(LocalFileOpenQuery { root_uri, path: "README.txt".into(), download: None, }), ) .await .expect("legacy sqlite read grant can open local file"); assert_eq!(bytes, b"hello legacy sqlite"); assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&policy_root); } #[tokio::test] async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() { let _guard = env_lock().lock().expect("env lock"); let root = temp_root("mnote-local-resource-write-sqlite-grant-root"); let policy_root = temp_root("mnote-local-resource-write-sqlite-grant-config"); let policy_file = policy_root.join("missing-access-policy.json"); let state = test_state(); let root_uri = format!("file://{}", root.display()); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); std::fs::write(root.join("README.md"), "old").expect("seed file"); std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); state .control_plane() .upsert_user(UpsertUserInput { id: Some("sqlite_writer".into()), email: Some("sqlite-writer@example.com".into()), username: "sqlite_writer".into(), display_name: "sqlite_writer".into(), role: None, password_hash: None, }) .expect("upsert sqlite writer"); state .control_plane() .grant_directory_access(DirectoryGrantInput { user_id: "sqlite_writer".into(), workspace_id: None, root_uri: root_uri.clone(), root_path: root .canonicalize() .expect("canonical root") .display() .to_string(), permission: "write".into(), recursive: true, capabilities: vec![], source: "test".into(), created_by: None, }) .expect("grant sqlite write"); let context = request_context("sqlite_writer", "user"); let (_, _, payload) = write_local_resource( State(state), Extension(context), Json(LocalResourceWriteRequest { root_uri: root_uri.clone(), path: "README.md".into(), expected_file_version: None, content_format: Some("editorBlocks".into()), content: serde_json::json!([ { "type": "paragraph", "content": [{"type": "text", "text": "new content"}] } ]), tiptap_document: None, }), ) .await .expect("sqlite write grant can write local resource"); assert_eq!(payload["ok"], true); let written = std::fs::read_to_string(root.join("README.md")).expect("read file"); assert!(written.contains("new content")); assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy"); std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&policy_root); } #[tokio::test] async fn local_file_open_sets_image_and_markdown_content_type() { let root = temp_root("mnote-local-file-open-content-type"); init_workspace(&root); std::fs::write(root.join("photo.jpg"), b"jpg").expect("write image"); std::fs::write(root.join("note.md"), "# Note\n").expect("write markdown"); let root_uri = format!("file://{}", root.display()); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_test".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &Method::GET, &"/api/local-folder/files/open".parse().expect("uri"), &headers, ); let (_, image_headers, _) = open_local_file( State(test_state()), Extension(context.clone()), Query(LocalFileOpenQuery { root_uri: root_uri.clone(), path: "photo.jpg".into(), download: None, }), ) .await .expect("open image"); assert_eq!( image_headers .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()), Some("image/jpeg") ); let (_, markdown_headers, _) = open_local_file( State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, path: "note.md".into(), download: None, }), ) .await .expect("open markdown"); assert_eq!( markdown_headers .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()), Some("text/markdown; charset=utf-8") ); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_file_open_sets_pdf_and_office_content_type() { let root = temp_root("mnote-local-file-open-office-content-type"); init_workspace(&root); std::fs::write(root.join("spec.pdf"), b"pdf").expect("write pdf"); std::fs::write(root.join("report.docx"), b"docx").expect("write docx"); let root_uri = format!("file://{}", root.display()); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_test".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &Method::GET, &"/api/local-folder/files/open".parse().expect("uri"), &headers, ); let (_, pdf_headers, _) = open_local_file( State(test_state()), Extension(context.clone()), Query(LocalFileOpenQuery { root_uri: root_uri.clone(), path: "spec.pdf".into(), download: None, }), ) .await .expect("open pdf"); assert_eq!( pdf_headers .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()), Some("application/pdf") ); let (_, office_headers, _) = open_local_file( State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, path: "report.docx".into(), download: None, }), ) .await .expect("open docx"); assert_eq!( office_headers .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()), Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document") ); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_file_open_download_sets_content_disposition_filename() { let root = temp_root("mnote-local-file-open-download-filename"); init_workspace(&root); std::fs::write(root.join("报告 2026.docx"), b"docx").expect("write docx"); let root_uri = format!("file://{}", root.display()); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_test".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &Method::GET, &"/api/local-folder/files/open".parse().expect("uri"), &headers, ); let (_, response_headers, _) = open_local_file( State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, path: "报告 2026.docx".into(), download: Some(true), }), ) .await .expect("download local file"); let disposition = response_headers .get(axum::http::header::CONTENT_DISPOSITION) .and_then(|value| value.to_str().ok()) .expect("content disposition"); assert!(disposition.starts_with("attachment;")); assert!(disposition.contains("filename=\"__ 2026.docx\"")); assert!( disposition.contains("filename*=UTF-8''%E6%8A%A5%E5%91%8A%202026.docx"), "{disposition}" ); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn local_folder_open_downloads_directory_as_tar() { let root = temp_root("mnote-local-folder-open-download-directory"); init_workspace(&root); let reports = root.join("Reports"); std::fs::create_dir_all(&reports).expect("create reports"); std::fs::write(reports.join("summary.txt"), b"summary").expect("write summary"); let root_uri = format!("file://{}", root.display()); let mut headers = HeaderMap::new(); headers.insert("x-mnote-actor-id", "user_test".parse().unwrap()); headers.insert("x-mnote-actor-type", "user".parse().unwrap()); let context = RequestContext::from_http_parts( &Method::GET, &"/api/local-folder/files/open".parse().expect("uri"), &headers, ); let (_, response_headers, bytes) = open_local_file( State(test_state()), Extension(context), Query(LocalFileOpenQuery { root_uri, path: "Reports".into(), download: Some(true), }), ) .await .expect("download local directory"); assert_eq!( response_headers .get(axum::http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()), Some("application/x-tar") ); let disposition = response_headers .get(axum::http::header::CONTENT_DISPOSITION) .and_then(|value| value.to_str().ok()) .expect("content disposition"); assert!(disposition.contains("filename=\"Reports.tar\"")); assert!(bytes.windows(5).any(|window| window == b"ustar")); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_file_tree_classifies_mindmap_and_office_assets() { let root = temp_root("mnote-local-filetree-resource-kinds"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); std::fs::write(root.join("Page").join("Page.md"), "").expect("write md"); std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap"); std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx"); let root_uri = format!("file://{}", root.display()); let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "Page").expect("file tree"); let items = snapshot.projection["items"].as_array().expect("items"); let mindmap = items .iter() .find(|item| item["title"].as_str() == Some("思维导图123456.json")) .expect("mindmap row"); assert_eq!(mindmap["rowKind"].as_str(), Some("asset")); assert_eq!(mindmap["iconHint"].as_str(), Some("mindmap")); assert_eq!( mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(), Some("mindmap") ); let office = items .iter() .find(|item| item["title"].as_str() == Some("report.docx")) .expect("office row"); assert_eq!(office["rowKind"].as_str(), Some("asset")); assert_eq!(office["iconHint"].as_str(), Some("office")); assert_eq!( office["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(), Some("only_office") ); let _ = std::fs::remove_dir_all(&root); } // ── Gap A2 BufferStore 运行时接入集成测试 ──────────────────────── #[test] fn document_buffer_mark_saved_after_page_body_write() { let root = temp_root("mnote-buf-save-after-write"); std::fs::write(root.join("README.md"), "# Old\n").expect("write md"); let root_uri = format!("file://{}", root.display()); let store = crate::document_buffer_store::BufferStore::new(); let ws_path = crate::document_buffer_store::build_local_folder_workspace_path( &local_workspace_id(&root), &root_uri, "README.md", "local-md:README.md", ); // 模拟编辑器打开文档:先初始化 buffer store.get_or_create(&ws_path); // 经过 write_local_markdown_page_body 保存入口 write_local_markdown_page_body( &core_protocol::PageBodyWriteRequest { document_id: "local-md:README.md".into(), workspace_id: String::new(), source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, root_uri: root_uri.clone(), expected_file_version: None, base_content_hash: None, content_format: "editorBlocks".into(), content: serde_json::json!([ {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Written"}]} ]), editor_source: Some("unit-test".into()), }, Some(&store), ) .expect("write_local_markdown_page_body"); // 验证 buffer store 已被消费 let buf = store .get_by_path(&ws_path) .expect("buffer should exist after write"); assert_eq!( buf.dirty_state, core_protocol::DocBufferDirtyState::Clean, "保存后 buffer 应为 Clean" ); assert!(buf.file_version.is_some(), "file_version 应被更新"); assert!(buf.last_saved_at.is_some(), "last_saved_at 应被更新"); let _ = std::fs::remove_dir_all(&root); } #[test] fn document_buffer_marks_external_modified_from_local_folder_event() { let store = crate::document_buffer_store::BufferStore::new(); let ws_path = crate::document_buffer_store::build_local_folder_workspace_path( "ws_test", "file:///tmp/test-root", "doc.md", "local-md:doc.md", ); // 模拟编辑器打开文档:Clean 状态 buffer store.get_or_create(&ws_path); // 模拟 watcher 事件调用 let result = store.mark_external_modified(&ws_path, Some("external-editor".into())); assert!(result.is_some(), "外部修改应返回 buffer"); let buf = result.unwrap(); assert_eq!( buf.dirty_state, core_protocol::DocBufferDirtyState::ExternalModified, "Clean buffer 受外部修改后应变为 ExternalModified" ); assert_eq!(buf.external_actor.as_deref(), Some("external-editor")); } #[test] fn document_buffer_marks_stale_from_local_folder_event_when_dirty() { let store = crate::document_buffer_store::BufferStore::new(); let ws_path = crate::document_buffer_store::build_local_folder_workspace_path( "ws_test", "file:///tmp/test-root", "doc.md", "local-md:doc.md", ); // 模拟编辑器打开并且有未保存修改:Dirty 状态 buffer let buf = store.get_or_create(&ws_path); let mut dirty_buf = buf; dirty_buf.mark_dirty("sha256:dirty_content".into()); let key = crate::document_buffer_store::BufferKey::from_workspace_path(&ws_path); store.update(&key, dirty_buf); // 模拟 watcher 事件调用 let result = store.mark_external_modified(&ws_path, Some("vim".into())); assert!(result.is_some(), "外部修改应返回 buffer"); let buf = result.unwrap(); assert_eq!( buf.dirty_state, core_protocol::DocBufferDirtyState::Stale, "Dirty buffer 受外部修改后应变为 Stale" ); assert_eq!(buf.external_actor.as_deref(), Some("vim")); } #[test] fn local_tree_command_create_page_creates_timestamped_nested_bundle() { let root = temp_root("mnote-local-tree-create-page-timestamp"); init_workspace(&root); let root_uri = format!("file://{}", root.display()); let result = execute_local_tree_command(&root_uri, "create", "", None, Some("新页面")) .expect("create page with default title"); let relative_path = result["relativePath"].as_str().expect("relativePath"); let document_id = result["documentId"].as_str().expect("documentId"); // 应形如“新页面HHMMSS/新页面HHMMSS.md”。 assert!( relative_path.starts_with("新页面"), "relativePath should start with 新页面, got: {relative_path}" ); assert!( relative_path.ends_with(".md"), "relativePath should end with .md, got: {relative_path}" ); assert!( relative_path.len() > "新页面.md".len(), "expected timestamp suffix" ); let (dir, file) = relative_path.split_once('/').expect("nested bundle path"); assert_eq!(file, format!("{dir}.md")); assert!( root.join(dir).is_dir(), "bundle directory should exist: {}", dir ); // Markdown 文件应已创建。 assert!( root.join(relative_path).is_file(), "markdown file should exist: {relative_path}" ); // documentId 继续使用路径型身份。 assert!( document_id.starts_with("local-md:"), "documentId should be path-based" ); assert_eq!(result["action"], "create"); assert_eq!(result["sourceKind"], "local_folder"); // 页面标题来自文件名,默认正文不再写第二个标题真相。 let content = std::fs::read_to_string(root.join(relative_path)).expect("read markdown"); assert!( content.is_empty(), "new page body should be empty: {content}" ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_tree_command_create_folder_uses_timestamped_default_name() { let root = temp_root("mnote-local-tree-create-folder-timestamp"); init_workspace(&root); let root_uri = format!("file://{}", root.display()); let result = execute_local_tree_command(&root_uri, "create_folder", "", None, Some("新建文件夹")) .expect("create folder with default title"); let relative_path = result["relativePath"].as_str().expect("relativePath"); assert!( relative_path.starts_with("新建文件夹"), "relativePath should start with 新建文件夹, got: {relative_path}" ); assert!( relative_path.len() > "新建文件夹".len(), "expected timestamp suffix" ); assert!( root.join(relative_path).is_dir(), "default folder should create directory" ); assert!( !root .join(relative_path) .join(format!("{relative_path}.md")) .exists(), "default folder creation must not create page markdown" ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_page_tree_projects_nested_bundle_as_page_node() { let root = temp_root("mnote-local-page-tree-nested-bundle"); init_workspace(&root); let root_uri = format!("file://{}", root.display()); // 构造 Root/Root.md + Root/Child/Child.md。 std::fs::create_dir_all(root.join("Root")).expect("create Root dir"); std::fs::write(root.join("Root").join("Root.md"), "# Body Root\n").expect("write Root.md"); std::fs::create_dir_all(root.join("Root").join("Child")).expect("create Child dir"); std::fs::write( root.join("Root").join("Child").join("Child.md"), "# Body Child\n", ) .expect("write Child.md"); // 读取页面树。 let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("load page tree"); let items = snapshot.projection["items"].as_array().expect("items"); // 找到 Root 页面节点和 Child 页面节点。标题来自文件名,不来自正文 H1。 let root_node = items .iter() .find(|item| { item["title"].as_str() == Some("Root") && item["resourceMeta"]["resourceKind"].as_str() == Some("document") }) .expect("Root page node"); let child_node = items .iter() .find(|item| { item["title"].as_str() == Some("Child") && item["resourceMeta"]["resourceKind"].as_str() == Some("document") }) .expect("Child page node"); // Child 的父节点应是 Root 的 documentId。 let root_doc_id = root_node["resourceMeta"]["documentId"] .as_str() .or_else(|| root_node["nodeId"].as_str()) .expect("root documentId"); assert_eq!(child_node["parentNodeId"].as_str(), Some(root_doc_id)); assert_eq!(root_doc_id, "local-md:Root~2FRoot.md"); // Root bundle 目录不应额外生成 page-group 行。 let page_group_for_root = items.iter().find(|item| { item["rowId"] .as_str() .map_or(false, |id| id.contains("page-group:Root")) }); assert!( page_group_for_root.is_none(), "should not have page-group for Root" ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_file_tree_keeps_nested_bundle_filesystem_details() { let root = temp_root("mnote-local-file-tree-nested-bundle"); init_workspace(&root); let root_uri = format!("file://{}", root.display()); // 构造 Root/Root.md + Root/Child/Child.md。 std::fs::create_dir_all(root.join("Root")).expect("create Root dir"); std::fs::write(root.join("Root").join("Root.md"), "# Root\n").expect("write Root.md"); std::fs::create_dir_all(root.join("Root").join("Child")).expect("create Child dir"); std::fs::write( root.join("Root").join("Child").join("Child.md"), "# Child\n", ) .expect("write Child.md"); // 读取文件树。 let snapshot = load_local_folder_file_tree_snapshot_with_reveal( &root_uri, Some("local-md:Root~2FChild~2FChild.md"), ) .expect("load file tree"); let items = snapshot.projection["items"].as_array().expect("items"); // 文件树应保留 Root 文件夹和其中的 Root.md 真实条目。 let root_md = items.iter().find(|item| { item["title"].as_str() == Some("Root.md") && item["resourceMeta"]["resourceKind"].as_str() == Some("markdown") }); assert!( root_md.is_some(), "file tree should have Root.md as markdown row" ); let root_folder = items.iter().find(|item| { item["title"].as_str() == Some("Root") && item["resourceMeta"]["resourceKind"].as_str() == Some("folder") }); assert!( root_folder.is_some(), "file tree should have Root as folder row" ); // 同名页面包 reveal 时只需要让 Child 目录可见,不展开包内部 Child.md。 let child_folder = items.iter().find(|item| { item["title"].as_str() == Some("Child") && item["resourceMeta"]["resourceKind"].as_str() == Some("folder") }); assert!( child_folder.is_some(), "file tree should have Child folder under Root" ); let child_md = items.iter().find(|item| { item["title"].as_str() == Some("Child.md") && item["resourceMeta"]["resourceKind"].as_str() == Some("markdown") }); assert!( child_md.is_none(), "file tree should not auto-expand same-name bundle markdown" ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() { let root = temp_root("mnote-local-markdown-asset-upload"); init_workspace(&root); std::fs::create_dir_all(root.join("docs")).expect("create docs"); std::fs::create_dir_all(root.join("docs").join("README")).expect("create page dir"); std::fs::write( root.join("docs").join("README").join("README.md"), "# Assets\n", ) .expect("write md"); std::fs::create_dir_all(root.join("docs").join("README")).expect("create page dir"); std::fs::write(root.join("docs").join("README").join("photo.png"), b"old") .expect("write existing asset"); let root_uri = format!("file://{}", root.display()); let asset = write_local_markdown_asset( &root_uri, "local-md:docs~2FREADME~2FREADME.md", "image", LocalUploadFile { name: "photo.png".to_string(), content_type: "image/png".to_string(), bytes: b"new-image".to_vec(), }, ) .expect("upload asset"); assert_eq!(asset["sourcePath"], "photo-1.png"); assert_eq!(asset["file_url"], "photo-1.png"); assert_eq!(asset["asset_type"], "image"); assert_eq!(asset["sourceKind"], "local_folder"); assert_eq!(asset["uploadIntent"], "editor.markdown.attach"); assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png"); assert_eq!(asset["markdownRelativePath"], "photo-1.png"); assert_eq!( asset["ownerDocumentId"], "local-md:docs~2FREADME~2FREADME.md" ); assert_eq!( std::fs::read(root.join("docs").join("README").join("photo-1.png")) .expect("read copied asset"), b"new-image" ); let markdown_asset = write_local_markdown_asset( &root_uri, "local-md:docs~2FREADME~2FREADME.md", "attachment", LocalUploadFile { name: "notes.md".to_string(), content_type: "text/markdown".to_string(), bytes: b"# Uploaded notes\n".to_vec(), }, ) .expect("upload markdown asset"); assert_eq!(markdown_asset["sourcePath"], "notes.md"); assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach"); assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md"); assert_eq!(markdown_asset["markdownRelativePath"], "notes.md"); assert_eq!( markdown_asset["ownerDocumentId"], "local-md:docs~2FREADME~2FREADME.md" ); let uploaded_asset_index = std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json")) .expect("uploaded asset index"); assert!(uploaded_asset_index.contains("docs/README/notes.md")); let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README") .expect("file tree"); let items = snapshot.projection["items"].as_array().expect("items"); let notes_row = items .iter() .find(|item| item["title"].as_str() == Some("notes.md")) .expect("uploaded markdown asset row"); assert_eq!(notes_row["rowKind"].as_str(), Some("asset")); assert_eq!(notes_row["iconHint"].as_str(), Some("markdown")); assert_eq!( notes_row["resourceMeta"]["assetId"].as_str(), Some("local-file:docs/README/notes.md") ); let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree"); let page_items = page_tree.projection["items"] .as_array() .expect("page items"); let page_notes_row = page_items .iter() .find(|item| item["title"].as_str() == Some("notes.md")) .expect("uploaded markdown asset page row"); assert_eq!(page_notes_row["rowKind"].as_str(), Some("asset")); assert_eq!(page_notes_row["iconHint"].as_str(), Some("markdown")); assert_eq!( page_notes_row["resourceMeta"]["assetId"].as_str(), Some("local-file:docs/README/notes.md") ); let _ = std::fs::remove_dir_all(&root); } #[test] fn local_rename_markdown_page_renames_nested_bundle() { let root = temp_root("mnote-local-rename-nested-bundle"); init_workspace(&root); let root_uri = format!("file://{}", root.display()); // 构造 Old/Old.md + Old/Child.md。 std::fs::create_dir_all(root.join("Old")).expect("create Old dir"); std::fs::write(root.join("Old").join("Old.md"), "# Old\n").expect("write Old.md"); std::fs::write(root.join("Old").join("Child.md"), "# Child\n").expect("write Child.md"); // 将 Old 重命名为 New。 let old_document_id = "local-md:Old~2FOld.md"; let result = execute_local_tree_command(&root_uri, "rename", old_document_id, None, Some("New")) .expect("rename page"); // New/New.md 应存在。 assert!( root.join("New").join("New.md").is_file(), "New/New.md should exist" ); // New/Child.md 应存在。 assert!( root.join("New").join("Child.md").is_file(), "New/Child.md should exist" ); // Old/ 不应再存在。 assert!( !root.join("Old").exists(), "Old/ directory should not exist" ); assert_eq!(result["action"], "rename"); assert_eq!(result["sourceKind"], "local_folder"); assert_eq!(result["documentId"].as_str(), Some("local-md:New~2FNew.md")); let _ = std::fs::remove_dir_all(&root); } }