Files
mnote/rust/crates/mnote-web/src/routes/local_folder_source.rs
T

13229 lines
478 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::Json;
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
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::{Map, Value, json};
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);
#[cfg(test)]
static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT: OnceLock<Mutex<BTreeMap<String, u64>>> =
OnceLock::new();
#[derive(Debug, Clone)]
struct LocalPageTreeSnapshotCacheEntry {
watch_revision: LocalFolderWatchRevision,
snapshot: ProjectionSnapshot,
}
static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
> = OnceLock::new();
fn local_page_tree_snapshot_cache()
-> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
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(loads) = LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT.get() {
if let Ok(mut loads) = loads.lock() {
loads.clear();
}
}
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_for_root(root_uri: &str) -> u64 {
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT
.get_or_init(|| Mutex::new(BTreeMap::new()))
.lock()
.ok()
.and_then(|loads| loads.get(root_uri).copied())
.unwrap_or(0)
}
#[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<String, Value>,
trash_entries: BTreeMap<String, LocalTrashEntry>,
uploaded_assets: BTreeMap<String, LocalUploadedAssetEntry>,
file_order: BTreeMap<String, Vec<String>>,
}
#[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<String>,
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<String>,
action: String,
root_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
resource: Option<LocalFileOperationResource>,
#[serde(skip_serializing_if = "Option::is_none")]
previous_resource: Option<LocalFileOperationResource>,
affected_parents: Vec<AffectedParent>,
#[serde(skip_serializing_if = "Option::is_none")]
reveal_target: Option<RevealTarget>,
#[serde(skip_serializing_if = "Option::is_none")]
select_target: Option<RevealTarget>,
}
#[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<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalAccessPolicy {
#[serde(default)]
admins: Vec<String>,
#[serde(default)]
grants: Vec<LocalAccessGrant>,
}
#[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<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalShareGrantsStore {
#[serde(default)]
grants: Vec<LocalShareGrant>,
}
#[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<String>,
#[serde(default, alias = "access")]
permission: String,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default, alias = "createdAt")]
created_at: String,
#[serde(default, alias = "revokedAt")]
revoked_at: Option<String>,
#[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<u128>,
}
#[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<String>,
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<String>,
capabilities: Vec<String>,
workspace_id: String,
root_source_uri: String,
}
#[derive(Debug, Clone)]
struct LocalFolderScanResult {
rows: Vec<LocalFolderRow>,
watch_revision: LocalFolderWatchRevision,
}
#[derive(Debug, Clone)]
pub(crate) struct LocalUploadFile {
name: String,
content_type: String,
bytes: Vec<u8>,
}
#[derive(Debug, Clone)]
struct LocalAssetUploadFields {
file: LocalUploadFile,
root_uri: String,
document_id: String,
target_relative_path: Option<String>,
upload_intent: String,
kind: String,
}
fn local_folder_ok_response(
context: &RequestContext,
result: Value,
) -> (StatusCode, HeaderMap, Json<Value>) {
(
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<bool>,
}
#[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<String>,
#[serde(default, alias = "content_format")]
pub content_format: Option<String>,
#[serde(default)]
pub content: Value,
#[serde(default, alias = "tiptap_document")]
pub tiptap_document: Option<Value>,
}
#[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 payloadSQLite 目录授权创建时由控制面生成真实 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<String>,
}
#[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<String>,
#[serde(default, alias = "access")]
pub permission: String,
#[serde(default)]
pub capabilities: Vec<String>,
}
#[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<String>,
#[serde(default, alias = "access")]
pub permission: String,
#[serde(default, alias = "expires_at")]
pub expires_at: Option<String>,
}
#[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<PathBuf, WebError> {
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<PathBuf, WebError> {
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<PathBuf, WebError> {
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<PathBuf, 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 必须指向目录",
));
}
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<PathBuf, WebError> {
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<PathBuf, WebError> {
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<PathBuf, WebError> {
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<PathBuf, WebError> {
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<PathBuf, WebError> {
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<LocalAccessPolicy, WebError> {
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::<LocalAccessPolicy>(&content).map_err(|error| {
WebError::bad_request_code(
"local_access_policy_invalid",
format!("本地目录授权策略格式非法: {error}"),
)
})
}
fn load_local_share_grants_store() -> Result<LocalShareGrantsStore, WebError> {
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::<LocalShareGrantsStore>(&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<LocalAccessPolicy, 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_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<String> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn canonical_root_from_admin_request(root_uri: &str, root_path: &str) -> Result<PathBuf, WebError> {
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<String, WebError> {
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<Vec<String>, 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<String, WebError> {
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<Vec<String>, 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<Value, WebError> {
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::<Vec<_>>(),
}))
}
fn create_sqlite_share_link_for_context(
state: &AppState,
context: &RequestContext,
request: LocalShareLinkRequest,
) -> Result<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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::<Vec<_>>();
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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::<Vec<_>>();
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<LocalShareGrant, WebError> {
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<PathBuf> {
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::<Value>(&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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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::<Vec<String>>(&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<Value, WebError> {
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::<Vec<_>>();
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<Value, WebError> {
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::<Vec<_>>();
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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::<Vec<String>>(&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::<Vec<_>>();
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<Value, WebError> {
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::<Vec<_>>(),
},
}))
}
fn delete_sqlite_local_access_grant_for_context(
state: &AppState,
context: &RequestContext,
grant_id: &str,
) -> Result<Value, WebError> {
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::<Vec<_>>(),
},
}))
}
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<PathBuf> {
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<Value, WebError> {
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<Value, WebError> {
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<RequestContext>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
Json(request): Json<LocalAccessValidateRootRequest>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalAccessGrantRequest>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalAccessGrantRequest>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
AxumPath(grant_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
AxumPath(grant_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<ShareLinkListQuery>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalShareLinkRequest>,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
AxumPath(link_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
Json(request): Json<LocalShareGrantRequest>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
AxumPath(share_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
Json(request): Json<LocalShareGrantRequest>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
AxumPath(share_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
Json(request): Json<SharedCacheRecordRequest>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
Json(request): Json<SyncPendingChangeRequest>,
) -> Result<(StatusCode, Json<Value>), 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<RequestContext>,
Json(request): Json<SyncConflictReportRequest>,
) -> Result<(StatusCode, Json<Value>), 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<Value, WebError> {
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<LocalWorkspaceManifest, WebError> {
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<LocalWorkspaceManifest, WebError> {
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::<LocalWorkspaceManifest>(&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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, 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 必须指向目录",
));
}
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::<Vec<_>>();
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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
#[cfg(test)]
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
#[cfg(test)]
{
if let Ok(mut loads) = LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT
.get_or_init(|| Mutex::new(BTreeMap::new()))
.lock()
{
*loads.entry(root_uri.to_string()).or_insert(0) += 1;
}
}
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::<String, u32>::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::<Vec<_>>();
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::<Vec<_>>(),
}),
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<LocalFolderWatchRevision, 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 必须指向目录",
));
}
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<PageAggregate, 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 必须指向目录",
));
}
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<Value, WebError> {
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<Value, 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}"),
)
})?;
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,
&current_conflict_key,
buffer_state.as_ref(),
));
}
}
let (frontmatter, _) = split_frontmatter(&current);
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<Value, WebError> {
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_with_operation(
&ws_path,
file_version.to_string(),
format!("sha256:{file_version}"),
request.write_intent_id.clone(),
request.save_operation_id.clone(),
);
}
}
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),
);
map.insert(
"writeIntentId".into(),
request
.write_intent_id
.as_deref()
.map(Value::from)
.unwrap_or(Value::Null),
);
map.insert(
"saveOperationId".into(),
request
.save_operation_id
.as_deref()
.map(Value::from)
.unwrap_or(Value::Null),
);
}
Ok(result)
}
pub async fn upload_local_markdown_asset(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
multipart: Multipart,
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFileOpenQuery>,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFileOpenQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalResourceReadQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalResourceWriteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<PathBuf, 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}"),
)
})?;
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<PathBuf, WebError> {
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<LocalAssetUploadFields, WebError> {
let mut file: Option<LocalUploadFile> = None;
let mut root_uri = String::new();
let mut document_id = String::new();
let mut target_relative_path: Option<String> = 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<Value, 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 必须指向目录",
));
}
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_uploaded_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<Value, 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 必须指向目录",
));
}
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_uploaded_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<String> {
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<PathBuf, WebError> {
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<PathBuf, WebError> {
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<Value, 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}"),
)
})?;
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<Value, 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}"),
)
})?;
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<Value, 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}"),
)
})?;
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<Value, WebError> {
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<i64>,
) -> Result<Value, 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}"),
)
})?;
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<AffectedParent>,
seen: &mut BTreeSet<String>,
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<String> {
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<String>,
resource_kind: Option<&str>,
) -> Option<LocalFileOperationResource> {
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<String> {
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<AffectedParent> {
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) = result.get("parentRelativePath").and_then(Value::as_str) {
push_local_tree_affected_parent(&mut parents, &mut seen, parent_path.to_string(), "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<String> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<Value, WebError> {
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 parent_relative_path = normalize_relative_path(root, &parent_directory)?;
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,
"parentRelativePath": parent_relative_path,
"relativePath": relative_path,
"action": "create",
"sourceKind": "local_folder",
}))
}
fn create_local_folder(
root: &Path,
parent_id: Option<&str>,
title: &str,
) -> Result<Value, WebError> {
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));
let parent_relative_path = normalize_relative_path(root, &parent_directory)?;
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)?),
"parentRelativePath": parent_relative_path,
"relativePath": normalize_relative_path(root, &target)?,
"action": "createFolder",
"sourceKind": "local_folder",
}))
}
fn rename_local_entry(root: &Path, document_id: &str, title: &str) -> Result<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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, &copy_stem);
let target_stem = target_bundle
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&copy_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, &copy_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<i64>,
) -> Result<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
let mut metadata = load_local_folder_metadata(root)?;
let relative_path = normalize_relative_path(root, file)?;
if is_markdown_file(&relative_path) {
return Err(WebError::bad_request_code(
"local_file_resource_not_supported",
"Markdown 文件必须走页面生命周期,不能走 local_file 资源回收站",
));
}
let trash_dir = root.join(".mnote").join("trash");
fs::create_dir_all(&trash_dir).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法创建本地回收站 {}: {error}", trash_dir.display()),
)
})?;
let file_name = file
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("local-file");
let target = next_available_raw_path(&trash_dir, file_name);
fs::rename(file, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法移动本地资源到回收站 {}: {error}", file.display()),
)
})?;
let trash_relative_path = normalize_relative_path(root, &target)?;
let trash_entry_id = local_file_trash_entry_id(&relative_path);
let now = now_ms();
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
let mut metadata = load_local_folder_metadata(root)?;
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
WebError::bad_request_code(
"local_trash_entry_not_found",
"找不到要恢复的本地资源回收站记录",
)
})?;
let trash_entry = metadata
.trash_entries
.get(&trash_key)
.cloned()
.ok_or_else(|| {
WebError::bad_request_code(
"local_trash_entry_not_found",
"找不到要恢复的本地资源回收站记录",
)
})?;
let original_relative_path = local_trash_original_path(&trash_entry);
let trash_relative_path = local_trash_file_path(&trash_entry);
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
if !trash_path.is_file() {
return Err(WebError::bad_request_code(
"local_trash_entry_not_found",
"本地资源回收站文件不存在,无法恢复",
));
}
let original_path = resolve_metadata_relative_path(root, &original_relative_path)?;
if original_path.exists() {
return Err(WebError::new(
StatusCode::CONFLICT,
"local_file_restore_conflict",
"本地资源原路径已存在,恢复会覆盖用户文件",
));
}
let parent = original_path.parent().ok_or_else(|| {
WebError::bad_request_code("local_tree_command_failed", "无法解析资源恢复目标目录")
})?;
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法创建资源恢复目标目录 {}: {error}", parent.display()),
)
})?;
fs::rename(&trash_path, &original_path).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法从本地回收站恢复资源 {}: {error}", trash_path.display()),
)
})?;
metadata.trash_entries.remove(&trash_key);
write_trash_index_metadata(root, &metadata.trash_entries)?;
Ok(json!({
"ok": true,
"id": entry_id,
"documentId": entry_id,
"resourceKind": "local_file",
"resourceScope": "local_folder",
"originalFilePath": original_relative_path,
"relativePath": normalize_relative_path(root, &original_path)?,
"trashEntryId": trash_key,
"action": "restore",
"canonicalCommand": "tree.resource.restore",
"sourceKind": "local_folder",
}))
}
fn restore_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
let mut metadata = load_local_folder_metadata(root)?;
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
WebError::bad_request_code(
"local_trash_entry_not_found",
"找不到要永久删除的本地资源回收站记录",
)
})?;
let trash_entry = metadata.trash_entries.remove(&trash_key).ok_or_else(|| {
WebError::bad_request_code(
"local_trash_entry_not_found",
"找不到要永久删除的本地资源回收站记录",
)
})?;
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<PathBuf, WebError> {
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<Value, WebError> {
let metadata = load_local_folder_metadata(root)?;
let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
let files = serde_json::from_str::<Vec<LocalDropFilePayload>>(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<String>,
}
fn resolve_local_directory_id(root: &Path, entry_id: &str) -> Result<Option<PathBuf>, 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<Option<PathBuf>, 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<String> {
let trimmed = entry_id.trim();
if metadata
.trash_entries
.get(trimmed)
.map(is_local_file_trash_entry)
.unwrap_or(false)
{
return Some(trimmed.to_string());
}
let relative_path = trimmed
.strip_prefix("local:asset:")
.or_else(|| trimmed.strip_prefix("local:node:"))
.unwrap_or(trimmed);
let expected_key = local_file_trash_entry_id(relative_path);
if metadata
.trash_entries
.get(&expected_key)
.map(is_local_file_trash_entry)
.unwrap_or(false)
{
return Some(expected_key);
}
metadata
.trash_entries
.iter()
.find(|(_, entry)| {
is_local_file_trash_entry(entry) && local_trash_original_path(entry) == relative_path
})
.map(|(key, _)| key.clone())
}
fn find_local_directory_trash_entry_key(
metadata: &LocalFolderMetadata,
entry_id: &str,
) -> Option<String> {
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<String, 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}"),
)
})?;
Ok(local_workspace_id(&canonical_root))
}
pub(crate) fn local_root_has_workspace_manifest(root_uri: &str) -> Result<bool, 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}"),
)
})?;
Ok(load_local_workspace_manifest(&canonical_root).is_ok())
}
fn parse_file_root_uri(root_uri: &str) -> Result<PathBuf, WebError> {
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<String, WebError> {
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<LocalFolderMetadata, WebError> {
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<BTreeMap<String, LocalTrashEntry>, 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<BTreeMap<String, Value>, 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<Value, WebError> {
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<BTreeMap<String, LocalTrashEntry>, 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<String, LocalTrashEntry> = 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<BTreeMap<String, LocalUploadedAssetEntry>, 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<String, LocalUploadedAssetEntry> = serde_json::from_value(source.clone())
.map_err(|error| metadata_invalid(path, format!("上传资源索引损坏: {error}")))?;
Ok(entries)
}
fn load_file_order_metadata(path: &Path) -> Result<BTreeMap<String, Vec<String>>, 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<String, Vec<String>> = 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<String>) -> 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<String, 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}"),
)
})?;
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<String, LocalTrashEntry>,
) -> 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<String, LocalUploadedAssetEntry>,
) -> 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<String, Vec<String>>,
) -> 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<String>,
depth: u32,
max_depth: Option<u32>,
root_source_uri: &str,
workspace_id: &str,
metadata: &LocalFolderMetadata,
rows: &mut Vec<LocalFolderRow>,
) -> 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<String>,
depth: u32,
root_source_uri: &str,
workspace_id: &str,
metadata: &LocalFolderMetadata,
) -> Result<LocalFolderScanResult, 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);
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<String> {
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<String> {
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<String> {
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::<Vec<_>>()
.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<LocalFolderRow>,
) -> 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::<BTreeSet<_>>();
let mut seen = rows
.iter()
.map(|row| row.row_id.clone())
.collect::<BTreeSet<_>>();
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<String> {
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<Vec<LocalFolderEntry>, 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<bool, WebError> {
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<String> {
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<String, WebError> {
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::<Vec<_>>()
.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<PathBuf, WebError> {
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<String>) -> Vec<String> {
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<String, WebError> {
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::<Vec<_>>()
.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::<BTreeMap<_, _>>();
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<Vec<String>, 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::<Vec<_>>())
}
fn reorder_child_paths(children: &mut Vec<String>, 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<String, Vec<String>>,
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::<Vec<_>>();
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<String, Vec<String>>, 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::<Vec<_>>();
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<String>,
depth: u32,
skip_markdown_relative_path: Option<String>,
metadata: &LocalFolderMetadata,
rows: &mut Vec<LocalFolderRow>,
workspace_id: &str,
root_source_uri: &str,
) -> Result<bool, 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);
let mut directory_rows = Vec::<LocalFolderRow>::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<Option<LocalFolderEntry>, 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<Option<LocalFolderEntry>, 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<Option<LocalFolderEntry>, 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<String> {
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" | "word" | "ppt" | "sheet" => KernelObjectKind::OnlyOffice,
_ => KernelObjectKind::Attachment,
},
_ => KernelObjectKind::Attachment,
};
let object_asset_id = if row.row_kind == "asset" && !row.relative_path.trim().is_empty() {
Some(format!("local-file:{}", row.relative_path))
} else {
None
};
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: object_asset_id,
},
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<bool> {
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::<String>()
)
}
fn local_folder_watch_revision_for_root(
root: &Path,
root_source_uri: &str,
) -> Result<LocalFolderWatchRevision, WebError> {
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")
) {
"word"
} else if matches!(extension(&lower).as_deref(), Some("ppt" | "pptx" | "odp")) {
"ppt"
} else if matches!(
extension(&lower).as_deref(),
Some("xls" | "xlsx" | "ods" | "csv")
) {
"sheet"
} else if is_web_file_name(&lower) {
"web"
} else if is_config_file_name(&lower) {
"config"
} else if is_code_file_name(&lower) {
"code"
} else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) {
"book"
} else {
"unknown"
}
.to_string()
}
fn is_web_file_name(file_name: &str) -> bool {
matches!(
extension(file_name).as_deref(),
Some(
"html"
| "htm"
| "css"
| "scss"
| "less"
| "js"
| "jsx"
| "ts"
| "tsx"
| "mjs"
| "cjs"
| "vue"
| "svelte"
| "astro"
)
)
}
fn is_config_file_name(file_name: &str) -> bool {
let code_file_names = [
".dockerignore",
".editorconfig",
".env",
".eslintrc",
".gitattributes",
".gitignore",
".npmrc",
".prettierrc",
"dockerfile",
"containerfile",
"makefile",
"cmakelists.txt",
"gemfile",
"rakefile",
"procfile",
];
code_file_names.contains(&file_name)
|| matches!(
extension(file_name).as_deref(),
Some(
"json"
| "jsonc"
| "json5"
| "toml"
| "yaml"
| "yml"
| "ini"
| "env"
| "xml"
| "lock"
| "hcl"
| "tf"
| "tfvars"
| "nix"
| "properties"
| "conf"
| "cfg"
| "config"
| "service"
| "desktop"
| "gitignore"
| "gitattributes"
| "editorconfig"
| "npmrc"
| "prettierrc"
| "eslintrc"
)
)
}
fn is_code_file_name(file_name: &str) -> bool {
matches!(
extension(file_name).as_deref(),
Some(
"rs" | "py"
| "go"
| "java"
| "c"
| "cpp"
| "h"
| "hpp"
| "cs"
| "php"
| "rb"
| "sh"
| "bash"
| "zsh"
| "sql"
| "lua"
| "dart"
| "kt"
| "kts"
| "swift"
| "scala"
| "gradle"
| "groovy"
| "clj"
| "ex"
| "exs"
| "erl"
| "hrl"
| "fs"
| "fsx"
| "r"
| "jl"
| "m"
| "mm"
| "pl"
| "pm"
| "ps1"
| "bat"
| "cmd"
| "psm1"
| "psd1"
| "proto"
| "graphql"
| "gql"
| "prisma"
| "cmake"
| "bazel"
| "bzl"
)
)
}
fn extension(file_name: &str) -> Option<String> {
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<String, WebError> {
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::<String>();
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::<String>();
let trimmed = sanitized.trim().trim_matches('.').trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
fn sanitize_uploaded_file_name(value: &str, fallback: &str) -> String {
let trimmed = value.trim();
let leaf = trimmed
.rsplit(['/', '\\'])
.find(|part| !part.trim().is_empty())
.unwrap_or(trimmed);
sanitize_file_name(leaf, fallback)
}
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<String, WebError> {
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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>()
.join("\n");
if text.ends_with('\n') {
text
} else {
format!("{text}\n")
}
}
fn file_extension(file_name: &str) -> Option<String> {
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<String> {
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<String> {
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::<String>();
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<u8> {
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<usize> = 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<u8>,
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::<u32>();
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<u8>, 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<u8>,
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::<Result<Vec<_>, _>>()?;
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<Vec<u8>, 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::<Vec<(String, bool)>>::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::<Vec<_>>()
})
.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::<String>::new();
for (index, row) in parsed_rows.iter().enumerate() {
let mut cells = row.iter().map(|(text, _)| text.clone()).collect::<Vec<_>>();
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::<Vec<_>>()
.join(" | ");
lines.push(format!("| {} |", align_row));
}
}
if lines.len() < 2 {
String::new()
} else {
lines.join("\n")
}
}
fn alignments_from_table(block: &Value) -> Vec<String> {
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::<Vec<_>>()
})
.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::<Vec<_>>()
.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::<Vec<_>>()
.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<String, Value>) -> 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)
}
pub(crate) fn local_markdown_conflict_detection_key(
document_id: &str,
markdown_path: &Path,
) -> Result<String, WebError> {
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<String, WebError> {
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::<Vec<_>>();
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::{
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
SyncConflictReportRequest, SyncPendingChangeRequest,
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,
};
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, Method, StatusCode};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{Value, json};
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::<Vec<_>>();
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),
write_intent_id: Some("intent:test:page-body-write".into()),
save_operation_id: Some("save:test:page-body-write".into()),
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["writeIntentId"], "intent:test:page-body-write");
assert_eq!(result["saveOperationId"], "save:test:page-body-write");
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::<Vec<_>>();
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::<Vec<_>>();
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=2GFM 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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");
std::fs::write(root.join("Page").join("sheet.xlsx"), b"xlsx").expect("write xlsx");
std::fs::write(root.join("Page").join("slides.pptx"), b"pptx").expect("write pptx");
std::fs::write(root.join("Page").join("main.rs"), b"fn main() {}").expect("write rs");
std::fs::write(
root.join("Page").join("app.tsx"),
b"export const App = () => null;",
)
.expect("write tsx");
std::fs::write(root.join("Page").join("package.json"), b"{}").expect("write package");
std::fs::write(root.join("Page").join(".env"), b"KEY=value").expect("write env");
std::fs::write(root.join("Page").join("config.yaml"), b"name: mnote").expect("write yaml");
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")
);
assert_eq!(
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
Some("local-file:Page/思维导图123456.json")
);
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("word"));
assert_eq!(
office["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
Some("only_office")
);
assert_eq!(
office["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
Some("local-file:Page/report.docx")
);
let sheet = items
.iter()
.find(|item| item["title"].as_str() == Some("sheet.xlsx"))
.expect("sheet row");
assert_eq!(sheet["iconHint"].as_str(), Some("sheet"));
let ppt = items
.iter()
.find(|item| item["title"].as_str() == Some("slides.pptx"))
.expect("ppt row");
assert_eq!(ppt["iconHint"].as_str(), Some("ppt"));
let rust = items
.iter()
.find(|item| item["title"].as_str() == Some("main.rs"))
.expect("rust row");
assert_eq!(rust["iconHint"].as_str(), Some("code"));
let tsx = items
.iter()
.find(|item| item["title"].as_str() == Some("app.tsx"))
.expect("tsx row");
assert_eq!(tsx["iconHint"].as_str(), Some("web"));
let package_json = items
.iter()
.find(|item| item["title"].as_str() == Some("package.json"))
.expect("package row");
assert_eq!(package_json["iconHint"].as_str(), Some("config"));
let env = items
.iter()
.find(|item| item["title"].as_str() == Some(".env"))
.expect("env row");
assert_eq!(env["iconHint"].as_str(), Some("config"));
let yaml = items
.iter()
.find(|item| item["title"].as_str() == Some("config.yaml"))
.expect("yaml row");
assert_eq!(yaml["iconHint"].as_str(), Some("config"));
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,
write_intent_id: Some("intent:test:buffer-save".into()),
save_operation_id: Some("save:test:buffer-save".into()),
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");
assert_eq!(result["parentRelativePath"], "");
assert!(
result["affectedParents"]
.as_array()
.expect("affected parents")
.iter()
.any(|parent| parent["relativePath"].as_str() == Some("")),
"create page affectedParents 应包含承载新页面包的父级 root: {}",
result["affectedParents"]
);
// 页面标题来自文件名,默认正文不再写第二个标题真相。
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"
);
assert_eq!(result["parentRelativePath"], "");
assert!(
result["affectedParents"]
.as_array()
.expect("affected parents")
.iter()
.any(|parent| parent["relativePath"].as_str() == Some("")),
"create folder affectedParents 应包含承载新文件夹的父级 root: {}",
result["affectedParents"]
);
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 windows_path_asset = write_local_markdown_asset(
&root_uri,
"local-md:docs~2FREADME~2FREADME.md",
"attachment",
LocalUploadFile {
name: "C:\\Users\\liaib\\Downloads\\1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx".to_string(),
content_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
.to_string(),
bytes: b"pptx".to_vec(),
},
)
.expect("upload windows path named pptx asset");
assert_eq!(
windows_path_asset["file_name"],
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
);
assert_eq!(
windows_path_asset["sourcePath"],
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
);
assert_eq!(
windows_path_asset["rootRelativePath"],
"docs/README/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
);
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);
}
}