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

3561 lines
125 KiB
Rust
Raw Normal View History

2026-05-08 00:41:03 +08:00
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,
};
2026-05-08 00:41:03 +08:00
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::http::StatusCode;
use bridge_runtime::project_legacy_content_to_block_document;
2026-05-08 00:41:03 +08:00
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::cmp::Ordering;
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
struct LocalFolderEntry {
path: PathBuf,
relative_path: String,
file_name: String,
is_dir: bool,
is_symlink: bool,
is_readonly: bool,
}
#[derive(Debug, Clone, Default)]
struct LocalFolderMetadata {
page_ids: BTreeMap<String, String>,
page_options: BTreeMap<String, Value>,
trash_entries: BTreeMap<String, LocalTrashEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalTrashEntry {
#[serde(default, alias = "documentId")]
2026-05-08 00:41:03 +08:00
document_id: String,
#[serde(default, alias = "resourceKind")]
resource_kind: String,
#[serde(default, alias = "resourceScope")]
resource_scope: String,
#[serde(default, alias = "originalFilePath")]
original_file_path: String,
#[serde(default, alias = "trashedFilePath")]
trashed_file_path: String,
#[serde(default, alias = "trashEntryId")]
trash_entry_id: String,
#[serde(default, alias = "originalRelativePath")]
2026-05-08 00:41:03 +08:00
original_relative_path: String,
#[serde(default, alias = "trashRelativePath")]
2026-05-08 00:41:03 +08:00
trash_relative_path: String,
#[serde(default, alias = "deletedAtMs")]
2026-05-08 00:41:03 +08:00
deleted_at_ms: u128,
#[serde(default, alias = "archivedAt")]
archived_at: u128,
#[serde(default, alias = "purgedAt")]
purged_at: Option<u128>,
2026-05-08 00:41:03 +08:00
}
#[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>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalFolderWatchRevision {
pub root_uri: String,
pub revision: String,
pub entry_count: usize,
pub latest_modified_ms: u128,
}
pub fn load_local_folder_file_tree_snapshot(
root_uri: &str,
) -> Result<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 root_source_uri = file_uri_for_path(&canonical_root);
let metadata = load_local_folder_metadata(&canonical_root)?;
let mut rows = Vec::new();
scan_directory(
&canonical_root,
&canonical_root,
None,
0,
&root_source_uri,
&metadata,
&mut rows,
)?;
let items = rows
.iter()
.map(local_folder_row_to_projection_item)
.collect::<Vec<_>>();
let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
let dataset = json!({
"workspace": {
"id": local_workspace_id(&canonical_root),
"sourceKind": "local_folder",
"rootUri": root_source_uri,
},
"nodes": [],
"edges": [],
"documents": [],
"media_assets": [],
"mindmap_assets": [],
"table_assets": [],
"mindmap_asset_children": {},
});
let projection = json!({
"projection": "file_tree",
"sourceKind": "local_folder",
"rootUri": root_source_uri,
"watchRevision": watch_revision,
"items": items,
});
Ok(ProjectionSnapshot {
dataset,
projection,
})
}
pub fn load_local_folder_page_tree_snapshot(
root_uri: &str,
) -> Result<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 root_source_uri = file_uri_for_path(&canonical_root);
let metadata = load_local_folder_metadata(&canonical_root)?;
let mut rows = Vec::new();
scan_markdown_page_tree(
&canonical_root,
&canonical_root,
None,
0,
&metadata,
&mut rows,
)?;
let child_counts = rows
.iter()
.filter_map(|row| row.parent_node_id.as_ref())
.fold(
std::collections::BTreeMap::<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 watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
Ok(ProjectionSnapshot {
dataset: json!({
"workspace": {
"id": local_workspace_id(&canonical_root),
"sourceKind": "local_folder",
"rootUri": root_source_uri,
},
"documents": rows
.iter()
.filter_map(|row| {
row.document_id.as_ref().map(|document_id| {
json!({
"id": document_id,
"workspace_id": local_workspace_id(&canonical_root),
"title": row.title,
"parent_id": row.parent_node_id,
"sort_order": row.position,
})
})
})
.collect::<Vec<_>>(),
}),
projection: json!({
"projection": "page_tree",
"sourceKind": "local_folder",
"rootUri": root_source_uri,
"watchRevision": watch_revision,
"items": items,
}),
})
}
pub fn local_folder_watch_revision(root_uri: &str) -> Result<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 content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body);
2026-05-08 00:41:03 +08:00
let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
let page_subtree = markdown_page_subtree(document_id, &title, &content);
let workspace_id = local_workspace_id(&canonical_root);
let page_options = metadata
.page_options
.get(document_id)
.or_else(|| metadata.page_options.get(&markdown_file.relative_path))
.map(page_options_from_metadata)
.unwrap_or_default();
let character_count = parsed.body.chars().count() as u64;
let word_count = parsed
.body
.split_whitespace()
.filter(|word| !word.trim().is_empty())
.count() as u64;
let read_only = markdown_file.is_readonly;
let block_document =
project_legacy_content_to_block_document(document_id, &content, &Value::Number(0.into()))
.map_err(|error| WebError::internal(format!("{error:?}")))?;
2026-05-08 00:41:03 +08:00
Ok(PageAggregate {
schema: PageAggregate::SCHEMA.into(),
projection_version: PageAggregate::VERSION,
source: PageAggregateSource::KernelProjection,
page_id: document_id.to_string(),
parent_id: None,
title: title.clone(),
path: vec![document_id.to_string()],
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
body_ref: Some(format!("local-md:{document_id}")),
layout_options: serde_json::to_value(&page_options).unwrap_or_else(|_| json!({})),
updated_at: None,
identity: PageIdentity {
document_id: document_id.to_string(),
workspace_id,
},
head: PageHead {
title,
updated_at: Value::Null,
permissions: PagePermissions {
read_only,
disable_download: false,
disable_copy: false,
},
},
layout: PageLayout { page_options },
body: PageBody {
content,
revision: Value::Number(0.into()),
conflict_detection_key: Value::String(local_markdown_conflict_detection_key(
document_id,
&markdown_file.path,
)?),
block_document,
block_projection_version: 1,
projection_source: "local_markdown.content".into(),
2026-05-08 00:41:03 +08:00
},
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>,
2026-05-08 00:41:03 +08:00
content: &Value,
) -> 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 current = fs::read_to_string(&markdown_file.path).map_err(|error| {
WebError::bad_request_code(
"local_markdown_read_failed",
format!(
"无法读取本地 Markdown 文件 {}: {error}",
markdown_file.path.display()
),
)
})?;
let current_conflict_key =
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
if let Some(expected_key) = expected_conflict_detection_key
.map(str::trim)
.filter(|value| !value.is_empty())
{
if expected_key != current_conflict_key {
return Err(WebError::new(
StatusCode::CONFLICT,
"local_markdown_external_change",
"本地 Markdown 文件已被外部修改,请刷新后再保存",
));
}
}
2026-05-08 00:41:03 +08:00
let (frontmatter, _) = split_frontmatter(&current);
let body = editor_blocks_to_markdown(content);
let next_markdown = if let Some(frontmatter) = frontmatter {
format!("---\n{frontmatter}\n---\n{body}")
} else {
body
};
fs::write(&markdown_file.path, next_markdown).map_err(|error| {
WebError::bad_request_code(
"local_markdown_write_failed",
format!(
"无法保存本地 Markdown 文件 {}: {error}",
markdown_file.path.display()
),
)
})?;
let next_conflict_key =
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
2026-05-08 00:41:03 +08:00
Ok(json!({
"ok": true,
"documentId": document_id,
"revision": now_ms(),
"conflict_detection_key": next_conflict_key,
2026-05-08 00:41:03 +08:00
"executedCommand": "page.body.save",
"canonicalCommand": "page.body.save",
"sourceKind": "local_folder",
}))
}
pub fn update_local_markdown_title(
root_uri: &str,
document_id: &str,
title: &str,
) -> Result<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 current = fs::read_to_string(&markdown_file.path).map_err(|error| {
WebError::bad_request_code(
"local_markdown_read_failed",
format!(
"无法读取本地 Markdown 文件 {}: {error}",
markdown_file.path.display()
),
)
})?;
fs::write(
&markdown_file.path,
markdown_with_frontmatter_fields(&current, &[("title", title.trim())]),
)
.map_err(|error| {
WebError::bad_request_code(
"local_markdown_write_failed",
format!(
"无法写入本地 Markdown 标题 {}: {error}",
markdown_file.path.display()
),
)
})?;
Ok(json!({
"ok": true,
"documentId": document_id,
"title": title.trim(),
"updated_at": Value::Null,
"sourceKind": "local_folder",
}))
}
pub fn update_local_page_options(
root_uri: &str,
document_id: &str,
options: &Value,
) -> Result<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 mut metadata = load_local_folder_metadata(&canonical_root)?;
metadata
.page_options
.insert(document_id.to_string(), options.clone());
write_page_options_metadata(&canonical_root, &metadata.page_options)?;
Ok(json!({
"ok": true,
"documentId": document_id,
"options": options,
"updated_at": Value::Null,
"sourceKind": "local_folder",
}))
}
pub fn execute_local_tree_command(
root_uri: &str,
action: &str,
document_id: &str,
parent_id: Option<&str>,
title: Option<&str>,
) -> Result<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}"),
)
})?;
match action {
"create" => {
create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面"))
}
"createFolder" | "create_folder" | "folder.create" => {
create_local_folder(&canonical_root, parent_id, title.unwrap_or("新建文件夹"))
}
"rename" => rename_local_entry(&canonical_root, document_id, title.unwrap_or("无标题")),
"copy" => {
copy_local_markdown_page(&canonical_root, document_id, parent_id, title.unwrap_or(""))
}
"dropFiles" | "drop_files" => drop_external_files_into_local_folder(
&canonical_root,
parent_id,
title.unwrap_or("外部文件"),
),
"move" => move_local_entry(&canonical_root, document_id, parent_id),
"delete" | "trash" => trash_local_entry(&canonical_root, document_id),
"restore" => restore_local_entry(&canonical_root, document_id),
"purge" => purge_local_entry(&canonical_root, document_id),
2026-05-08 00:41:03 +08:00
other => Err(WebError::bad_request_code(
"local_tree_command_unsupported",
format!("local_folder 暂不支持 tree action: {other}"),
)),
}
}
fn create_local_markdown_page(
root: &Path,
parent_id: Option<&str>,
title: &str,
) -> Result<Value, WebError> {
let metadata = load_local_folder_metadata(root)?;
let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
let safe_title = sanitize_file_stem(title, "新页面");
let target = next_available_path(&parent_directory, &safe_title, "md");
let relative_path = normalize_relative_path(root, &target)?;
let page_id = normalize_metadata_page_id(&format!(
"local-mdid:{}",
encode_local_id_segment(&format!("{}:{relative_path}", local_workspace_id(root)))
));
let markdown = markdown_with_frontmatter_fields(
&format!("# {}\n", title.trim()),
&[("mnote_id", page_id.as_str()), ("title", title.trim())],
);
fs::write(&target, markdown).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法创建本地 Markdown 文件 {}: {error}", target.display()),
)
})?;
let mut page_ids = metadata.page_ids;
page_ids.insert(relative_path.clone(), page_id.clone());
write_page_ids_metadata(root, &page_ids)?;
Ok(json!({
"ok": true,
"id": page_id,
"documentId": page_id,
"relativePath": relative_path,
"action": "create",
"sourceKind": "local_folder",
}))
}
fn create_local_folder(
root: &Path,
parent_id: Option<&str>,
title: &str,
) -> Result<Value, WebError> {
let metadata = load_local_folder_metadata(root)?;
let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
let target =
next_available_directory_path(&parent_directory, &sanitize_file_stem(title, "新建文件夹"));
fs::create_dir_all(&target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法创建本地文件夹 {}: {error}", target.display()),
)
})?;
Ok(json!({
"ok": true,
"id": local_directory_group_id(&normalize_relative_path(root, &target)?),
"relativePath": normalize_relative_path(root, &target)?,
"action": "createFolder",
"sourceKind": "local_folder",
}))
}
fn rename_local_entry(root: &Path, document_id: &str, title: &str) -> Result<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)?),
"relativePath": normalize_relative_path(root, &target)?,
"action": "rename",
"sourceKind": "local_folder",
}))
}
fn rename_local_markdown_page(
root: &Path,
document_id: &str,
title: &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 parent = markdown_file.path.parent().ok_or_else(|| {
WebError::bad_request_code("local_tree_command_failed", "无法解析 Markdown 父目录")
})?;
let target = next_available_path(parent, &sanitize_file_stem(title, "无标题"), "md");
let old_relative_path = markdown_file.relative_path;
fs::rename(&markdown_file.path, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!(
"无法重命名本地 Markdown 文件 {}: {error}",
markdown_file.path.display()
),
)
})?;
let new_relative_path = normalize_relative_path(root, &target)?;
metadata.page_ids.remove(&old_relative_path);
metadata
.page_ids
.insert(new_relative_path.clone(), document_id.to_string());
write_page_ids_metadata(root, &metadata.page_ids)?;
Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"relativePath": new_relative_path,
"action": "rename",
"sourceKind": "local_folder",
}))
}
fn rename_local_directory(root: &Path, directory: &Path, title: &str) -> Result<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 parent = directory.parent().ok_or_else(|| {
WebError::bad_request_code("local_tree_command_failed", "无法解析文件夹父目录")
})?;
let target = next_available_directory_path(parent, &sanitize_file_stem(title, "文件夹"));
let old_relative_path = normalize_relative_path(root, directory)?;
fs::rename(directory, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法重命名本地文件夹 {}: {error}", directory.display()),
)
})?;
let new_relative_path = normalize_relative_path(root, &target)?;
rewrite_page_ids_prefix(
&mut metadata.page_ids,
&old_relative_path,
&new_relative_path,
);
write_page_ids_metadata(root, &metadata.page_ids)?;
Ok(json!({
"ok": true,
"id": local_directory_group_id(&new_relative_path),
"relativePath": new_relative_path,
"action": "rename",
"sourceKind": "local_folder",
}))
}
fn copy_local_markdown_page(
root: &Path,
document_id: &str,
parent_id: Option<&str>,
title: &str,
) -> Result<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 target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
let source_stem = markdown_file
.path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("页面");
let copy_stem = if title.trim().is_empty() {
source_stem
} else {
title.trim()
};
let target = next_available_path(
&target_directory,
&sanitize_file_stem(copy_stem, "页面"),
"md",
);
let new_relative_path = normalize_relative_path(root, &target)?;
let new_page_id = normalize_metadata_page_id(&format!(
"local-mdid:{}",
encode_local_id_segment(&format!("{}:{new_relative_path}", local_workspace_id(root)))
));
let source_markdown = fs::read_to_string(&markdown_file.path).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!(
"无法读取待复制 Markdown 文件 {}: {error}",
markdown_file.path.display()
),
)
})?;
fs::write(
&target,
markdown_with_frontmatter_fields(&source_markdown, &[("mnote_id", new_page_id.as_str())]),
)
.map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法复制本地 Markdown 文件到 {}: {error}", target.display()),
)
})?;
metadata
.page_ids
.insert(new_relative_path.clone(), new_page_id.clone());
write_page_ids_metadata(root, &metadata.page_ids)?;
Ok(json!({
"ok": true,
"id": new_page_id,
"documentId": new_page_id,
"relativePath": new_relative_path,
"action": "copy",
"sourceKind": "local_folder",
}))
}
fn move_local_entry(
root: &Path,
document_id: &str,
parent_id: Option<&str>,
) -> Result<Value, WebError> {
if let Some(directory) = resolve_local_directory_id(root, document_id)? {
return move_local_directory(root, &directory, parent_id);
}
move_local_markdown_page(root, document_id, parent_id)
}
fn move_local_markdown_page(
root: &Path,
document_id: &str,
parent_id: Option<&str>,
) -> Result<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 target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
if markdown_file.path.parent() == Some(target_directory.as_path()) {
return Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"relativePath": markdown_file.relative_path,
"action": "move",
"sourceKind": "local_folder",
}));
}
let file_name = markdown_file
.path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| sanitize_file_stem(stem, "页面"))
.unwrap_or_else(|| "页面".to_string());
let target = next_available_path(&target_directory, &file_name, "md");
if target == markdown_file.path {
return Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"relativePath": markdown_file.relative_path,
"action": "move",
"sourceKind": "local_folder",
}));
}
if target.starts_with(&markdown_file.path) {
return Err(WebError::bad_request_code(
"local_tree_command_failed",
"禁止把本地页面移动到自身或后代",
));
}
let old_relative_path = markdown_file.relative_path;
fs::rename(&markdown_file.path, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!(
"无法移动本地 Markdown 文件 {}: {error}",
markdown_file.path.display()
),
)
})?;
let new_relative_path = normalize_relative_path(root, &target)?;
metadata.page_ids.remove(&old_relative_path);
metadata
.page_ids
.insert(new_relative_path.clone(), document_id.to_string());
write_page_ids_metadata(root, &metadata.page_ids)?;
Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"relativePath": new_relative_path,
"action": "move",
"sourceKind": "local_folder",
}))
}
fn move_local_directory(
root: &Path,
directory: &Path,
parent_id: Option<&str>,
) -> Result<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 target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
if target_directory == directory || target_directory.starts_with(directory) {
return Err(WebError::bad_request_code(
"local_tree_command_failed",
"禁止把本地文件夹移动到自身或后代",
));
}
if directory.parent() == Some(target_directory.as_path()) {
return Ok(json!({
"ok": true,
"id": local_directory_group_id(&normalize_relative_path(root, directory)?),
"relativePath": normalize_relative_path(root, directory)?,
"action": "move",
"sourceKind": "local_folder",
}));
}
let stem = directory
.file_name()
.and_then(|name| name.to_str())
.map(|name| sanitize_file_stem(name, "文件夹"))
.unwrap_or_else(|| "文件夹".to_string());
let target = next_available_directory_path(&target_directory, &stem);
let old_relative_path = normalize_relative_path(root, directory)?;
fs::rename(directory, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法移动本地文件夹 {}: {error}", directory.display()),
)
})?;
let new_relative_path = normalize_relative_path(root, &target)?;
rewrite_page_ids_prefix(
&mut metadata.page_ids,
&old_relative_path,
&new_relative_path,
);
write_page_ids_metadata(root, &metadata.page_ids)?;
Ok(json!({
"ok": true,
"id": local_directory_group_id(&new_relative_path),
"relativePath": new_relative_path,
"action": "move",
"sourceKind": "local_folder",
}))
}
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
if let Some(file) = resolve_local_raw_file_id(root, entry_id)? {
return trash_local_raw_file(root, entry_id, &file);
}
trash_local_markdown_page(root, entry_id)
}
fn restore_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
let metadata = load_local_folder_metadata(root)?;
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
return restore_local_raw_file(root, entry_id);
}
restore_local_markdown_page(root, entry_id)
}
fn purge_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
let metadata = load_local_folder_metadata(root)?;
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
return purge_local_raw_file(root, entry_id);
}
if entry_id.starts_with("local:asset:") || entry_id.starts_with("local:node:") {
return Err(WebError::bad_request_code(
"local_trash_entry_not_found",
"本地资源必须先进入回收站后才能永久删除",
));
}
purge_local_markdown_page(root, entry_id)
}
2026-05-08 00:41:03 +08:00
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 file_name = markdown_file
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("page.md");
let target = next_available_path(&trash_dir, &file_stem_title(file_name), "md");
fs::rename(&markdown_file.path, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!(
"无法移动到本地回收站 {}: {error}",
markdown_file.path.display()
),
)
})?;
let trash_relative_path = normalize_relative_path(root, &target)?;
metadata.page_ids.remove(&markdown_file.relative_path);
metadata.trash_entries.insert(
document_id.to_string(),
LocalTrashEntry {
document_id: document_id.to_string(),
resource_kind: "markdown".to_string(),
resource_scope: "local_folder".to_string(),
original_file_path: markdown_file.relative_path.clone(),
trashed_file_path: trash_relative_path.clone(),
trash_entry_id: document_id.to_string(),
2026-05-08 00:41:03 +08:00
original_relative_path: markdown_file.relative_path,
trash_relative_path: trash_relative_path.clone(),
deleted_at_ms: now_ms(),
archived_at: now_ms(),
purged_at: None,
2026-05-08 00:41:03 +08:00
},
);
write_page_ids_metadata(root, &metadata.page_ids)?;
write_trash_index_metadata(root, &metadata.trash_entries)?;
Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"trashPath": trash_relative_path,
"action": "delete",
"sourceKind": "local_folder",
}))
}
fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Value, WebError> {
let mut metadata = load_local_folder_metadata(root)?;
let relative_path = normalize_relative_path(root, file)?;
if is_markdown_file(&relative_path) {
return Err(WebError::bad_request_code(
"local_file_resource_not_supported",
"Markdown 文件必须走页面生命周期,不能走 local_file 资源回收站",
));
}
let trash_dir = root.join(".mnote").join("trash");
fs::create_dir_all(&trash_dir).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法创建本地回收站 {}: {error}", trash_dir.display()),
)
})?;
let file_name = file
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("local-file");
let target = next_available_raw_path(&trash_dir, file_name);
fs::rename(file, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法移动本地资源到回收站 {}: {error}", file.display()),
)
})?;
let trash_relative_path = normalize_relative_path(root, &target)?;
let trash_entry_id = local_file_trash_entry_id(&relative_path);
let now = now_ms();
metadata.trash_entries.insert(
trash_entry_id.clone(),
LocalTrashEntry {
document_id: entry_id.to_string(),
resource_kind: "local_file".to_string(),
resource_scope: "local_folder".to_string(),
original_file_path: relative_path.clone(),
trashed_file_path: trash_relative_path.clone(),
trash_entry_id: trash_entry_id.clone(),
original_relative_path: relative_path.clone(),
trash_relative_path: trash_relative_path.clone(),
deleted_at_ms: now,
archived_at: now,
purged_at: None,
},
);
write_trash_index_metadata(root, &metadata.trash_entries)?;
Ok(json!({
"ok": true,
"id": entry_id,
"documentId": entry_id,
"resourceKind": "local_file",
"resourceScope": "local_folder",
"originalFilePath": relative_path,
"trashEntryId": trash_entry_id,
"trashPath": trash_relative_path,
"action": "delete",
"canonicalCommand": "tree.resource.archive",
"sourceKind": "local_folder",
}))
}
2026-05-08 00:41:03 +08:00
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_path.is_file() {
return Err(WebError::bad_request_code(
"local_trash_entry_not_found",
"本地回收站文件不存在,无法恢复",
));
}
let original_path = resolve_metadata_relative_path(root, &trash_entry.original_relative_path)?;
let parent = original_path.parent().ok_or_else(|| {
WebError::bad_request_code("local_tree_command_failed", "无法解析恢复目标目录")
})?;
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法创建恢复目标目录 {}: {error}", parent.display()),
)
})?;
let stem = original_path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| sanitize_file_stem(stem, "页面"))
.unwrap_or_else(|| "页面".to_string());
let target = if original_path.exists() {
next_available_path(parent, &stem, "md")
} else {
original_path
};
fs::rename(&trash_path, &target).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!("无法从本地回收站恢复 {}: {error}", trash_path.display()),
)
})?;
let restored_relative_path = normalize_relative_path(root, &target)?;
metadata
.page_ids
.insert(restored_relative_path.clone(), document_id.to_string());
metadata.trash_entries.remove(document_id);
write_page_ids_metadata(root, &metadata.page_ids)?;
write_trash_index_metadata(root, &metadata.trash_entries)?;
Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"relativePath": restored_relative_path,
"action": "restore",
"sourceKind": "local_folder",
}))
}
fn 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",
}))
}
2026-05-08 00:41:03 +08:00
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)? {
fs::remove_file(&markdown_file.path).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!(
"无法永久删除本地 Markdown 文件 {}: {error}",
markdown_file.path.display()
),
)
})?;
metadata.page_ids.remove(&markdown_file.relative_path);
write_page_ids_metadata(root, &metadata.page_ids)?;
return Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"action": "purge",
"sourceKind": "local_folder",
}));
}
let trash_entry = metadata.trash_entries.remove(document_id).ok_or_else(|| {
WebError::bad_request_code(
"local_markdown_not_found",
"找不到要永久删除的本地 Markdown 页面",
)
})?;
let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
if trash_path.exists() {
fs::remove_file(&trash_path).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!(
"无法永久删除本地回收站文件 {}: {error}",
trash_path.display()
),
)
})?;
}
write_trash_index_metadata(root, &metadata.trash_entries)?;
Ok(json!({
"ok": true,
"id": document_id,
"documentId": document_id,
"action": "purge",
"sourceKind": "local_folder",
}))
}
fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
let mut metadata = load_local_folder_metadata(root)?;
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
WebError::bad_request_code(
"local_trash_entry_not_found",
"找不到要永久删除的本地资源回收站记录",
)
})?;
let trash_entry = metadata.trash_entries.remove(&trash_key).ok_or_else(|| {
WebError::bad_request_code(
"local_trash_entry_not_found",
"找不到要永久删除的本地资源回收站记录",
)
})?;
let trash_relative_path = local_trash_file_path(&trash_entry);
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
if trash_path.exists() {
fs::remove_file(&trash_path).map_err(|error| {
WebError::bad_request_code(
"local_tree_command_failed",
format!(
"无法永久删除本地资源回收站文件 {}: {error}",
trash_path.display()
),
)
})?;
}
write_trash_index_metadata(root, &metadata.trash_entries)?;
Ok(json!({
"ok": true,
"id": entry_id,
"documentId": entry_id,
"resourceKind": "local_file",
"resourceScope": "local_folder",
"trashEntryId": trash_key,
"action": "purge",
"canonicalCommand": "tree.resource.purge",
"sourceKind": "local_folder",
}))
}
2026-05-08 00:41:03 +08:00
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:node:")
.or_else(|| trimmed.strip_prefix("local:asset:"))
else {
return Ok(None);
};
let file = resolve_metadata_relative_path(root, relative_path)?;
if file.is_file() {
Ok(Some(file))
} else {
Err(WebError::bad_request_code(
"local_tree_command_failed",
"本地文件不存在",
))
}
}
fn local_file_trash_entry_id(relative_path: &str) -> String {
format!("local-file:{relative_path}")
}
fn is_local_file_trash_entry(entry: &LocalTrashEntry) -> bool {
entry.resource_kind == "local_file" || entry.trash_entry_id.starts_with("local-file:")
}
fn local_trash_original_path(entry: &LocalTrashEntry) -> String {
if !entry.original_file_path.trim().is_empty() {
entry.original_file_path.clone()
} else {
entry.original_relative_path.clone()
}
}
fn local_trash_file_path(entry: &LocalTrashEntry) -> String {
if !entry.trashed_file_path.trim().is_empty() {
entry.trashed_file_path.clone()
} else {
entry.trash_relative_path.clone()
}
}
fn find_local_file_trash_entry_key(
metadata: &LocalFolderMetadata,
entry_id: &str,
) -> Option<String> {
let trimmed = entry_id.trim();
if metadata
.trash_entries
.get(trimmed)
.map(is_local_file_trash_entry)
.unwrap_or(false)
{
return Some(trimmed.to_string());
}
let relative_path = trimmed
.strip_prefix("local:asset:")
.or_else(|| trimmed.strip_prefix("local:node:"))
.unwrap_or(trimmed);
let expected_key = local_file_trash_entry_id(relative_path);
if metadata
.trash_entries
.get(&expected_key)
.map(is_local_file_trash_entry)
.unwrap_or(false)
{
return Some(expected_key);
}
metadata
.trash_entries
.iter()
.find(|(_, entry)| {
is_local_file_trash_entry(entry) && local_trash_original_path(entry) == relative_path
})
.map(|(key, _)| key.clone())
}
2026-05-08 00:41:03 +08:00
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))
}
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_ids: load_metadata_string_map(&root.join(".mnote").join("page-ids.json"))?,
page_options: load_metadata_value_map(&root.join(".mnote").join("page-options.json"))?,
trash_entries: load_trash_index_map(&root.join(".mnote").join("trash-index.json"))?,
})
}
fn load_metadata_string_map(path: &Path) -> Result<BTreeMap<String, String>, WebError> {
if !path.exists() {
return Ok(BTreeMap::new());
}
let value = read_metadata_json(path)?;
let source = value
.get("pages")
.or_else(|| value.get("pageIds"))
.unwrap_or(&value);
let Some(map) = source.as_object() else {
return Err(metadata_invalid(path, "元数据必须是对象"));
};
let mut result = BTreeMap::new();
for (key, value) in map {
let Some(page_id) = value
.as_str()
.map(str::trim)
.filter(|item| !item.is_empty())
else {
return Err(metadata_invalid(path, "page id 必须是非空字符串"));
};
result.insert(key.clone(), page_id.to_string());
}
Ok(result)
}
fn load_metadata_value_map(path: &Path) -> Result<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 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",
));
}
let mut metadata = load_local_folder_metadata(&canonical_root)?;
if let Some(existing) = metadata.page_ids.get(relative_path) {
return Ok(normalize_metadata_page_id(existing));
}
let page_id = format!(
"local-mdid:{}",
encode_local_id_segment(&format!(
"{}:{relative_path}",
local_workspace_id(&canonical_root)
))
);
metadata
.page_ids
.insert(relative_path.to_string(), page_id.clone());
write_page_ids_metadata(&canonical_root, &metadata.page_ids)?;
Ok(page_id)
}
#[allow(dead_code)]
fn write_page_ids_metadata(
root: &Path,
page_ids: &BTreeMap<String, 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("page-ids.json");
let value = json!({
"version": 1,
"pages": page_ids,
});
write_json_atomic(&path, &value)
}
fn write_trash_index_metadata(
root: &Path,
entries: &BTreeMap<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_page_options_metadata(
root: &Path,
page_options: &BTreeMap<String, Value>,
) -> Result<(), WebError> {
let mnote_dir = root.join(".mnote");
fs::create_dir_all(&mnote_dir).map_err(|error| {
WebError::bad_request_code(
"local_metadata_write_failed",
format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()),
)
})?;
let path = mnote_dir.join("page-options.json");
let value = json!({
"version": 1,
"pages": page_options,
});
write_json_atomic(&path, &value)
}
#[allow(dead_code)]
fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> {
let tmp_path = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(value)
.map_err(|error| WebError::internal(format!("本地元数据序列化失败: {error}")))?;
fs::write(&tmp_path, bytes).map_err(|error| {
WebError::bad_request_code(
"local_metadata_write_failed",
format!("无法写入临时元数据 {}: {error}", tmp_path.display()),
)
})?;
fs::rename(&tmp_path, path).map_err(|error| {
let _ = fs::remove_file(&tmp_path);
WebError::bad_request_code(
"local_metadata_write_failed",
format!("无法替换本地元数据 {}: {error}", path.display()),
)
})
}
fn scan_directory(
root: &Path,
directory: &Path,
parent_node_id: Option<String>,
depth: u32,
root_source_uri: &str,
metadata: &LocalFolderMetadata,
rows: &mut Vec<LocalFolderRow>,
) -> Result<(), WebError> {
let entries = read_sorted_entries(directory, root)?;
let entry_count = entries.len();
for (position, entry) in entries.into_iter().enumerate() {
let node_id = local_node_id(if entry.relative_path.is_empty() {
"."
} else {
&entry.relative_path
});
let child_count = if entry.is_dir && !entry.is_symlink {
count_visible_children(&entry.path, root)?
} else {
0
};
let row_kind = if entry.is_dir {
"folder".to_string()
} else if is_markdown_file(&entry.file_name) {
"markdown".to_string()
} else {
"asset".to_string()
};
let icon_hint = icon_hint_for_entry(&entry);
rows.push(LocalFolderRow {
node_id: node_id.clone(),
row_id: format!("local:{row_kind}:{}", entry.relative_path),
parent_node_id: parent_node_id.clone(),
title: entry.file_name.clone(),
depth,
position: position as u32,
row_kind,
icon_hint,
relative_path: entry.relative_path.clone(),
source_uri: file_uri_for_path(&entry.path),
child_count,
expandable: entry.is_dir && child_count > 0,
expanded_by_default: depth < 1 && entry.is_dir && entry_count <= 80,
document_id: if is_markdown_file(&entry.file_name) {
Some(local_markdown_page_id(&entry.relative_path, None, metadata))
} else {
None
},
capabilities: local_entry_capabilities(&entry),
});
if entry.is_dir && !entry.is_symlink {
scan_directory(
root,
&entry.path,
Some(node_id),
depth + 1,
root_source_uri,
metadata,
rows,
)?;
}
}
if rows.is_empty() && directory == root {
rows.push(LocalFolderRow {
node_id: local_node_id("."),
row_id: "local:folder:.".to_string(),
parent_node_id: None,
title: root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("local folder")
.to_string(),
depth: 0,
position: 0,
row_kind: "folder".to_string(),
icon_hint: "folder".to_string(),
relative_path: ".".to_string(),
source_uri: root_source_uri.to_string(),
child_count: 0,
expandable: false,
expanded_by_default: false,
document_id: None,
capabilities: local_path_capabilities(root),
});
}
Ok(())
}
fn read_sorted_entries(directory: &Path, root: &Path) -> Result<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 count_visible_children(directory: &Path, root: &Path) -> Result<u32, WebError> {
Ok(read_sorted_entries(directory, root)?.len() as u32)
}
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 local_path_capabilities(path: &Path) -> Vec<String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.permissions().readonly() => vec!["readonly".to_string()],
_ => Vec::new(),
}
}
fn normalize_relative_path(root: &Path, path: &Path) -> Result<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 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 scan_markdown_page_tree(
root: &Path,
directory: &Path,
parent_node_id: Option<String>,
depth: u32,
metadata: &LocalFolderMetadata,
rows: &mut Vec<LocalFolderRow>,
) -> Result<bool, WebError> {
let entries = read_sorted_entries(directory, root)?;
let mut directory_rows = Vec::<LocalFolderRow>::new();
let mut contains_markdown = false;
for (position, entry) in entries.into_iter().enumerate() {
if entry.is_dir && !entry.is_symlink {
let node_id = local_directory_group_id(&entry.relative_path);
let mut child_rows = Vec::new();
let child_contains_markdown = scan_markdown_page_tree(
root,
&entry.path,
Some(node_id.clone()),
depth + 1,
metadata,
&mut child_rows,
)?;
if child_contains_markdown {
directory_rows.push(LocalFolderRow {
node_id,
row_id: format!("local:page-group:{}", entry.relative_path),
parent_node_id: parent_node_id.clone(),
title: entry.file_name.clone(),
depth,
position: position as u32,
row_kind: "document".to_string(),
icon_hint: "folder".to_string(),
relative_path: entry.relative_path.clone(),
source_uri: file_uri_for_path(&entry.path),
child_count: 0,
expandable: true,
expanded_by_default: depth < 2,
document_id: None,
capabilities: local_entry_capabilities(&entry),
});
directory_rows.extend(child_rows);
contains_markdown = true;
}
continue;
}
if is_markdown_file(&entry.file_name) {
let markdown = fs::read_to_string(&entry.path).unwrap_or_default();
let parsed = parse_markdown_page(&markdown, &entry.file_name);
let page_id =
local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata);
directory_rows.push(LocalFolderRow {
node_id: page_id.clone(),
row_id: format!("local:page:{}", entry.relative_path),
parent_node_id: parent_node_id.clone(),
title: parsed.title,
depth,
position: position as u32,
row_kind: "document".to_string(),
icon_hint: "markdown".to_string(),
relative_path: entry.relative_path.clone(),
source_uri: file_uri_for_path(&entry.path),
child_count: 0,
expandable: false,
expanded_by_default: false,
document_id: Some(page_id),
capabilities: local_entry_capabilities(&entry),
});
contains_markdown = true;
}
}
rows.extend(directory_rows);
Ok(contains_markdown)
}
fn find_markdown_by_page_id(
root: &Path,
metadata: &LocalFolderMetadata,
document_id: &str,
) -> Result<Option<LocalFolderEntry>, WebError> {
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 markdown = fs::read_to_string(&entry.path).unwrap_or_default();
let parsed = parse_markdown_page(&markdown, &entry.file_name);
let page_id =
local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata);
if page_id == document_id {
return Ok(Some(entry));
}
}
Ok(None)
}
walk(root, root, metadata, document_id)
}
fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
let mut item = json!({
"rowId": row.row_id,
"rowKind": row.row_kind,
"nodeId": row.node_id,
"parentNodeId": row.parent_node_id,
"title": row.title,
"depth": row.depth,
"position": row.position,
"childCount": row.child_count,
"expandable": row.expandable,
"expandedByDefault": row.expanded_by_default,
"capabilities": row.capabilities.clone(),
"iconHint": row.icon_hint,
"resourceMeta": {
"resourceKind": row.row_kind,
"iconHint": row.icon_hint,
"extra": {
"source": {
"sourceKind": "local_folder",
"sourceUri": row.source_uri,
"relativePath": row.relative_path,
"storageIdentity": {
"kind": "file_path",
"path": row.relative_path,
},
"operationProfile": "local_readonly",
}
}
}
});
if let Some(document_id) = row.document_id.as_ref() {
item["resourceMeta"]["documentId"] = Value::String(document_id.clone());
}
item
}
fn local_node_id(relative_path: &str) -> String {
format!("local:node:{relative_path}")
}
fn local_directory_group_id(relative_path: &str) -> String {
format!("local-dir:{}", encode_local_id_segment(relative_path))
}
fn local_markdown_page_id(
relative_path: &str,
mnote_id: Option<&str>,
metadata: &LocalFolderMetadata,
) -> String {
if let Some(mnote_id) = mnote_id.map(str::trim).filter(|value| !value.is_empty()) {
if mnote_id.starts_with("local-mdid:") || mnote_id.starts_with("local-md:") {
return mnote_id.to_string();
}
return format!("local-mdid:{}", encode_local_id_segment(mnote_id));
}
if let Some(page_id) = metadata
.page_ids
.get(relative_path)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return normalize_metadata_page_id(page_id);
}
format!("local-md:{}", encode_local_id_segment(relative_path))
}
fn normalize_metadata_page_id(page_id: &str) -> String {
let trimmed = page_id.trim();
if trimmed.starts_with("local-md:") || trimmed.starts_with("local-mdid:") {
trimmed.to_string()
} else {
format!("local-mdid:{}", encode_local_id_segment(trimmed))
}
}
fn page_options_from_metadata(value: &Value) -> PageOptions {
let mut options = PageOptions::default();
if let Some(wide_layout) = value
.get("wideLayout")
.or_else(|| value.get("wide_layout"))
.and_then(Value::as_bool)
{
options.wide_layout = wide_layout;
}
if let Some(small_text) = value
.get("smallText")
.or_else(|| value.get("small_text"))
.and_then(Value::as_bool)
{
options.small_text = small_text;
}
if let Some(layout_density) = value
.get("layoutDensity")
.or_else(|| value.get("layout_density"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
{
options.layout_density = layout_density.to_string();
}
if let Some(show_toc) = value
.get("showToc")
.or_else(|| value.get("show_toc"))
.and_then(Value::as_bool)
{
options.show_toc = show_toc;
}
if let Some(show_structure) = value
.get("showStructure")
.or_else(|| value.get("show_structure"))
.and_then(Value::as_bool)
{
options.show_structure = show_structure;
}
if let Some(show_heading_numbers) = value
.get("showHeadingNumbers")
.or_else(|| value.get("show_heading_numbers"))
.and_then(Value::as_bool)
{
options.show_heading_numbers = show_heading_numbers;
}
if let Some(protect_editing) = value
.get("protectEditing")
.or_else(|| value.get("protect_editing"))
.and_then(Value::as_bool)
{
options.protect_editing = protect_editing;
}
options
}
fn local_workspace_id(root: &Path) -> String {
format!(
"local:{}",
root.to_string_lossy()
.chars()
.map(|character| match character {
'/' | '\\' | ':' | ' ' => '_',
value if value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | '.') => value,
_ => '_',
})
.collect::<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 lower.ends_with(".md") || lower.ends_with(".markdown") {
"markdown"
} else if matches!(
extension(&lower).as_deref(),
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp")
) {
"image"
} else if matches!(
extension(&lower).as_deref(),
Some("mp4" | "webm" | "mov" | "mkv")
) {
"video"
} else if matches!(
extension(&lower).as_deref(),
Some("mp3" | "wav" | "flac" | "ogg")
) {
"audio"
} else if lower.ends_with(".pdf") {
"pdf"
} else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) {
"book"
} else if matches!(
extension(&lower).as_deref(),
Some("csv" | "tsv" | "xlsx" | "xls")
) {
"table"
} else {
"unknown"
}
.to_string()
}
fn extension(file_name: &str) -> Option<String> {
Path::new(file_name)
.extension()
.and_then(|extension| extension.to_str())
.map(ToOwned::to_owned)
}
fn encode_local_id_segment(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
let character = *byte as char;
if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
encoded.push(character);
} else {
encoded.push('~');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
2026-05-08 11:23:08 +08:00
pub(crate) fn decode_local_id_segment(value: &str) -> Result<String, WebError> {
2026-05-08 00:41:03 +08:00
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'~' {
if index + 2 >= bytes.len() {
return Err(WebError::bad_request_code(
"local_id_invalid",
"本地 page id 编码不完整",
));
}
let hex = &value[index + 1..index + 3];
let byte = u8::from_str_radix(hex, 16).map_err(|_| {
WebError::bad_request_code("local_id_invalid", "本地 page id 编码非法")
})?;
decoded.push(byte);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded)
.map_err(|_| WebError::bad_request_code("local_id_invalid", "本地 page id 不是 UTF-8"))
}
fn rewrite_page_ids_prefix(
page_ids: &mut BTreeMap<String, String>,
old_relative_path: &str,
new_relative_path: &str,
) {
let old_prefix = format!("{old_relative_path}/");
let replacements = page_ids
.iter()
.filter_map(|(relative_path, page_id)| {
if relative_path == old_relative_path {
Some((
relative_path.clone(),
new_relative_path.to_string(),
page_id.clone(),
))
} else if let Some(rest) = relative_path.strip_prefix(&old_prefix) {
Some((
relative_path.clone(),
format!("{new_relative_path}/{rest}"),
page_id.clone(),
))
} else {
None
}
})
.collect::<Vec<_>>();
for (old_key, new_key, page_id) in replacements {
page_ids.remove(&old_key);
page_ids.insert(new_key, page_id);
}
}
fn sanitize_file_stem(value: &str, fallback: &str) -> String {
let sanitized = value
.trim()
.chars()
.map(|character| {
if matches!(
character,
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'
) {
'_'
} else {
character
}
})
.collect::<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 next_available_path(directory: &Path, stem: &str, extension: &str) -> PathBuf {
let first = directory.join(format!("{stem}.{extension}"));
if !first.exists() {
return first;
}
for index in 2..10_000 {
let candidate = directory.join(format!("{stem} {index}.{extension}"));
if !candidate.exists() {
return candidate;
}
}
directory.join(format!("{stem}-{}.{}", std::process::id(), extension))
}
fn next_available_raw_path(directory: &Path, file_name: &str) -> PathBuf {
let first = directory.join(file_name);
if !first.exists() {
return first;
}
let path = Path::new(file_name);
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(file_name);
let extension = path.extension().and_then(|value| value.to_str());
for index in 2..10_000 {
let candidate_name = if let Some(extension) = extension {
format!("{stem} {index}.{extension}")
} else {
format!("{stem} {index}")
};
let candidate = directory.join(candidate_name);
if !candidate.exists() {
return candidate;
}
}
directory.join(format!("{stem}-{}", std::process::id()))
}
fn next_available_directory_path(directory: &Path, stem: &str) -> PathBuf {
let first = directory.join(stem);
if !first.exists() {
return first;
}
for index in 2..10_000 {
let candidate = directory.join(format!("{stem} {index}"));
if !candidate.exists() {
return candidate;
}
}
directory.join(format!("{stem}-{}", std::process::id()))
}
fn markdown_with_frontmatter_fields(markdown: &str, fields: &[(&str, &str)]) -> String {
let (frontmatter, body) = split_frontmatter(markdown);
let mut lines = frontmatter
.unwrap_or_default()
.lines()
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
for (key, value) in fields {
let replacement = format!("{key}: {}", value.trim());
if let Some(existing) = lines.iter_mut().find(|line| {
line.split_once(':')
.map(|(candidate, _)| candidate.trim() == *key)
.unwrap_or(false)
}) {
*existing = replacement;
} else {
lines.push(replacement);
}
}
format!("---\n{}\n---\n{}", lines.join("\n"), body)
}
fn editor_blocks_to_markdown(content: &Value) -> String {
let blocks = if let Some(array) = content.as_array() {
array.clone()
} else {
content
.get("blocks")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
};
let mut lines = Vec::new();
for block in blocks {
let block_type = block
.get("type")
.or_else(|| block.get("blockType"))
.and_then(Value::as_str)
.unwrap_or("paragraph");
let inline_markdown = block_content_value(&block)
.map(inline_nodes_to_markdown)
.unwrap_or_default()
.trim()
.to_string();
let text = if inline_markdown.is_empty() {
extract_block_text(&block).trim().to_string()
} else {
inline_markdown
};
if text.is_empty() && !matches!(block_type, "divider" | "media" | "table") {
lines.push(String::new());
continue;
}
match block_type {
"media" => {
let props = block.get("props").and_then(Value::as_object);
let url = props
.and_then(|props| {
props
.get("sourcePath")
.or_else(|| props.get("url"))
.or_else(|| props.get("src"))
})
.and_then(Value::as_str)
.unwrap_or("")
.trim();
let name = props
.and_then(|props| props.get("name").or_else(|| props.get("fileName")))
.and_then(Value::as_str)
.unwrap_or(text.as_str())
.trim();
if url.is_empty() {
lines.push(text);
} else {
lines.push(format!(
"[{}]({url})",
if name.is_empty() { url } else { name }
));
}
}
"heading" => {
let level = block
.get("props")
.and_then(|props| props.get("level"))
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 6);
lines.push(format!("{} {text}", "#".repeat(level as usize)));
}
"bulletListItem" | "bullet_list_item" | "list_item" => {
lines.push(format!("- {text}"));
}
"numberedListItem" | "numbered_list_item" => {
lines.push(format!("1. {text}"));
}
"todo" | "checkListItem" | "advancedTodo" => {
let checked = block
.get("props")
.and_then(|props| props.get("checked"))
.and_then(Value::as_bool)
.unwrap_or(false);
let checkbox = if checked { "[x]" } else { "[ ]" };
lines.push(format!("- {checkbox} {text}"));
}
"quote" | "blockquote" => {
lines.push(format!("> {text}"));
}
"codeBlock" | "code_block" | "code" => {
lines.push(format!("```\n{text}\n```"));
}
"divider" => {
lines.push("---".to_string());
}
"table" => {
let table_markdown = editor_block_table_to_markdown(&block);
if table_markdown.is_empty() {
lines.push(text);
} else {
lines.extend(table_markdown.lines().map(ToOwned::to_owned));
}
}
_ => lines.push(text),
}
lines.push(String::new());
}
let markdown = lines.join("\n").trim_end().to_string();
if markdown.is_empty() {
"\n".to_string()
} else {
format!("{markdown}\n")
}
}
fn editor_block_table_to_markdown(block: &Value) -> String {
let table = block
.get("props")
.and_then(|props| props.get("tiptapTable"))
.or_else(|| block.get("tiptapTable"));
let rows = table
.and_then(|value| value.get("content"))
.and_then(Value::as_array);
let Some(rows) = rows else {
return String::new();
};
let mut parsed_rows = Vec::<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(inline_nodes_to_markdown)
.unwrap_or_default()
.replace('\n', " ")
.replace('|', r"\|")
.trim()
.to_string();
2026-05-08 00:41:03 +08:00
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);
2026-05-08 00:41:03 +08:00
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));
2026-05-08 00:41:03 +08:00
}
}
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("")
}
2026-05-08 00:41:03 +08:00
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"))
}
fn inline_nodes_to_markdown(value: &Value) -> String {
if let Some(text) = value.as_str() {
return escape_markdown_inline_text(text);
}
if let Some(array) = value.as_array() {
return array
.iter()
.map(inline_node_to_markdown)
.collect::<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));
2026-05-08 00:41:03 +08:00
}
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
return inline_nodes_to_markdown(content);
}
}
String::new()
}
fn inline_node_to_markdown(node: &Value) -> String {
if let Some(text) = node.as_str() {
return escape_markdown_inline_text(text);
}
if let Some(object) = node.as_object() {
let text = object
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if !text.is_empty() {
return markdown_text_with_styles(text, &inline_styles_from_object(object));
2026-05-08 00:41:03 +08:00
}
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
return inline_nodes_to_markdown(content);
}
}
String::new()
}
fn inline_styles_from_object(object: &Map<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)
}
2026-05-08 00:41:03 +08:00
fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
let mut value = escape_markdown_inline_text(text);
let link = styles
.get("link")
.or_else(|| styles.get("href"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|href| !href.is_empty())
.map(ToOwned::to_owned);
if styles
.get("code")
.or_else(|| styles.get("inlineCode"))
.and_then(Value::as_bool)
.unwrap_or(false)
{
value = format!("`{}`", text.replace('`', r"\`"));
}
if styles.get("bold").and_then(Value::as_bool).unwrap_or(false) {
value = format!("**{value}**");
}
if styles
.get("italic")
.and_then(Value::as_bool)
.unwrap_or(false)
{
value = format!("*{value}*");
}
if styles
.get("strike")
.or_else(|| styles.get("strikethrough"))
.and_then(Value::as_bool)
.unwrap_or(false)
{
value = format!("~~{value}~~");
}
if let Some(href) = link {
value = format!("[{}]({href})", value.replace(']', r"\]"));
}
value
}
fn escape_markdown_inline_text(text: &str) -> String {
text.replace('\\', r"\\")
}
fn now_ms() -> u128 {
system_time_ms(SystemTime::now())
}
fn system_time_ms(time: SystemTime) -> u128 {
time.duration_since(UNIX_EPOCH)
2026-05-08 00:41:03 +08:00
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
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()
2026-05-08 00:41:03 +08:00
))
}
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::{
initialize_local_page_id, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, resolve_local_markdown_page_aggregate,
2026-05-08 00:41:03 +08:00
save_local_markdown_page,
};
use serde_json::Value;
fn temp_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp root");
root
}
#[test]
fn local_frontmatter_mnote_id_survives_rename_and_move() {
let root = temp_root("mnote-local-frontmatter-id");
std::fs::write(
root.join("page.md"),
"---\nmnote_id: stable-frontmatter-id\ntitle: Stable\n---\n正文\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot");
assert!(first
.projection
.to_string()
.contains("local-mdid:stable-frontmatter-id"));
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md"))
.expect("move md");
let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot");
let second_json = second.projection.to_string();
assert!(second_json.contains("local-mdid:stable-frontmatter-id"));
assert!(second_json.contains("renamed.md"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_page_ids_metadata_overrides_path_derived_id_and_hides_mnote() {
let root = temp_root("mnote-local-page-ids");
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("page.md"), "# Metadata Page\n").expect("write md");
std::fs::write(
root.join(".mnote").join("page-ids.json"),
r#"{"version":1,"pages":{"docs/page.md":"stable-from-page-ids"}}"#,
)
.expect("write page ids");
let root_uri = format!("file://{}", root.display());
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("snapshot");
let html_json = snapshot.projection.to_string();
assert!(html_json.contains("local-mdid:stable-from-page-ids"));
assert!(!html_json.contains("page-ids.json"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_page_id_initialization_writes_page_ids_atomically() {
let root = temp_root("mnote-local-page-id-init");
std::fs::write(root.join("draft.md"), "# Draft\n").expect("write md");
let root_uri = format!("file://{}", root.display());
let page_id = initialize_local_page_id(&root_uri, "draft.md").expect("init page id");
assert!(page_id.starts_with("local-mdid:"));
let metadata = std::fs::read_to_string(root.join(".mnote").join("page-ids.json"))
.expect("read page ids");
let parsed: Value = serde_json::from_str(&metadata).expect("json");
assert_eq!(parsed["pages"]["draft.md"], page_id);
assert!(!root.join(".mnote").join("page-ids.json.tmp").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_metadata_damage_returns_explainable_error() {
let root = temp_root("mnote-local-bad-metadata");
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata");
std::fs::write(root.join("README.md"), "# Broken Metadata\n").expect("write md");
std::fs::write(root.join(".mnote").join("page-ids.json"), "{not-json")
.expect("write bad metadata");
let root_uri = format!("file://{}", root.display());
let error = load_local_folder_page_tree_snapshot(&root_uri).expect_err("metadata error");
assert!(error.message().contains("元数据 JSON 损坏"));
assert!(error.message().contains("请修复或移走"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_page_options_metadata_flows_into_page_aggregate() {
let root = temp_root("mnote-local-page-options");
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata");
std::fs::write(root.join("README.md"), "# Local Options\n").expect("write md");
std::fs::write(
root.join(".mnote").join("page-options.json"),
r#"{"version":1,"pages":{"local-md:README.md":{"wideLayout":true,"showToc":true,"showHeadingNumbers":true}}}"#,
)
.expect("write options");
let root_uri = format!("file://{}", root.display());
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md")
.expect("aggregate");
assert!(aggregate.layout.page_options.wide_layout);
assert!(aggregate.layout.page_options.show_toc);
assert!(aggregate.layout.page_options.show_heading_numbers);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_parser_preserves_pipe_table_as_table_block() {
let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
"| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n",
);
2026-05-08 00:41:03 +08:00
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(
2026-05-08 00:41:03 +08:00
"- [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);
}
2026-05-08 00:41:03 +08:00
#[test]
fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() {
let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
2026-05-08 00:41:03 +08:00
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");
}
2026-05-08 00:41:03 +08:00
#[test]
fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() {
let root = temp_root("mnote-local-markdown-save-basic-blocks");
std::fs::write(
root.join("README.md"),
"---\ntitle: Preserved\nmnote_id: stable\n---\n# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
save_local_markdown_page(
&root_uri,
"local-mdid:stable",
None,
2026-05-08 00:41:03 +08:00
&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,
2026-05-08 00:41:03 +08:00
&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));
2026-05-11 13:16:34 +08:00
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");
2026-05-11 13:16:34 +08:00
assert!(error.message().contains("本地 Markdown 文件已被外部修改"));
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(saved.contains("# External"));
assert!(!saved.contains("# Editor"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_writes_table_inline_marks() {
let root = temp_root("mnote-local-markdown-save-table-inline-marks");
std::fs::write(root.join("README.md"), "---\ntitle: Table\n---\n# Old\n")
.expect("write md");
let root_uri = format!("file://{}", root.display());
save_local_markdown_page(
&root_uri,
"local-md:README.md",
None,
&serde_json::json!([
{"type":"table","props":{"tiptapTable":{"type":"table","content":[
{"type":"tableRow","content":[
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]},
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]}
]},
{"type":"tableRow","content":[
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{"code":true}}]}]},
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{"bold":true}}]}]}
]}
]}}}
]),
)
.expect("save");
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(saved.contains("| `A` | **B** |"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_round_trips_tiptap_table_marks() {
let root = temp_root("mnote-local-markdown-save-table-marks-roundtrip");
std::fs::write(
root.join("README.md"),
"---\ntitle: Table Marks\n---\n# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
let parsed = crate::routes::local_markdown_parser::markdown_to_blocks(
"| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n",
);
save_local_markdown_page(&root_uri, "local-md:README.md", None, &parsed).expect("save");
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(saved.contains("| `A` | **B** |"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_writes_table_alignment_markers() {
let root = temp_root("mnote-local-markdown-save-table-alignments");
std::fs::write(
root.join("README.md"),
"---\ntitle: Table Align\n---\n# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
save_local_markdown_page(
&root_uri,
"local-md:README.md",
None,
&serde_json::json!([
{"type":"table","props":{"tiptapTable":{"type":"table","content":[
{"type":"tableRow","content":[
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]},
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"中","styles":{}}]}]},
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]}
]},
{"type":"tableRow","content":[
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]},
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]},
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]}
]}
]}}}
]),
)
.expect("save");
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(saved.contains("| :--- | :---: | ---: |"));
let _ = std::fs::remove_dir_all(&root);
}
2026-05-08 00:41:03 +08:00
}