2026-05-19 08:07:17 +08:00
|
|
|
use crate::context::RequestContext;
|
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,
|
|
|
|
|
};
|
2026-05-08 10:57:24 +08:00
|
|
|
use crate::routes::local_markdown_parser::{
|
|
|
|
|
file_stem_title, parse_markdown_page, split_frontmatter,
|
|
|
|
|
};
|
2026-05-19 09:38:57 +08:00
|
|
|
use crate::routes::local_search_index;
|
2026-05-08 00:41:03 +08:00
|
|
|
use crate::routes::snapshot_support::ProjectionSnapshot;
|
2026-05-19 08:07:17 +08:00
|
|
|
use axum::extract::{Extension, Multipart, Path as AxumPath, Query};
|
|
|
|
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
|
|
|
|
use axum::Json;
|
2026-05-16 22:03:14 +08:00
|
|
|
use bridge_runtime::project_legacy_content_to_block_document;
|
2026-05-19 11:11:08 +08:00
|
|
|
use core_protocol::{
|
|
|
|
|
KernelObjectIdentity, KernelObjectKind, ObjectWorkspacePath, WorkspaceSourceKind,
|
|
|
|
|
};
|
2026-05-20 10:43:38 +08:00
|
|
|
use reqwest::Url;
|
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};
|
2026-05-20 10:43:38 +08:00
|
|
|
use time::OffsetDateTime;
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct LocalFolderEntry {
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
relative_path: String,
|
|
|
|
|
file_name: String,
|
|
|
|
|
is_dir: bool,
|
|
|
|
|
is_symlink: bool,
|
|
|
|
|
is_readonly: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
struct LocalFolderMetadata {
|
|
|
|
|
page_options: BTreeMap<String, Value>,
|
|
|
|
|
trash_entries: BTreeMap<String, LocalTrashEntry>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct LocalWorkspaceManifest {
|
|
|
|
|
#[serde(default, alias = "workspace_id")]
|
|
|
|
|
workspace_id: String,
|
|
|
|
|
#[serde(default, alias = "owner_id")]
|
|
|
|
|
owner_id: String,
|
|
|
|
|
#[serde(default, alias = "created_at")]
|
|
|
|
|
created_at: String,
|
|
|
|
|
#[serde(default, alias = "capabilities")]
|
|
|
|
|
capabilities: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct LocalAccessPolicy {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
admins: Vec<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
grants: Vec<LocalAccessGrant>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct LocalAccessGrant {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
id: String,
|
|
|
|
|
#[serde(default, alias = "user_id")]
|
|
|
|
|
user_id: String,
|
|
|
|
|
#[serde(default, alias = "root_uri")]
|
|
|
|
|
root_uri: String,
|
|
|
|
|
#[serde(default, alias = "root_path")]
|
|
|
|
|
root_path: String,
|
|
|
|
|
#[serde(default, alias = "access")]
|
|
|
|
|
permission: String,
|
|
|
|
|
#[serde(default = "default_true")]
|
|
|
|
|
recursive: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
capabilities: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct LocalShareGrantsStore {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
grants: Vec<LocalShareGrant>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct LocalShareGrant {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
id: String,
|
|
|
|
|
#[serde(default, alias = "share_id")]
|
|
|
|
|
share_id: String,
|
|
|
|
|
#[serde(default, alias = "owner_user_id")]
|
|
|
|
|
owner_user_id: String,
|
|
|
|
|
#[serde(default, alias = "target_user_id")]
|
|
|
|
|
target_user_id: String,
|
|
|
|
|
#[serde(default, alias = "root_uri")]
|
|
|
|
|
root_uri: String,
|
|
|
|
|
#[serde(default, alias = "root_path")]
|
|
|
|
|
root_path: String,
|
|
|
|
|
#[serde(default, alias = "documentId")]
|
|
|
|
|
document_id: String,
|
|
|
|
|
#[serde(default, alias = "allowedResourceIds")]
|
|
|
|
|
allowed_resource_ids: Vec<String>,
|
|
|
|
|
#[serde(default, alias = "access")]
|
|
|
|
|
permission: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
capabilities: Vec<String>,
|
|
|
|
|
#[serde(default, alias = "createdAt")]
|
|
|
|
|
created_at: String,
|
|
|
|
|
#[serde(default, alias = "revokedAt")]
|
|
|
|
|
revoked_at: Option<String>,
|
|
|
|
|
#[serde(default = "default_true")]
|
|
|
|
|
active: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
enum LocalAccessMode {
|
|
|
|
|
Read,
|
|
|
|
|
Write,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) struct LocalTrashEntry {
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "documentId")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) document_id: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "resourceKind")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) resource_kind: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "resourceScope")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) resource_scope: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "originalFilePath")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) original_file_path: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "trashedFilePath")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) trashed_file_path: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "trashEntryId")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) trash_entry_id: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "originalRelativePath")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) original_relative_path: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "trashRelativePath")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) trash_relative_path: String,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "deletedAtMs")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) deleted_at_ms: u128,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "archivedAt")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) archived_at: u128,
|
2026-05-16 07:38:45 +08:00
|
|
|
#[serde(default, alias = "purgedAt")]
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) 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>,
|
2026-05-19 11:11:08 +08:00
|
|
|
workspace_id: String,
|
|
|
|
|
root_source_uri: String,
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub(crate) struct LocalUploadFile {
|
|
|
|
|
name: String,
|
|
|
|
|
content_type: String,
|
|
|
|
|
bytes: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct LocalAssetUploadFields {
|
|
|
|
|
file: LocalUploadFile,
|
|
|
|
|
root_uri: String,
|
|
|
|
|
document_id: String,
|
|
|
|
|
kind: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct LocalFileOpenQuery {
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
pub path: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub download: Option<bool>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct LocalAccessValidateRootRequest {
|
|
|
|
|
#[serde(default, alias = "root_uri")]
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
#[serde(default, alias = "root_path")]
|
|
|
|
|
pub root_path: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct LocalAccessGrantRequest {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub id: String,
|
|
|
|
|
#[serde(default, alias = "user_id")]
|
|
|
|
|
pub user_id: String,
|
|
|
|
|
#[serde(default, alias = "root_uri")]
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
#[serde(default, alias = "root_path")]
|
|
|
|
|
pub root_path: String,
|
|
|
|
|
#[serde(default, alias = "access")]
|
|
|
|
|
pub permission: String,
|
|
|
|
|
#[serde(default = "default_true")]
|
|
|
|
|
pub recursive: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub capabilities: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct LocalShareGrantRequest {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub id: String,
|
|
|
|
|
#[serde(default, alias = "share_id")]
|
|
|
|
|
pub share_id: String,
|
|
|
|
|
#[serde(default, alias = "owner_user_id")]
|
|
|
|
|
pub owner_user_id: String,
|
|
|
|
|
#[serde(default, alias = "target_user_id")]
|
|
|
|
|
pub target_user_id: String,
|
|
|
|
|
#[serde(default, alias = "root_uri")]
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
#[serde(default, alias = "root_path")]
|
|
|
|
|
pub root_path: String,
|
|
|
|
|
#[serde(default, alias = "documentId")]
|
|
|
|
|
pub document_id: String,
|
|
|
|
|
#[serde(default, alias = "allowedResourceIds")]
|
|
|
|
|
pub allowed_resource_ids: Vec<String>,
|
|
|
|
|
#[serde(default, alias = "access")]
|
|
|
|
|
pub permission: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub capabilities: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct SharedCacheRecordRequest {
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
pub share_id: String,
|
|
|
|
|
pub permission: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub remote_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub base_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub source_actor: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct SyncPendingChangeRequest {
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
pub share_id: String,
|
|
|
|
|
pub resource_id: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub local_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub base_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub remote_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub change_summary: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct SyncConflictReportRequest {
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
pub share_id: String,
|
|
|
|
|
pub resource_id: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub local_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub remote_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub base_version: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub actor_id: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub summary: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
const DEFAULT_LOCAL_WORKSPACE_BASE_DIR: &str = "/mnt/Data1T/Mnote_data";
|
|
|
|
|
const DEFAULT_LOCAL_WORKSPACE_SLUG: &str = "my-space";
|
|
|
|
|
const ENV_LOCAL_ADMIN_USER_IDS: &str = "MNOTE_ADMIN_USER_IDS";
|
|
|
|
|
const ENV_LOCAL_ACCESS_POLICY_FILE: &str = "MNOTE_LOCAL_ACCESS_POLICY_FILE";
|
2026-05-19 11:11:08 +08:00
|
|
|
const ENV_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
|
2026-05-19 08:07:17 +08:00
|
|
|
|
|
|
|
|
fn default_true() -> bool {
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
pub(crate) fn ensure_local_workspace_access_for_actor(
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
actor_type: &str,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
ensure_local_workspace_access_for_actor_with_mode(
|
|
|
|
|
actor_id,
|
|
|
|
|
actor_type,
|
|
|
|
|
root_uri,
|
|
|
|
|
LocalAccessMode::Write,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn ensure_local_workspace_read_access_for_actor(
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
actor_type: &str,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
ensure_local_workspace_access_for_actor_with_mode(
|
|
|
|
|
actor_id,
|
|
|
|
|
actor_type,
|
|
|
|
|
root_uri,
|
|
|
|
|
LocalAccessMode::Read,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_local_workspace_access_for_actor_with_mode(
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
actor_type: &str,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
mode: LocalAccessMode,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
let actor_id = actor_id.trim();
|
|
|
|
|
let actor_type = actor_type.trim();
|
|
|
|
|
if actor_id.is_empty()
|
|
|
|
|
|| actor_id == "anonymous"
|
|
|
|
|
|| actor_type.is_empty()
|
|
|
|
|
|| actor_type == "anonymous"
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"local_workspace_auth_required",
|
|
|
|
|
"本地工作区访问需要先登录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let policy = load_local_access_policy().unwrap_or_default();
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !canonical_root.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_not_directory",
|
|
|
|
|
"本地 rootUri 必须指向目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if is_local_admin(actor_id, actor_type, &policy) {
|
|
|
|
|
return Ok(canonical_root);
|
|
|
|
|
}
|
|
|
|
|
if local_access_policy_allows(&policy, actor_id, &canonical_root, mode) {
|
|
|
|
|
return Ok(canonical_root);
|
|
|
|
|
}
|
|
|
|
|
if let Ok(manifest) = load_local_workspace_manifest(&canonical_root) {
|
|
|
|
|
if manifest.owner_id.trim() == actor_id {
|
|
|
|
|
return Ok(canonical_root);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_workspace_access_denied",
|
|
|
|
|
"当前用户无权访问该本地工作区",
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn ensure_local_workspace_read_access(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
ensure_local_workspace_read_access_for_actor(
|
|
|
|
|
&context.auth.actor_id,
|
|
|
|
|
&context.auth.actor_type,
|
|
|
|
|
root_uri,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn ensure_local_path_read_access(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
candidate_path: &str,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
let canonical_root = ensure_local_workspace_read_access(context, root_uri)?;
|
|
|
|
|
let candidate_path = candidate_path.trim();
|
|
|
|
|
if candidate_path.is_empty() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_file_path_required",
|
|
|
|
|
"本地文件读取缺少文件路径",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let raw_path = candidate_path
|
|
|
|
|
.strip_prefix("file://")
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| PathBuf::from(candidate_path));
|
|
|
|
|
let target_path = if raw_path.is_absolute() {
|
|
|
|
|
raw_path
|
|
|
|
|
} else {
|
|
|
|
|
canonical_root.join(raw_path)
|
|
|
|
|
};
|
|
|
|
|
let canonical_target = target_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_file_unavailable",
|
|
|
|
|
format!("无法访问本地文件: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !canonical_target.starts_with(&canonical_root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_file_root_escape",
|
|
|
|
|
"本地文件读取不能越过授权目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !canonical_target.is_file() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_file_not_file",
|
|
|
|
|
"本地文件读取目标必须是文件",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(canonical_target)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn ensure_local_workspace_access(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
ensure_local_workspace_access_for_actor(
|
|
|
|
|
&context.auth.actor_id,
|
|
|
|
|
&context.auth.actor_type,
|
|
|
|
|
root_uri,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_access_policy_path() -> PathBuf {
|
|
|
|
|
std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
default_local_workspace_base_dir()
|
|
|
|
|
.join("control-plane")
|
|
|
|
|
.join("access-policy.json")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn local_access_policy_path_display() -> String {
|
|
|
|
|
local_access_policy_path().display().to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
fn local_share_grants_path() -> PathBuf {
|
|
|
|
|
std::env::var(ENV_SHARE_GRANTS_FILE)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
default_local_workspace_base_dir()
|
|
|
|
|
.join("control-plane")
|
|
|
|
|
.join("share-grants.json")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
fn load_local_access_policy() -> Result<LocalAccessPolicy, WebError> {
|
|
|
|
|
let path = local_access_policy_path();
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Ok(LocalAccessPolicy::default());
|
|
|
|
|
}
|
|
|
|
|
let content = fs::read_to_string(&path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_read_failed",
|
|
|
|
|
format!("无法读取本地目录授权策略 {}: {error}", path.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
serde_json::from_str::<LocalAccessPolicy>(&content).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_invalid",
|
|
|
|
|
format!("本地目录授权策略格式非法: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
fn load_local_share_grants_store() -> Result<LocalShareGrantsStore, WebError> {
|
|
|
|
|
let path = local_share_grants_path();
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Ok(LocalShareGrantsStore::default());
|
|
|
|
|
}
|
|
|
|
|
let content = fs::read_to_string(&path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_share_grants_read_failed",
|
|
|
|
|
format!("无法读取分享授权控制面 {}: {error}", path.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
serde_json::from_str::<LocalShareGrantsStore>(&content).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_share_grants_invalid",
|
|
|
|
|
format!("分享授权控制面格式非法: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
fn write_local_access_policy(policy: &LocalAccessPolicy) -> Result<(), WebError> {
|
|
|
|
|
let path = local_access_policy_path();
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_dir_create_failed",
|
|
|
|
|
format!("无法创建本地目录授权策略目录 {}: {error}", parent.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
let content = serde_json::to_string_pretty(policy)
|
|
|
|
|
.map_err(|error| WebError::internal(format!("无法序列化本地目录授权策略: {error}")))?;
|
|
|
|
|
fs::write(&path, format!("{content}\n")).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_write_failed",
|
|
|
|
|
format!("无法写入本地目录授权策略 {}: {error}", path.display()),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
fn write_local_share_grants_store(store: &LocalShareGrantsStore) -> Result<(), WebError> {
|
|
|
|
|
let path = local_share_grants_path();
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_share_grants_dir_create_failed",
|
|
|
|
|
format!("无法创建分享授权目录 {}: {error}", parent.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
let content = serde_json::to_string_pretty(store)
|
|
|
|
|
.map_err(|error| WebError::internal(format!("无法序列化分享授权控制面: {error}")))?;
|
|
|
|
|
fs::write(&path, format!("{content}\n")).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_share_grants_write_failed",
|
|
|
|
|
format!("无法写入分享授权控制面 {}: {error}", path.display()),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
fn require_local_access_policy_admin(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
) -> Result<LocalAccessPolicy, WebError> {
|
|
|
|
|
let policy = load_local_access_policy()?;
|
|
|
|
|
let actor_id = context.auth.actor_id.trim();
|
|
|
|
|
let actor_type = context.auth.actor_type.trim();
|
|
|
|
|
if actor_id.is_empty()
|
|
|
|
|
|| actor_id == "anonymous"
|
|
|
|
|
|| actor_type.is_empty()
|
|
|
|
|
|| actor_type == "anonymous"
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"local_access_policy_auth_required",
|
|
|
|
|
"管理本地目录授权需要先登录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !is_local_admin(actor_id, actor_type, &policy) {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_access_policy_admin_required",
|
|
|
|
|
"只有管理员可以管理本地目录授权",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(policy)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
fn require_share_grants_admin(context: &RequestContext) -> Result<(), WebError> {
|
|
|
|
|
let policy = load_local_access_policy()?;
|
|
|
|
|
let actor_id = context.auth.actor_id.trim();
|
|
|
|
|
let actor_type = context.auth.actor_type.trim();
|
|
|
|
|
if actor_id.is_empty()
|
|
|
|
|
|| actor_id == "anonymous"
|
|
|
|
|
|| actor_type.is_empty()
|
|
|
|
|
|| actor_type == "anonymous"
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"local_share_grants_auth_required",
|
|
|
|
|
"管理分享授权需要先登录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !is_local_admin(actor_id, actor_type, &policy) {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_share_grants_admin_required",
|
|
|
|
|
"只有管理员可以管理分享授权",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
pub(crate) fn is_local_access_policy_admin_context(context: &RequestContext) -> bool {
|
|
|
|
|
let actor_id = context.auth.actor_id.trim();
|
|
|
|
|
let actor_type = context.auth.actor_type.trim();
|
|
|
|
|
if actor_id.is_empty()
|
|
|
|
|
|| actor_id == "anonymous"
|
|
|
|
|
|| actor_type.is_empty()
|
|
|
|
|
|| actor_type == "anonymous"
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let policy = load_local_access_policy().unwrap_or_default();
|
|
|
|
|
is_local_admin(actor_id, actor_type, &policy)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn env_local_admin_ids() -> Vec<String> {
|
|
|
|
|
std::env::var(ENV_LOCAL_ADMIN_USER_IDS)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| {
|
|
|
|
|
value
|
|
|
|
|
.split([',', ';', '\n', ' '])
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|item| !item.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn canonical_root_from_admin_request(root_uri: &str, root_path: &str) -> Result<PathBuf, WebError> {
|
|
|
|
|
let path = if !root_uri.trim().is_empty() {
|
|
|
|
|
parse_file_root_uri(root_uri.trim())?
|
|
|
|
|
} else if !root_path.trim().is_empty() {
|
|
|
|
|
PathBuf::from(root_path.trim())
|
|
|
|
|
} else {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_root_required",
|
|
|
|
|
"必须提供 rootUri 或 rootPath",
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
let canonical = path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_root_unavailable",
|
|
|
|
|
format!("无法访问授权目录: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !canonical.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_root_not_directory",
|
|
|
|
|
"授权目标必须是目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(canonical)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_local_access_permission(permission: &str) -> Result<String, WebError> {
|
|
|
|
|
let permission = permission.trim();
|
|
|
|
|
match permission {
|
|
|
|
|
"read" | "write" => Ok(permission.to_string()),
|
|
|
|
|
_ => Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_permission_invalid",
|
|
|
|
|
"授权 permission 只能是 read 或 write",
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_local_access_capabilities(capabilities: &[String]) -> Result<Vec<String>, WebError> {
|
|
|
|
|
let mut normalized = Vec::new();
|
|
|
|
|
for capability in capabilities {
|
|
|
|
|
let capability = capability.trim();
|
|
|
|
|
if capability.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
match capability {
|
|
|
|
|
"ai" | "share" => {
|
|
|
|
|
if !normalized.iter().any(|item| item == capability) {
|
|
|
|
|
normalized.push(capability.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_capability_invalid",
|
|
|
|
|
"授权 capability 只能是 ai 或 share",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
normalized.sort();
|
|
|
|
|
Ok(normalized)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn generate_local_access_grant_id(user_id: &str, canonical_root: &Path) -> String {
|
|
|
|
|
let now = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_millis();
|
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
|
user_id.hash(&mut hasher);
|
|
|
|
|
canonical_root.display().to_string().hash(&mut hasher);
|
|
|
|
|
format!("grant_{now}_{:x}", hasher.finish())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
fn normalize_share_grant_permission(permission: &str) -> Result<String, WebError> {
|
|
|
|
|
let permission = permission.trim();
|
|
|
|
|
match permission {
|
|
|
|
|
"read" | "write" => Ok(permission.to_string()),
|
|
|
|
|
_ => Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_permission_invalid",
|
|
|
|
|
"分享授权 permission 只能是 read 或 write",
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_share_grant_capabilities(capabilities: &[String]) -> Result<Vec<String>, WebError> {
|
|
|
|
|
let mut normalized = Vec::new();
|
|
|
|
|
for capability in capabilities {
|
|
|
|
|
let capability = capability.trim();
|
|
|
|
|
if capability.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
match capability {
|
|
|
|
|
"ai" | "share" => {
|
|
|
|
|
if !normalized.iter().any(|item| item == capability) {
|
|
|
|
|
normalized.push(capability.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_capability_invalid",
|
|
|
|
|
"分享授权 capability 只能是 ai 或 share",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
normalized.sort();
|
|
|
|
|
Ok(normalized)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn generate_local_share_grant_id(share_id: &str, target_user_id: &str, root: &Path) -> String {
|
|
|
|
|
let now = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_millis();
|
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
|
share_id.hash(&mut hasher);
|
|
|
|
|
target_user_id.hash(&mut hasher);
|
|
|
|
|
root.display().to_string().hash(&mut hasher);
|
|
|
|
|
format!("share_grant_{now}_{:x}", hasher.finish())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn share_grant_payload(grant: &LocalShareGrant) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"id": grant.id,
|
|
|
|
|
"shareId": grant.share_id,
|
|
|
|
|
"ownerUserId": grant.owner_user_id,
|
|
|
|
|
"targetUserId": grant.target_user_id,
|
|
|
|
|
"rootUri": grant.root_uri,
|
|
|
|
|
"rootPath": grant.root_path,
|
|
|
|
|
"documentId": grant.document_id,
|
|
|
|
|
"allowedResourceIds": grant.allowed_resource_ids,
|
|
|
|
|
"permission": grant.permission,
|
|
|
|
|
"capabilities": grant.capabilities,
|
|
|
|
|
"createdAt": grant.created_at,
|
|
|
|
|
"revokedAt": grant.revoked_at,
|
|
|
|
|
"active": grant.active,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn list_local_share_grants_for_context(context: &RequestContext) -> Result<Value, WebError> {
|
|
|
|
|
require_share_grants_admin(context)?;
|
|
|
|
|
let store = load_local_share_grants_store()?;
|
|
|
|
|
let grants = store
|
|
|
|
|
.grants
|
|
|
|
|
.iter()
|
|
|
|
|
.map(share_grant_payload)
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"grantsPath": local_share_grants_path().display().to_string(),
|
|
|
|
|
"grants": grants,
|
|
|
|
|
"store": {
|
|
|
|
|
"grants": grants,
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn add_local_share_grant_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
request: LocalShareGrantRequest,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
require_share_grants_admin(context)?;
|
|
|
|
|
let mut store = load_local_share_grants_store()?;
|
|
|
|
|
let share_id = request.share_id.trim();
|
|
|
|
|
let owner_user_id = request.owner_user_id.trim();
|
|
|
|
|
let target_user_id = request.target_user_id.trim();
|
|
|
|
|
if share_id.is_empty() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_share_id_required",
|
|
|
|
|
"必须提供 shareId",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if owner_user_id.is_empty() || owner_user_id == "anonymous" {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_owner_required",
|
|
|
|
|
"必须提供有效 ownerUserId",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if target_user_id.is_empty() || target_user_id == "anonymous" {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_target_required",
|
|
|
|
|
"必须提供有效 targetUserId",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?;
|
|
|
|
|
let permission = normalize_share_grant_permission(&request.permission)?;
|
|
|
|
|
let capabilities = normalize_share_grant_capabilities(&request.capabilities)?;
|
|
|
|
|
let root_uri = file_uri_for_path(&canonical);
|
|
|
|
|
let allowed_resource_ids = request
|
|
|
|
|
.allowed_resource_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|item| item.trim().to_string())
|
|
|
|
|
.filter(|item| !item.is_empty())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let grant_id = request.id.trim();
|
|
|
|
|
let grant_id = if grant_id.is_empty() {
|
|
|
|
|
generate_local_share_grant_id(share_id, target_user_id, &canonical)
|
|
|
|
|
} else {
|
|
|
|
|
grant_id.to_string()
|
|
|
|
|
};
|
|
|
|
|
if store
|
|
|
|
|
.grants
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|grant| grant.id.trim() == grant_id || grant.share_id.trim() == share_id)
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_exists",
|
|
|
|
|
"分享授权已存在",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let grant = LocalShareGrant {
|
|
|
|
|
id: grant_id,
|
|
|
|
|
share_id: share_id.to_string(),
|
|
|
|
|
owner_user_id: owner_user_id.to_string(),
|
|
|
|
|
target_user_id: target_user_id.to_string(),
|
|
|
|
|
root_uri,
|
|
|
|
|
root_path: canonical.display().to_string(),
|
|
|
|
|
document_id: request.document_id.trim().to_string(),
|
|
|
|
|
allowed_resource_ids,
|
|
|
|
|
permission,
|
|
|
|
|
capabilities,
|
|
|
|
|
created_at: now_ms().to_string(),
|
|
|
|
|
revoked_at: None,
|
|
|
|
|
active: true,
|
|
|
|
|
};
|
|
|
|
|
store.grants.push(grant.clone());
|
|
|
|
|
write_local_share_grants_store(&store)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"grantsPath": local_share_grants_path().display().to_string(),
|
|
|
|
|
"grant": share_grant_payload(&grant),
|
|
|
|
|
"store": store,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn revoke_local_share_grant_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
share_id: &str,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
require_share_grants_admin(context)?;
|
|
|
|
|
let mut store = load_local_share_grants_store()?;
|
|
|
|
|
let share_id = share_id.trim();
|
|
|
|
|
if share_id.is_empty() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_share_id_required",
|
|
|
|
|
"必须提供 shareId",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let now = now_ms().to_string();
|
|
|
|
|
let mut updated = None;
|
|
|
|
|
for grant in &mut store.grants {
|
|
|
|
|
if grant.share_id.trim() == share_id || grant.id.trim() == share_id {
|
|
|
|
|
grant.active = false;
|
|
|
|
|
grant.revoked_at = Some(now.clone());
|
|
|
|
|
updated = Some(grant.clone());
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let grant = updated.ok_or_else(|| {
|
|
|
|
|
WebError::new(
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
"local_share_grant_not_found",
|
|
|
|
|
"未找到要撤销的分享授权",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
write_local_share_grants_store(&store)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"grantsPath": local_share_grants_path().display().to_string(),
|
|
|
|
|
"revokedShareId": share_id,
|
|
|
|
|
"grant": share_grant_payload(&grant),
|
|
|
|
|
"store": store,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn require_active_share_grant_for_actor(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
share_id: &str,
|
|
|
|
|
) -> Result<LocalShareGrant, WebError> {
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_shared_cache_root_unavailable",
|
|
|
|
|
format!("无法访问共享缓存 root: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let actor_id = context.auth.actor_id.trim();
|
|
|
|
|
if actor_id.is_empty() || actor_id == "anonymous" {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"local_share_grant_auth_required",
|
|
|
|
|
"访问共享缓存需要先登录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let store = load_local_share_grants_store()?;
|
|
|
|
|
let share_id = share_id.trim();
|
|
|
|
|
let grant = store
|
|
|
|
|
.grants
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|grant| grant.share_id.trim() == share_id || grant.id.trim() == share_id)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_share_grant_not_found",
|
|
|
|
|
"未找到共享授权",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !grant.active || grant.revoked_at.as_deref().unwrap_or("").trim().len() > 0 {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_share_grant_revoked",
|
|
|
|
|
"共享授权已撤销",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if grant.target_user_id.trim() != actor_id && grant.owner_user_id.trim() != actor_id {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_share_grant_actor_mismatch",
|
|
|
|
|
"当前用户不在该共享授权范围内",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let Some(grant_root) = local_share_grant_root(&grant) else {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_share_grant_root_invalid",
|
|
|
|
|
"共享授权 root 无效",
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
if grant_root != canonical_root {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_share_grant_root_mismatch",
|
|
|
|
|
"共享授权 root 与请求 root 不一致",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(grant)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_share_grant_root(grant: &LocalShareGrant) -> Option<PathBuf> {
|
|
|
|
|
let root = grant.root_uri.trim();
|
|
|
|
|
let path = if !root.is_empty() {
|
|
|
|
|
parse_file_root_uri(root).ok()?
|
|
|
|
|
} else {
|
|
|
|
|
let root_path = grant.root_path.trim();
|
|
|
|
|
if root_path.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
PathBuf::from(root_path)
|
|
|
|
|
};
|
|
|
|
|
path.canonicalize().ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shared_cache_json_path(root: &Path) -> PathBuf {
|
|
|
|
|
root.join(".mnote").join("share-cache.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sync_state_json_path(root: &Path) -> PathBuf {
|
|
|
|
|
root.join(".mnote").join("sync-state.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sync_report_dir(root: &Path) -> PathBuf {
|
|
|
|
|
root.join(".mnote").join("sync-reports")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_json_or_default(path: &Path, default_value: Value) -> Value {
|
|
|
|
|
fs::read_to_string(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|content| serde_json::from_str::<Value>(&content).ok())
|
|
|
|
|
.unwrap_or(default_value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_json_file(path: &Path, value: &Value) -> Result<(), WebError> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_sidecar_dir_create_failed",
|
|
|
|
|
format!("无法创建本地 sidecar 目录 {}: {error}", parent.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
let content = serde_json::to_string_pretty(value)
|
|
|
|
|
.map_err(|error| WebError::internal(format!("无法序列化本地 sidecar JSON: {error}")))?;
|
|
|
|
|
fs::write(path, format!("{content}\n")).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_sidecar_write_failed",
|
|
|
|
|
format!("无法写入本地 sidecar 文件 {}: {error}", path.display()),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn record_shared_cache_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
request: SharedCacheRecordRequest,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let grant =
|
|
|
|
|
require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?;
|
|
|
|
|
let root = parse_file_root_uri(&request.root_uri)?
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_shared_cache_root_unavailable",
|
|
|
|
|
format!("无法访问共享缓存 root: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let permission = normalize_share_grant_permission(&request.permission)?;
|
|
|
|
|
if permission != grant.permission.trim() {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_shared_cache_permission_mismatch",
|
|
|
|
|
"共享缓存权限必须来自 canonical share grant",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let cache_entry = json!({
|
|
|
|
|
"schema": "mnote.share_cache.v1",
|
|
|
|
|
"shareId": grant.share_id,
|
|
|
|
|
"permission": permission,
|
|
|
|
|
"rootUri": grant.root_uri,
|
|
|
|
|
"allowedResourceIds": grant.allowed_resource_ids,
|
|
|
|
|
"remoteVersion": request.remote_version,
|
|
|
|
|
"baseVersion": request.base_version,
|
|
|
|
|
"sourceActor": request.source_actor,
|
|
|
|
|
"syncedAt": now_ms(),
|
|
|
|
|
});
|
|
|
|
|
let path = shared_cache_json_path(&root);
|
|
|
|
|
let mut cache = read_json_or_default(
|
|
|
|
|
&path,
|
|
|
|
|
json!({"schema": "mnote.share_cache.v1", "shares": {}}),
|
|
|
|
|
);
|
|
|
|
|
cache["shares"][grant.share_id.clone()] = cache_entry.clone();
|
|
|
|
|
write_json_file(&path, &cache)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"path": path.display().to_string(),
|
|
|
|
|
"share": cache_entry,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn record_sync_pending_change_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
request: SyncPendingChangeRequest,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let grant =
|
|
|
|
|
require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?;
|
|
|
|
|
if normalize_share_grant_permission(&grant.permission)? != "write" {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_sync_shared_read_write_forbidden",
|
|
|
|
|
"shared_read 不能记录待同步写入",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !grant
|
|
|
|
|
.allowed_resource_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|resource_id| resource_id == &request.resource_id)
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_sync_resource_not_shared",
|
|
|
|
|
"待同步资源不在 share grant 授权范围内",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !request.remote_version.trim().is_empty()
|
|
|
|
|
&& !request.base_version.trim().is_empty()
|
|
|
|
|
&& request.remote_version.trim() != request.base_version.trim()
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::CONFLICT,
|
|
|
|
|
"local_sync_base_version_conflict",
|
|
|
|
|
"同步前远端版本已变化,需要生成冲突报告",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let root = parse_file_root_uri(&request.root_uri)?
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_sync_root_unavailable",
|
|
|
|
|
format!("无法访问同步 root: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let path = sync_state_json_path(&root);
|
|
|
|
|
let mut state = read_json_or_default(
|
|
|
|
|
&path,
|
|
|
|
|
json!({"schema": "mnote.sync_state.v1", "pendingChanges": []}),
|
|
|
|
|
);
|
|
|
|
|
let pending = json!({
|
|
|
|
|
"shareId": grant.share_id,
|
|
|
|
|
"resourceId": request.resource_id,
|
|
|
|
|
"localVersion": request.local_version,
|
|
|
|
|
"baseVersion": request.base_version,
|
|
|
|
|
"remoteVersion": request.remote_version,
|
|
|
|
|
"actorId": context.auth.actor_id,
|
|
|
|
|
"changeSummary": request.change_summary,
|
|
|
|
|
"createdAt": now_ms(),
|
|
|
|
|
});
|
|
|
|
|
state["pendingChanges"]
|
|
|
|
|
.as_array_mut()
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_sync_state_invalid",
|
|
|
|
|
"sync-state pendingChanges 非数组",
|
|
|
|
|
)
|
|
|
|
|
})?
|
|
|
|
|
.push(pending.clone());
|
|
|
|
|
write_json_file(&path, &state)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"path": path.display().to_string(),
|
|
|
|
|
"pendingChange": pending,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_sync_conflict_report_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
request: SyncConflictReportRequest,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let grant =
|
|
|
|
|
require_active_share_grant_for_actor(context, &request.root_uri, &request.share_id)?;
|
|
|
|
|
if !grant
|
|
|
|
|
.allowed_resource_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|resource_id| resource_id == &request.resource_id)
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_sync_report_resource_not_shared",
|
|
|
|
|
"冲突报告资源不在 share grant 授权范围内",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let root = parse_file_root_uri(&request.root_uri)?
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_sync_report_root_unavailable",
|
|
|
|
|
format!("无法访问同步报告 root: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let report = json!({
|
|
|
|
|
"schema": "mnote.sync_conflict_report.v1",
|
|
|
|
|
"source": "shared_sync",
|
|
|
|
|
"shareId": grant.share_id,
|
|
|
|
|
"resourceId": request.resource_id,
|
|
|
|
|
"localVersion": request.local_version,
|
|
|
|
|
"remoteVersion": request.remote_version,
|
|
|
|
|
"baseVersion": request.base_version,
|
|
|
|
|
"actorId": if request.actor_id.trim().is_empty() { context.auth.actor_id.clone() } else { request.actor_id },
|
|
|
|
|
"summary": request.summary,
|
|
|
|
|
"createdAt": now_ms(),
|
|
|
|
|
"suggestedActions": ["accept_local", "accept_remote", "open_diff_merge"],
|
|
|
|
|
});
|
|
|
|
|
let dir = sync_report_dir(&root);
|
|
|
|
|
fs::create_dir_all(&dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_sync_report_dir_create_failed",
|
|
|
|
|
format!("无法创建同步报告目录 {}: {error}", dir.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let path = dir.join(format!(
|
|
|
|
|
"sync-conflict-{}-{}.json",
|
|
|
|
|
sanitize_file_name(&grant.share_id, "share"),
|
|
|
|
|
now_ms()
|
|
|
|
|
));
|
|
|
|
|
write_json_file(&path, &report)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"path": path.display().to_string(),
|
|
|
|
|
"report": report,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
fn local_access_policy_payload(policy: &LocalAccessPolicy) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"policyPath": local_access_policy_path().display().to_string(),
|
|
|
|
|
"policy": policy,
|
|
|
|
|
"effectiveAdmins": {
|
|
|
|
|
"policy": &policy.admins,
|
|
|
|
|
"env": env_local_admin_ids(),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_local_access_root_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
request: LocalAccessValidateRootRequest,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let policy = require_local_access_policy_admin(context)?;
|
|
|
|
|
let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"policyPath": local_access_policy_path().display().to_string(),
|
|
|
|
|
"rootPath": canonical.display().to_string(),
|
|
|
|
|
"rootUri": file_uri_for_path(&canonical),
|
|
|
|
|
"admin": is_local_admin(&context.auth.actor_id, &context.auth.actor_type, &policy),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn add_local_access_grant_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
request: LocalAccessGrantRequest,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let mut policy = require_local_access_policy_admin(context)?;
|
|
|
|
|
let user_id = request.user_id.trim();
|
|
|
|
|
if user_id.is_empty() || user_id == "anonymous" {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_user_required",
|
|
|
|
|
"必须提供有效 userId",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?;
|
|
|
|
|
let permission = normalize_local_access_permission(&request.permission)?;
|
|
|
|
|
let capabilities = normalize_local_access_capabilities(&request.capabilities)?;
|
|
|
|
|
let root_uri = file_uri_for_path(&canonical);
|
|
|
|
|
let grant_id = request.id.trim();
|
|
|
|
|
let grant_id = if grant_id.is_empty() {
|
|
|
|
|
generate_local_access_grant_id(user_id, &canonical)
|
|
|
|
|
} else {
|
|
|
|
|
grant_id.to_string()
|
|
|
|
|
};
|
|
|
|
|
if policy
|
|
|
|
|
.grants
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|grant| grant.id.trim() == grant_id)
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_grant_id_exists",
|
|
|
|
|
"授权 grantId 已存在",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if policy.grants.iter().any(|grant| {
|
|
|
|
|
grant.user_id.trim() == user_id
|
|
|
|
|
&& grant.root_uri.trim() == root_uri
|
|
|
|
|
&& grant.permission.trim() == permission
|
|
|
|
|
&& grant.recursive == request.recursive
|
|
|
|
|
&& {
|
|
|
|
|
let existing =
|
|
|
|
|
normalize_local_access_capabilities(&grant.capabilities).unwrap_or_default();
|
|
|
|
|
existing == capabilities
|
|
|
|
|
}
|
|
|
|
|
}) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_grant_duplicate",
|
|
|
|
|
"相同目录授权已存在",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let grant = LocalAccessGrant {
|
|
|
|
|
id: grant_id,
|
|
|
|
|
user_id: user_id.to_string(),
|
|
|
|
|
root_uri,
|
|
|
|
|
root_path: canonical.display().to_string(),
|
|
|
|
|
permission,
|
|
|
|
|
recursive: request.recursive,
|
|
|
|
|
capabilities,
|
|
|
|
|
};
|
|
|
|
|
policy.grants.push(grant.clone());
|
|
|
|
|
write_local_access_policy(&policy)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"policyPath": local_access_policy_path().display().to_string(),
|
|
|
|
|
"grant": grant,
|
|
|
|
|
"policy": policy,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn delete_local_access_grant_for_context(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
grant_id: &str,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let mut policy = require_local_access_policy_admin(context)?;
|
|
|
|
|
let grant_id = grant_id.trim();
|
|
|
|
|
if grant_id.is_empty() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_access_policy_grant_id_required",
|
|
|
|
|
"必须提供 grantId",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let before = policy.grants.len();
|
|
|
|
|
policy.grants.retain(|grant| grant.id.trim() != grant_id);
|
|
|
|
|
if policy.grants.len() == before {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
"local_access_policy_grant_not_found",
|
|
|
|
|
"未找到要删除的目录授权",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
write_local_access_policy(&policy)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"policyPath": local_access_policy_path().display().to_string(),
|
|
|
|
|
"deletedGrantId": grant_id,
|
|
|
|
|
"policy": policy,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_local_admin(actor_id: &str, actor_type: &str, policy: &LocalAccessPolicy) -> bool {
|
|
|
|
|
if actor_type.trim() == "admin" {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
let actor_id = actor_id.trim();
|
|
|
|
|
if actor_id.is_empty() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if policy.admins.iter().any(|admin| admin.trim() == actor_id) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
std::env::var(ENV_LOCAL_ADMIN_USER_IDS)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| {
|
|
|
|
|
value
|
|
|
|
|
.split([',', ';', '\n', ' '])
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|item| !item.is_empty())
|
|
|
|
|
.any(|item| item == actor_id)
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_access_policy_allows(
|
|
|
|
|
policy: &LocalAccessPolicy,
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
canonical_root: &Path,
|
|
|
|
|
mode: LocalAccessMode,
|
|
|
|
|
) -> bool {
|
|
|
|
|
let actor_id = actor_id.trim();
|
|
|
|
|
if actor_id.is_empty() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
policy.grants.iter().any(|grant| {
|
|
|
|
|
if grant.user_id.trim() != actor_id {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if !local_access_permission_allows(grant.permission.as_str(), mode) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let Some(grant_root) = local_access_grant_root(grant) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
if grant.recursive {
|
|
|
|
|
canonical_root.starts_with(&grant_root)
|
|
|
|
|
} else {
|
|
|
|
|
canonical_root == grant_root
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_access_permission_allows(permission: &str, mode: LocalAccessMode) -> bool {
|
|
|
|
|
let permission = permission.trim();
|
|
|
|
|
if permission == "admin" {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
match mode {
|
|
|
|
|
LocalAccessMode::Read => permission == "read" || permission == "write",
|
|
|
|
|
LocalAccessMode::Write => permission == "write",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_access_grant_root(grant: &LocalAccessGrant) -> Option<PathBuf> {
|
|
|
|
|
let root = grant.root_uri.trim();
|
|
|
|
|
let path = if !root.is_empty() {
|
|
|
|
|
parse_file_root_uri(root).ok()?
|
|
|
|
|
} else {
|
|
|
|
|
let root_path = grant.root_path.trim();
|
|
|
|
|
if root_path.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
PathBuf::from(root_path)
|
|
|
|
|
};
|
|
|
|
|
path.canonicalize().ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn create_default_local_workspace_for_actor(
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
actor_type: &str,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
create_default_local_workspace_for_actor_at_base(
|
|
|
|
|
actor_id,
|
|
|
|
|
actor_type,
|
|
|
|
|
&default_local_workspace_base_dir(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn create_default_local_workspace_for_actor_at_base(
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
actor_type: &str,
|
|
|
|
|
base_dir: &Path,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let actor_id = actor_id.trim();
|
|
|
|
|
let actor_type = actor_type.trim();
|
|
|
|
|
if actor_id.is_empty()
|
|
|
|
|
|| actor_id == "anonymous"
|
|
|
|
|
|| actor_type.is_empty()
|
|
|
|
|
|| actor_type == "anonymous"
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"local_workspace_auth_required",
|
|
|
|
|
"创建本地工作区需要先登录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs::create_dir_all(base_dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_base_create_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法创建本地工作区数据根目录 {}: {error}",
|
|
|
|
|
base_dir.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let canonical_base = base_dir.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_base_unavailable",
|
|
|
|
|
format!("无法访问本地工作区数据根目录: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let actor_segment = encode_local_id_segment(actor_id);
|
|
|
|
|
let workspace_root = canonical_base
|
|
|
|
|
.join("users")
|
|
|
|
|
.join(&actor_segment)
|
|
|
|
|
.join("workspaces")
|
|
|
|
|
.join(DEFAULT_LOCAL_WORKSPACE_SLUG);
|
|
|
|
|
fs::create_dir_all(&workspace_root).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_create_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法创建默认本地工作区 {}: {error}",
|
|
|
|
|
workspace_root.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let canonical_root = workspace_root.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_unavailable",
|
|
|
|
|
format!("无法访问默认本地工作区: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !canonical_root.starts_with(&canonical_base) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_workspace_root_escape",
|
|
|
|
|
"默认本地工作区不能越过受管数据根目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let manifest = ensure_default_workspace_manifest(actor_id, &canonical_root)?;
|
|
|
|
|
ensure_default_workspace_directories(&canonical_root)?;
|
|
|
|
|
ensure_default_workspace_home_page(&canonical_root)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"workspace": {
|
|
|
|
|
"rootUri": file_uri_for_path(&canonical_root),
|
|
|
|
|
"rootPath": canonical_root.display().to_string(),
|
|
|
|
|
"baseDir": canonical_base.display().to_string(),
|
|
|
|
|
"manifest": local_workspace_manifest_json(&manifest),
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn create_default_local_workspace(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload =
|
|
|
|
|
create_default_local_workspace_for_actor(&context.auth.actor_id, &context.auth.actor_type)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_local_access_policy(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let policy = require_local_access_policy_admin(&context)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(local_access_policy_payload(&policy))))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn validate_local_access_root(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(request): Json<LocalAccessValidateRootRequest>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = validate_local_access_root_for_context(&context, request)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn create_local_access_grant(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(request): Json<LocalAccessGrantRequest>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = add_local_access_grant_for_context(&context, request)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn delete_local_access_grant(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
AxumPath(grant_id): AxumPath<String>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = delete_local_access_grant_for_context(&context, &grant_id)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
pub async fn get_share_grants(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = list_local_share_grants_for_context(&context)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn create_share_grant(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(request): Json<LocalShareGrantRequest>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = add_local_share_grant_for_context(&context, request)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn delete_share_grant(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
AxumPath(share_id): AxumPath<String>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = revoke_local_share_grant_for_context(&context, &share_id)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn record_shared_cache(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(request): Json<SharedCacheRecordRequest>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = record_shared_cache_for_context(&context, request)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn record_sync_pending_change(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(request): Json<SyncPendingChangeRequest>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = record_sync_pending_change_for_context(&context, request)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn write_sync_conflict_report(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(request): Json<SyncConflictReportRequest>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let payload = write_sync_conflict_report_for_context(&context, request)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
Ok((StatusCode::OK, Json(payload)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[cfg(test)]
|
|
|
|
|
pub(crate) fn initialize_local_workspace_for_actor(
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let actor_id = actor_id.trim();
|
|
|
|
|
if actor_id.is_empty() || actor_id == "anonymous" {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"local_workspace_auth_required",
|
|
|
|
|
"创建本地工作区需要先登录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !canonical_root.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_not_directory",
|
|
|
|
|
"本地 rootUri 必须指向目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let manifest_path = local_workspace_manifest_path(&canonical_root);
|
|
|
|
|
if manifest_path.exists() {
|
|
|
|
|
let manifest = load_local_workspace_manifest(&canonical_root)?;
|
|
|
|
|
if manifest.owner_id.trim() != actor_id {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_workspace_access_denied",
|
|
|
|
|
"当前用户无权接管该本地工作区",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return Ok(local_workspace_manifest_json(&manifest));
|
|
|
|
|
}
|
|
|
|
|
let manifest = LocalWorkspaceManifest {
|
|
|
|
|
workspace_id: format!(
|
|
|
|
|
"local-ws:{}",
|
|
|
|
|
encode_local_id_segment(&format!("{}:{}", actor_id, canonical_root.display()))
|
|
|
|
|
),
|
|
|
|
|
owner_id: actor_id.to_string(),
|
|
|
|
|
created_at: now_ms().to_string(),
|
|
|
|
|
capabilities: vec![
|
|
|
|
|
"local_files".to_string(),
|
|
|
|
|
"tree_commands".to_string(),
|
|
|
|
|
"markdown_edit".to_string(),
|
|
|
|
|
"asset_upload".to_string(),
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
write_local_workspace_manifest(&canonical_root, &manifest)?;
|
|
|
|
|
Ok(local_workspace_manifest_json(&manifest))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_local_workspace_base_dir() -> PathBuf {
|
|
|
|
|
std::env::var("MNOTE_LOCAL_WORKSPACE_BASE_DIR")
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| PathBuf::from(DEFAULT_LOCAL_WORKSPACE_BASE_DIR))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_default_workspace_manifest(
|
|
|
|
|
actor_id: &str,
|
|
|
|
|
root: &Path,
|
|
|
|
|
) -> Result<LocalWorkspaceManifest, WebError> {
|
|
|
|
|
let manifest_path = local_workspace_manifest_path(root);
|
|
|
|
|
if manifest_path.exists() {
|
|
|
|
|
let manifest = load_local_workspace_manifest(root)?;
|
|
|
|
|
if manifest.owner_id.trim() != actor_id {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_workspace_access_denied",
|
|
|
|
|
"当前用户无权接管该本地工作区",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
return Ok(manifest);
|
|
|
|
|
}
|
|
|
|
|
let manifest = LocalWorkspaceManifest {
|
|
|
|
|
workspace_id: format!(
|
|
|
|
|
"local-ws:{}:{}",
|
|
|
|
|
encode_local_id_segment(actor_id),
|
|
|
|
|
DEFAULT_LOCAL_WORKSPACE_SLUG
|
|
|
|
|
),
|
|
|
|
|
owner_id: actor_id.to_string(),
|
|
|
|
|
created_at: now_ms().to_string(),
|
|
|
|
|
capabilities: vec![
|
|
|
|
|
"local_files".to_string(),
|
|
|
|
|
"tree_commands".to_string(),
|
|
|
|
|
"markdown_edit".to_string(),
|
|
|
|
|
"asset_upload".to_string(),
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
write_local_workspace_manifest(root, &manifest)?;
|
|
|
|
|
Ok(manifest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> {
|
2026-05-20 10:43:38 +08:00
|
|
|
for relative in [".mnote/trash"] {
|
2026-05-19 08:07:17 +08:00
|
|
|
let path = root.join(relative);
|
|
|
|
|
if !path.starts_with(root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_workspace_root_escape",
|
|
|
|
|
"默认本地工作区目录不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
fs::create_dir_all(&path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_create_failed",
|
|
|
|
|
format!("无法创建默认本地工作区目录 {}: {error}", path.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_default_workspace_home_page(root: &Path) -> Result<(), WebError> {
|
2026-05-20 10:43:38 +08:00
|
|
|
let page_dir = root.join("初始化的新页面");
|
|
|
|
|
let page = page_dir.join("初始化的新页面.md");
|
2026-05-19 08:07:17 +08:00
|
|
|
if page.exists() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
if !page.starts_with(root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_workspace_root_escape",
|
|
|
|
|
"默认本地工作区首页不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-20 10:43:38 +08:00
|
|
|
fs::create_dir_all(&page_dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_create_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法创建默认本地工作区首页目录 {}: {error}",
|
|
|
|
|
page_dir.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let content = "";
|
2026-05-19 08:07:17 +08:00
|
|
|
fs::write(&page, content).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_create_failed",
|
|
|
|
|
format!("无法创建默认本地工作区首页 {}: {error}", page.display()),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_workspace_manifest_path(root: &Path) -> PathBuf {
|
|
|
|
|
root.join(".mnote").join("workspace.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_local_workspace_manifest(root: &Path) -> Result<LocalWorkspaceManifest, WebError> {
|
|
|
|
|
let manifest_path = local_workspace_manifest_path(root);
|
|
|
|
|
let content = fs::read_to_string(&manifest_path).map_err(|error| {
|
|
|
|
|
WebError::new(
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"local_workspace_manifest_missing",
|
|
|
|
|
format!("缺少本地工作区清单 {}: {error}", manifest_path.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let manifest = serde_json::from_str::<LocalWorkspaceManifest>(&content).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_manifest_invalid",
|
|
|
|
|
format!("本地工作区清单格式非法: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if manifest.workspace_id.trim().is_empty() || manifest.owner_id.trim().is_empty() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_workspace_manifest_invalid",
|
|
|
|
|
"本地工作区清单缺少 workspaceId / ownerId",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(manifest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_local_workspace_manifest(
|
|
|
|
|
root: &Path,
|
|
|
|
|
manifest: &LocalWorkspaceManifest,
|
|
|
|
|
) -> Result<(), WebError> {
|
|
|
|
|
let metadata_dir = root.join(".mnote");
|
|
|
|
|
fs::create_dir_all(&metadata_dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_manifest_write_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法创建本地工作区元数据目录 {}: {error}",
|
|
|
|
|
metadata_dir.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let manifest_path = local_workspace_manifest_path(root);
|
|
|
|
|
let content = serde_json::to_string_pretty(manifest)
|
|
|
|
|
.map_err(|error| WebError::internal(format!("本地工作区清单序列化失败: {error}")))?;
|
|
|
|
|
fs::write(&manifest_path, content).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_workspace_manifest_write_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法写入本地工作区清单 {}: {error}",
|
|
|
|
|
manifest_path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_workspace_manifest_json(manifest: &LocalWorkspaceManifest) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": manifest.workspace_id,
|
|
|
|
|
"ownerId": manifest.owner_id,
|
|
|
|
|
"createdAt": manifest.created_at,
|
|
|
|
|
"capabilities": manifest.capabilities,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
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 必须指向目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
let workspace_id = local_workspace_id(&canonical_root);
|
2026-05-08 00:41:03 +08:00
|
|
|
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,
|
2026-05-19 11:11:08 +08:00
|
|
|
&workspace_id,
|
2026-05-08 00:41:03 +08:00
|
|
|
&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": {
|
2026-05-19 11:11:08 +08:00
|
|
|
"id": &workspace_id,
|
2026-05-08 00:41:03 +08:00
|
|
|
"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 必须指向目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
let workspace_id = local_workspace_id(&canonical_root);
|
2026-05-08 00:41:03 +08:00
|
|
|
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,
|
2026-05-20 10:43:38 +08:00
|
|
|
None,
|
2026-05-08 00:41:03 +08:00
|
|
|
&metadata,
|
|
|
|
|
&mut rows,
|
2026-05-19 11:11:08 +08:00
|
|
|
&workspace_id,
|
|
|
|
|
&root_source_uri,
|
2026-05-08 00:41:03 +08:00
|
|
|
)?;
|
|
|
|
|
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;
|
2026-05-08 10:57:24 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-05-16 22:03:14 +08:00
|
|
|
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-19 08:07:17 +08:00
|
|
|
let conflict_detection_key =
|
|
|
|
|
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
|
|
|
|
|
|
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()),
|
2026-05-19 08:07:17 +08:00
|
|
|
conflict_detection_key: Value::String(conflict_detection_key.clone()),
|
|
|
|
|
file_version: Value::String(conflict_detection_key),
|
2026-05-16 22:03:14 +08:00
|
|
|
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,
|
2026-05-08 10:57:24 +08:00
|
|
|
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()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-08 10:57:24 +08:00
|
|
|
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 {
|
2026-05-19 09:38:57 +08:00
|
|
|
return Err(local_markdown_conflict_error(
|
|
|
|
|
root_uri,
|
|
|
|
|
document_id,
|
|
|
|
|
expected_key,
|
|
|
|
|
¤t_conflict_key,
|
2026-05-08 10:57:24 +08:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
let (frontmatter, _) = split_frontmatter(¤t);
|
2026-05-20 10:43:38 +08:00
|
|
|
let body = editor_blocks_to_markdown_for_file(content, &canonical_root, &markdown_file.path);
|
2026-05-08 00:41:03 +08:00
|
|
|
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()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-19 09:38:57 +08:00
|
|
|
let workspace_id = local_workspace_id(&canonical_root);
|
2026-05-20 10:43:38 +08:00
|
|
|
let relative_path_for_buffer = markdown_file
|
|
|
|
|
.path
|
|
|
|
|
.strip_prefix(&canonical_root)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
|
|
|
|
.unwrap_or_default();
|
2026-05-19 09:38:57 +08:00
|
|
|
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
2026-05-08 10:57:24 +08:00
|
|
|
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,
|
2026-05-20 10:43:38 +08:00
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"relativePath": relative_path_for_buffer,
|
2026-05-08 00:41:03 +08:00
|
|
|
"revision": now_ms(),
|
2026-05-08 10:57:24 +08:00
|
|
|
"conflict_detection_key": next_conflict_key,
|
2026-05-19 08:07:17 +08:00
|
|
|
"fileVersion": next_conflict_key,
|
2026-05-08 00:41:03 +08:00
|
|
|
"executedCommand": "page.body.save",
|
|
|
|
|
"canonicalCommand": "page.body.save",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 09:38:57 +08:00
|
|
|
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
|
|
|
|
|
let _ = local_search_index::refresh_local_search_index(root, root_uri, workspace_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_markdown_conflict_error(
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
editor_base_version: &str,
|
|
|
|
|
current_disk_version: &str,
|
|
|
|
|
) -> WebError {
|
|
|
|
|
WebError::new(
|
|
|
|
|
StatusCode::CONFLICT,
|
|
|
|
|
"local_markdown_external_change",
|
|
|
|
|
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
|
|
|
|
)
|
|
|
|
|
.with_details(json!({
|
|
|
|
|
"conflict": {
|
|
|
|
|
"code": "local_markdown_external_change",
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"rootUri": root_uri,
|
|
|
|
|
"currentDiskVersion": current_disk_version,
|
|
|
|
|
"editorBaseVersion": editor_base_version,
|
|
|
|
|
"suggestedActions": [
|
|
|
|
|
"accept_disk",
|
|
|
|
|
"keep_editor",
|
|
|
|
|
"open_diff",
|
|
|
|
|
"merge"
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
pub fn write_local_markdown_page_body(
|
|
|
|
|
request: &core_protocol::PageBodyWriteRequest,
|
2026-05-20 10:43:38 +08:00
|
|
|
buffer_store: Option<&crate::document_buffer_store::BufferStore>,
|
2026-05-19 08:07:17 +08:00
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
if request.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"page_body_write_source_unsupported",
|
|
|
|
|
"page.body.write 当前只支持 local_folder 本地写入",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if request.content_format.trim() != "editorBlocks" {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"page_body_write_content_format_unsupported",
|
|
|
|
|
"page.body.write 当前只支持 editorBlocks 内容格式",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut result = save_local_markdown_page(
|
|
|
|
|
&request.root_uri,
|
|
|
|
|
&request.document_id,
|
|
|
|
|
request.expected_file_version.as_deref(),
|
|
|
|
|
&request.content,
|
|
|
|
|
)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
|
|
|
|
|
// 保存成功后更新 BufferStore
|
|
|
|
|
if let Some(store) = buffer_store {
|
|
|
|
|
if let (Some(workspace_id), Some(relative_path), Some(file_version)) = (
|
|
|
|
|
result.get("workspaceId").and_then(Value::as_str),
|
|
|
|
|
result.get("relativePath").and_then(Value::as_str),
|
|
|
|
|
result.get("fileVersion").and_then(Value::as_str),
|
|
|
|
|
) {
|
|
|
|
|
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
|
|
|
|
|
workspace_id,
|
|
|
|
|
&request.root_uri,
|
|
|
|
|
relative_path,
|
|
|
|
|
&request.document_id,
|
|
|
|
|
);
|
|
|
|
|
store.mark_saved(
|
|
|
|
|
&ws_path,
|
|
|
|
|
file_version.to_string(),
|
|
|
|
|
format!("sha256:{file_version}"),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
if let Value::Object(map) = &mut result {
|
|
|
|
|
map.insert("canonicalCommand".into(), json!("page.body.write"));
|
|
|
|
|
map.insert("compatCommand".into(), json!("page.body.save"));
|
|
|
|
|
map.insert("contentFormat".into(), json!(request.content_format));
|
|
|
|
|
map.insert(
|
|
|
|
|
"editorSource".into(),
|
|
|
|
|
request
|
|
|
|
|
.editor_source
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(Value::from)
|
|
|
|
|
.unwrap_or(Value::Null),
|
|
|
|
|
);
|
|
|
|
|
map.insert(
|
|
|
|
|
"baseContentHash".into(),
|
|
|
|
|
request
|
|
|
|
|
.base_content_hash
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(Value::from)
|
|
|
|
|
.unwrap_or(Value::Null),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn upload_local_markdown_asset(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
multipart: Multipart,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let fields = read_local_asset_upload_multipart(multipart).await?;
|
|
|
|
|
ensure_local_workspace_access(&context, &fields.root_uri)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
let asset = write_local_markdown_asset(
|
|
|
|
|
&fields.root_uri,
|
|
|
|
|
&fields.document_id,
|
|
|
|
|
&fields.kind,
|
|
|
|
|
fields.file,
|
|
|
|
|
)?;
|
|
|
|
|
Ok((StatusCode::OK, Json(json!({ "ok": true, "asset": asset }))))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn open_local_file(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<LocalFileOpenQuery>,
|
|
|
|
|
) -> Result<(StatusCode, HeaderMap, Vec<u8>), WebError> {
|
|
|
|
|
ensure_local_workspace_read_access(&context, &query.root_uri)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
|
|
|
|
let target = resolve_local_file_open_path(&query.root_uri, &query.path)?;
|
|
|
|
|
let bytes = fs::read(&target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_file_open_read_failed",
|
|
|
|
|
format!("无法读取本地文件 {}: {error}", target.display()),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
let mut headers = HeaderMap::new();
|
2026-05-20 10:43:38 +08:00
|
|
|
headers.insert(header::CONTENT_TYPE, content_type_for_path(&target));
|
2026-05-19 08:07:17 +08:00
|
|
|
if query.download.unwrap_or(false) {
|
|
|
|
|
headers.insert(
|
|
|
|
|
header::CONTENT_DISPOSITION,
|
|
|
|
|
HeaderValue::from_static("attachment"),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok((StatusCode::OK, headers, bytes))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let requested = Path::new(relative_path);
|
|
|
|
|
if requested.is_absolute()
|
|
|
|
|
|| requested
|
|
|
|
|
.components()
|
|
|
|
|
.any(|component| matches!(component, std::path::Component::ParentDir))
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_file_open_root_escape",
|
|
|
|
|
"本地文件路径不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let target = canonical_root
|
|
|
|
|
.join(requested)
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_file_open_not_found",
|
|
|
|
|
format!("找不到本地文件: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !target.starts_with(&canonical_root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_file_open_root_escape",
|
|
|
|
|
"本地文件路径不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if target.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_file_open_is_directory",
|
|
|
|
|
"不能直接读取本地目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(target)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn read_local_asset_upload_multipart(
|
|
|
|
|
mut multipart: Multipart,
|
|
|
|
|
) -> Result<LocalAssetUploadFields, WebError> {
|
|
|
|
|
let mut file: Option<LocalUploadFile> = None;
|
|
|
|
|
let mut root_uri = String::new();
|
|
|
|
|
let mut document_id = String::new();
|
|
|
|
|
let mut kind = String::new();
|
|
|
|
|
|
|
|
|
|
while let Some(field) = multipart.next_field().await.map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_bad_multipart",
|
|
|
|
|
format!("上传表单解析失败: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})? {
|
|
|
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
|
|
|
if name == "file" {
|
|
|
|
|
let file_name = field
|
|
|
|
|
.file_name()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("附件")
|
|
|
|
|
.to_string();
|
|
|
|
|
let content_type = field
|
|
|
|
|
.content_type()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("application/octet-stream")
|
|
|
|
|
.to_string();
|
|
|
|
|
let bytes = field
|
|
|
|
|
.bytes()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_file_read_failed",
|
|
|
|
|
format!("读取上传文件失败: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?
|
|
|
|
|
.to_vec();
|
|
|
|
|
file = Some(LocalUploadFile {
|
|
|
|
|
name: file_name,
|
|
|
|
|
content_type,
|
|
|
|
|
bytes,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let value = field.text().await.map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_field_read_failed",
|
|
|
|
|
format!("读取上传字段失败: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
match name.as_str() {
|
|
|
|
|
"rootUri" => root_uri = value.trim().to_string(),
|
|
|
|
|
"documentId" => document_id = value.trim().to_string(),
|
|
|
|
|
"kind" => kind = value.trim().to_string(),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let file = file.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_asset_upload_file_missing", "缺少 file")
|
|
|
|
|
})?;
|
|
|
|
|
if file.bytes.is_empty() || root_uri.is_empty() || document_id.is_empty() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_required_missing",
|
|
|
|
|
"缺少必要参数",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(LocalAssetUploadFields {
|
|
|
|
|
file,
|
|
|
|
|
root_uri,
|
|
|
|
|
document_id,
|
|
|
|
|
kind,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn write_local_markdown_asset(
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
kind: &str,
|
|
|
|
|
file: LocalUploadFile,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !canonical_root.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_not_directory",
|
|
|
|
|
"本地 rootUri 必须指向目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let metadata = load_local_folder_metadata(&canonical_root)?;
|
|
|
|
|
let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_markdown_not_found",
|
|
|
|
|
"找不到本地 Markdown 页面对应的文件",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let markdown_dir = markdown_file.path.parent().ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_bad_markdown_path",
|
|
|
|
|
"本地 Markdown 文件路径无父目录",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !markdown_dir.starts_with(&canonical_root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_root_escape",
|
|
|
|
|
"本地 Markdown 文件路径不在 root 内",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
let page_resource_dir =
|
|
|
|
|
markdown_page_resource_directory(&markdown_file.path).ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_bad_markdown_path",
|
|
|
|
|
"无法解析本地页面资源目录",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let asset_dir = page_resource_dir;
|
2026-05-19 08:07:17 +08:00
|
|
|
if !asset_dir.starts_with(&canonical_root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_root_escape",
|
|
|
|
|
"本地资源目录不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
fs::create_dir_all(&asset_dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_create_dir_failed",
|
|
|
|
|
format!("无法创建本地资源目录 {}: {error}", asset_dir.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let sanitized_name = sanitize_file_name(&file.name, "附件");
|
|
|
|
|
let target = next_available_asset_path(&asset_dir, &sanitized_name);
|
|
|
|
|
if !target.starts_with(&canonical_root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_root_escape",
|
|
|
|
|
"本地资源文件不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
fs::write(&target, &file.bytes).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_write_failed",
|
|
|
|
|
format!("无法写入本地资源文件 {}: {error}", target.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
|
|
|
|
|
let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?;
|
|
|
|
|
let asset_type = local_upload_asset_type(kind, &file.content_type);
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"id": format!("local:asset:{root_relative_path}"),
|
|
|
|
|
"asset_type": asset_type,
|
|
|
|
|
"file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name),
|
|
|
|
|
"mime_type": file.content_type,
|
|
|
|
|
"file_size": file.bytes.len(),
|
|
|
|
|
"file_url": markdown_relative_path,
|
|
|
|
|
"sourcePath": markdown_relative_path,
|
|
|
|
|
"document_id": document_id,
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
"rootUri": file_uri_for_path(&canonical_root),
|
|
|
|
|
"rootRelativePath": root_relative_path,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn local_mindmap_relative_path_from_id(mindmap_id: &str) -> Option<String> {
|
|
|
|
|
let value = mindmap_id.trim();
|
|
|
|
|
value
|
|
|
|
|
.strip_prefix("local-file:")
|
|
|
|
|
.or_else(|| value.strip_prefix("local:asset:"))
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|path| !path.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_relative_path_stays_in_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
relative_path: &str,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
let requested = Path::new(relative_path);
|
|
|
|
|
if requested.is_absolute()
|
|
|
|
|
|| requested
|
|
|
|
|
.components()
|
|
|
|
|
.any(|component| matches!(component, std::path::Component::ParentDir))
|
|
|
|
|
{
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_root_escape",
|
|
|
|
|
"本地思维导图路径不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let target = root.join(requested);
|
|
|
|
|
if !target.starts_with(root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_root_escape",
|
|
|
|
|
"本地思维导图路径不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(target)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_mindmap_file_name(mindmap_id: &str) -> String {
|
|
|
|
|
let sanitized = sanitize_file_name(mindmap_id, "mindmap");
|
|
|
|
|
if sanitized.to_ascii_lowercase().ends_with(".json") {
|
|
|
|
|
sanitized
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}.json", sanitize_file_stem(&sanitized, "mindmap"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_local_mindmap_file_name(file_name: &str) -> bool {
|
|
|
|
|
let trimmed = file_name.trim();
|
|
|
|
|
let lower = trimmed.to_ascii_lowercase();
|
|
|
|
|
lower.ends_with(".mindmap.json")
|
|
|
|
|
|| (trimmed.starts_with("思维导图") && lower.ends_with(".json"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_local_mindmap_path(
|
|
|
|
|
root: &Path,
|
|
|
|
|
metadata: &LocalFolderMetadata,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
mindmap_id: &str,
|
|
|
|
|
) -> Result<PathBuf, WebError> {
|
|
|
|
|
if let Some(relative_path) = local_mindmap_relative_path_from_id(mindmap_id) {
|
|
|
|
|
return ensure_relative_path_stays_in_root(root, &relative_path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let markdown_file =
|
|
|
|
|
find_markdown_by_page_id(root, metadata, document_id)?.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_markdown_not_found",
|
|
|
|
|
"找不到本地思维导图所属的 Markdown 页面",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let page_resource_dir =
|
|
|
|
|
markdown_page_resource_directory(&markdown_file.path).ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_bad_markdown_path",
|
|
|
|
|
"无法解析本地页面思维导图目录",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
Ok(page_resource_dir.join(local_mindmap_file_name(mindmap_id)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_mindmap_default_data() -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"data": {"uid": "root", "text": "KMIND"},
|
|
|
|
|
"children": []
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn read_local_mindmap_data(
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
mindmap_id: &str,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let metadata = load_local_folder_metadata(&canonical_root)?;
|
|
|
|
|
let target = resolve_local_mindmap_path(&canonical_root, &metadata, document_id, mindmap_id)?;
|
|
|
|
|
if !target.exists() {
|
|
|
|
|
return Ok(local_mindmap_default_data());
|
|
|
|
|
}
|
|
|
|
|
if !target.is_file() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_not_file",
|
|
|
|
|
"本地思维导图目标必须是文件",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let raw = fs::read_to_string(&target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_read_failed",
|
|
|
|
|
format!("无法读取本地思维导图 {}: {error}", target.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
serde_json::from_str(&raw).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_invalid",
|
|
|
|
|
format!("本地思维导图 JSON 损坏 {}: {error}", target.display()),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn write_local_mindmap_data(
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
mindmap_id: &str,
|
|
|
|
|
data: Value,
|
|
|
|
|
create_only: bool,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let metadata = load_local_folder_metadata(&canonical_root)?;
|
|
|
|
|
let target = resolve_local_mindmap_path(&canonical_root, &metadata, document_id, mindmap_id)?;
|
|
|
|
|
if !target.starts_with(&canonical_root) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_root_escape",
|
|
|
|
|
"本地思维导图路径不能越过 root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let parent = target.parent().ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_mindmap_bad_path", "无法解析本地思维导图父目录")
|
|
|
|
|
})?;
|
|
|
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_mindmap_create_dir_failed",
|
|
|
|
|
format!("无法创建本地思维导图目录 {}: {error}", parent.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !(create_only && target.exists()) {
|
|
|
|
|
write_json_atomic(&target, &data)?;
|
|
|
|
|
}
|
|
|
|
|
let relative_path = normalize_relative_path(&canonical_root, &target)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"mindmapId": mindmap_id,
|
|
|
|
|
"assetId": format!("local-file:{relative_path}"),
|
|
|
|
|
"relativePath": relative_path,
|
|
|
|
|
"fileName": target.file_name().and_then(|value| value.to_str()).unwrap_or("思维导图.json"),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
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}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let result = rename_local_markdown_page(&canonical_root, document_id, title)?;
|
2026-05-19 09:38:57 +08:00
|
|
|
let workspace_id = local_workspace_id(&canonical_root);
|
|
|
|
|
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
2026-05-20 10:43:38 +08:00
|
|
|
Ok(result)
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-19 09:38:57 +08:00
|
|
|
let result = match action {
|
2026-05-08 00:41:03 +08:00
|
|
|
"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),
|
2026-05-16 07:38:45 +08:00
|
|
|
"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}"),
|
|
|
|
|
)),
|
2026-05-19 09:38:57 +08:00
|
|
|
}?;
|
|
|
|
|
let workspace_id = local_workspace_id(&canonical_root);
|
|
|
|
|
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
|
|
|
|
Ok(result)
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
/// 生成新页面文件名主体。
|
|
|
|
|
/// 默认标题“新页面”会追加本地时间 HHMMSS,例如“新页面061240”。
|
|
|
|
|
/// 候选名必须同时检查 `.md` 文件和同名目录,避免页面 bundle 错配。
|
|
|
|
|
fn determine_page_stem(directory: &Path, title: &str) -> String {
|
|
|
|
|
let base = if title.trim() == "新页面" {
|
|
|
|
|
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
|
|
|
|
|
format!(
|
|
|
|
|
"新页面{:02}{:02}{:02}",
|
|
|
|
|
now.hour(),
|
|
|
|
|
now.minute(),
|
|
|
|
|
now.second()
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
sanitize_file_stem(title, "新页面")
|
|
|
|
|
};
|
|
|
|
|
// 同时检查 .md 文件和同名目录,确保页面 bundle 使用同一个 stem。
|
|
|
|
|
let mut candidate = base.clone();
|
|
|
|
|
let mut index = 2u32;
|
|
|
|
|
while directory.join(format!("{}.md", candidate)).exists()
|
|
|
|
|
|| directory.join(&candidate).exists()
|
|
|
|
|
{
|
|
|
|
|
candidate = format!("{}-{}", base, index);
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
candidate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn determine_page_stem_for_rename(
|
|
|
|
|
directory: &Path,
|
|
|
|
|
title: &str,
|
|
|
|
|
current_file: &Path,
|
|
|
|
|
current_directory: Option<&Path>,
|
|
|
|
|
) -> String {
|
|
|
|
|
let base = sanitize_file_stem(title, "新页面");
|
|
|
|
|
let mut candidate = base.clone();
|
|
|
|
|
let mut index = 2u32;
|
|
|
|
|
loop {
|
|
|
|
|
let file_candidate = directory.join(format!("{candidate}.md"));
|
|
|
|
|
let directory_candidate = directory.join(&candidate);
|
|
|
|
|
let file_conflict = file_candidate.exists() && file_candidate != current_file;
|
|
|
|
|
let directory_conflict = directory_candidate.exists()
|
|
|
|
|
&& current_directory
|
|
|
|
|
.map(|current| directory_candidate != current)
|
|
|
|
|
.unwrap_or(true);
|
|
|
|
|
if !file_conflict && !directory_conflict {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
candidate = format!("{}-{}", base, index);
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn determine_page_bundle_stem_for_rename(
|
|
|
|
|
directory: &Path,
|
|
|
|
|
title: &str,
|
|
|
|
|
current_bundle_directory: &Path,
|
|
|
|
|
) -> String {
|
|
|
|
|
let base = sanitize_file_stem(title, "新页面");
|
|
|
|
|
let mut candidate = base.clone();
|
|
|
|
|
let mut index = 2u32;
|
|
|
|
|
loop {
|
|
|
|
|
let bundle_candidate = directory.join(&candidate);
|
|
|
|
|
let loose_markdown_candidate = directory.join(format!("{candidate}.md"));
|
|
|
|
|
let bundle_conflict =
|
|
|
|
|
bundle_candidate.exists() && bundle_candidate != current_bundle_directory;
|
|
|
|
|
let loose_markdown_conflict = loose_markdown_candidate.exists();
|
|
|
|
|
if !bundle_conflict && !loose_markdown_conflict {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
candidate = format!("{}-{}", base, index);
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn markdown_file_stem(path: &Path) -> Option<String> {
|
|
|
|
|
path.file_stem()
|
|
|
|
|
.and_then(|stem| stem.to_str())
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|stem| !stem.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_nested_page_bundle_markdown(path: &Path) -> bool {
|
|
|
|
|
let Some(parent_name) = path
|
|
|
|
|
.parent()
|
|
|
|
|
.and_then(|parent| parent.file_name())
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
markdown_file_stem(path)
|
|
|
|
|
.map(|stem| stem == parent_name)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn nested_bundle_main_markdown(directory: &Path) -> Option<PathBuf> {
|
|
|
|
|
let name = directory.file_name().and_then(|value| value.to_str())?;
|
|
|
|
|
let candidate = directory.join(format!("{name}.md"));
|
|
|
|
|
if candidate.is_file() {
|
|
|
|
|
Some(candidate)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn markdown_page_bundle_directory(markdown_path: &Path) -> Option<PathBuf> {
|
|
|
|
|
let parent = markdown_path.parent()?;
|
|
|
|
|
let stem = markdown_file_stem(markdown_path)?;
|
|
|
|
|
if parent
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.map(|name| name == stem)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
return Some(parent.to_path_buf());
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn markdown_page_resource_directory(markdown_path: &Path) -> Option<PathBuf> {
|
|
|
|
|
let parent = markdown_path.parent()?;
|
|
|
|
|
let stem = markdown_file_stem(markdown_path)?;
|
|
|
|
|
if parent
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.map(|name| name == stem)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
return Some(parent.to_path_buf());
|
|
|
|
|
}
|
|
|
|
|
let sibling_dir = parent.join(&stem);
|
|
|
|
|
if sibling_dir.is_dir() {
|
|
|
|
|
Some(sibling_dir)
|
|
|
|
|
} else {
|
|
|
|
|
Some(parent.join(sanitize_file_name(&stem, "page")))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
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)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let stem = determine_page_stem(&parent_directory, title);
|
|
|
|
|
let dir_target = parent_directory.join(&stem);
|
|
|
|
|
let target = dir_target.join(format!("{}.md", stem));
|
2026-05-08 00:41:03 +08:00
|
|
|
let relative_path = normalize_relative_path(root, &target)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let page_id = local_markdown_path_page_id(&relative_path);
|
|
|
|
|
let display_title = file_stem_title(&format!("{stem}.md"));
|
|
|
|
|
let markdown = "";
|
|
|
|
|
fs::create_dir_all(&dir_target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法创建本地页面目录 {}: {error}", dir_target.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-08 00:41:03 +08:00
|
|
|
fs::write(&target, markdown).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法创建本地 Markdown 文件 {}: {error}", target.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": page_id,
|
|
|
|
|
"documentId": page_id,
|
2026-05-20 10:43:38 +08:00
|
|
|
"title": display_title,
|
2026-05-08 00:41:03 +08:00
|
|
|
"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> {
|
2026-05-20 10:43:38 +08:00
|
|
|
let metadata = load_local_folder_metadata(root)?;
|
2026-05-08 00:41:03 +08:00
|
|
|
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 父目录")
|
|
|
|
|
})?;
|
2026-05-20 10:43:38 +08:00
|
|
|
if is_nested_page_bundle_markdown(&markdown_file.path) {
|
|
|
|
|
return rename_nested_bundle_markdown_page(root, document_id, &markdown_file, title);
|
|
|
|
|
}
|
|
|
|
|
let new_stem = determine_page_stem_for_rename(parent, title, &markdown_file.path, None);
|
|
|
|
|
let target = parent.join(format!("{new_stem}.md"));
|
|
|
|
|
if target.exists() && target != markdown_file.path {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"目标 Markdown 文件 {} 已存在,禁止覆盖重命名",
|
|
|
|
|
target.display()
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
let old_relative_path = markdown_file.relative_path;
|
2026-05-20 10:43:38 +08:00
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
fs::rename(&markdown_file.path, &target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法重命名本地 Markdown 文件 {}: {error}",
|
|
|
|
|
markdown_file.path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-20 10:43:38 +08:00
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
let new_relative_path = normalize_relative_path(root, &target)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let new_document_id = local_markdown_path_page_id(&new_relative_path);
|
2026-05-08 00:41:03 +08:00
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
2026-05-20 10:43:38 +08:00
|
|
|
"id": new_document_id,
|
|
|
|
|
"documentId": new_document_id,
|
|
|
|
|
"title": file_stem_title(&format!("{new_stem}.md")),
|
2026-05-08 00:41:03 +08:00
|
|
|
"relativePath": new_relative_path,
|
2026-05-20 10:43:38 +08:00
|
|
|
"previousDocumentId": document_id,
|
|
|
|
|
"previousRelativePath": old_relative_path,
|
|
|
|
|
"action": "rename",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rename_nested_bundle_markdown_page(
|
|
|
|
|
root: &Path,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
markdown_file: &LocalFolderEntry,
|
|
|
|
|
title: &str,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let bundle_dir = markdown_file.path.parent().ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_tree_command_failed", "无法解析页面目录")
|
|
|
|
|
})?;
|
|
|
|
|
if bundle_dir == root {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
"页面目录不能是 workspace root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let bundle_parent = bundle_dir.parent().ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_tree_command_failed", "无法解析页面目录父级")
|
|
|
|
|
})?;
|
|
|
|
|
let new_stem = determine_page_bundle_stem_for_rename(bundle_parent, title, bundle_dir);
|
|
|
|
|
let new_bundle_dir = bundle_parent.join(&new_stem);
|
|
|
|
|
let new_markdown = new_bundle_dir.join(format!("{new_stem}.md"));
|
|
|
|
|
if new_bundle_dir.exists() && new_bundle_dir != bundle_dir {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"目标页面目录 {} 已存在,禁止覆盖重命名",
|
|
|
|
|
new_bundle_dir.display()
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let old_relative_path = markdown_file.relative_path.clone();
|
|
|
|
|
if new_bundle_dir == bundle_dir {
|
|
|
|
|
if new_markdown != markdown_file.path {
|
|
|
|
|
fs::rename(&markdown_file.path, &new_markdown).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法重命名本地 Markdown 文件 {}: {error}",
|
|
|
|
|
markdown_file.path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
fs::rename(bundle_dir, &new_bundle_dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法重命名本地页面目录 {}: {error}", bundle_dir.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let moved_old_markdown = new_bundle_dir.join(
|
|
|
|
|
markdown_file
|
|
|
|
|
.path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("page.md"),
|
|
|
|
|
);
|
|
|
|
|
if moved_old_markdown != new_markdown {
|
|
|
|
|
fs::rename(&moved_old_markdown, &new_markdown).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法重命名本地 Markdown 文件 {}: {error}",
|
|
|
|
|
moved_old_markdown.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let new_relative_path = normalize_relative_path(root, &new_markdown)?;
|
|
|
|
|
let new_document_id = local_markdown_path_page_id(&new_relative_path);
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": new_document_id,
|
|
|
|
|
"documentId": new_document_id,
|
|
|
|
|
"title": file_stem_title(&format!("{new_stem}.md")),
|
|
|
|
|
"relativePath": new_relative_path,
|
|
|
|
|
"previousDocumentId": document_id,
|
|
|
|
|
"previousRelativePath": old_relative_path,
|
2026-05-08 00:41:03 +08:00
|
|
|
"action": "rename",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rename_local_directory(root: &Path, directory: &Path, title: &str) -> Result<Value, WebError> {
|
|
|
|
|
if directory == root {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
"不能重命名本地 workspace root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let parent = directory.parent().ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_tree_command_failed", "无法解析文件夹父目录")
|
|
|
|
|
})?;
|
|
|
|
|
let target = next_available_directory_path(parent, &sanitize_file_stem(title, "文件夹"));
|
|
|
|
|
fs::rename(directory, &target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法重命名本地文件夹 {}: {error}", directory.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let new_relative_path = normalize_relative_path(root, &target)?;
|
|
|
|
|
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> {
|
2026-05-20 10:43:38 +08:00
|
|
|
let metadata = load_local_folder_metadata(root)?;
|
2026-05-08 00:41:03 +08:00
|
|
|
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()
|
|
|
|
|
};
|
2026-05-20 10:43:38 +08:00
|
|
|
let copy_stem = sanitize_file_stem(copy_stem, "页面");
|
|
|
|
|
let target;
|
|
|
|
|
if let Some(source_bundle) = markdown_page_bundle_directory(&markdown_file.path) {
|
|
|
|
|
let target_bundle = next_available_directory_path(&target_directory, ©_stem);
|
|
|
|
|
let target_stem = target_bundle
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or(©_stem)
|
|
|
|
|
.to_string();
|
|
|
|
|
target = target_bundle.join(format!("{target_stem}.md"));
|
|
|
|
|
copy_directory_recursively(&source_bundle, &target_bundle).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法复制本地页面目录 {} 到 {}: {error}",
|
|
|
|
|
source_bundle.display(),
|
|
|
|
|
target_bundle.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let copied_main = target_bundle.join(
|
|
|
|
|
markdown_file
|
|
|
|
|
.path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("page.md"),
|
|
|
|
|
);
|
|
|
|
|
if copied_main != target {
|
|
|
|
|
fs::rename(&copied_main, &target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法重命名复制后的 Markdown 文件 {}: {error}",
|
|
|
|
|
copied_main.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
target = next_available_path(&target_directory, ©_stem, "md");
|
|
|
|
|
let source_markdown = fs::read_to_string(&markdown_file.path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法读取待复制 Markdown 文件 {}: {error}",
|
|
|
|
|
markdown_file.path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
fs::write(&target, source_markdown).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法复制本地 Markdown 文件到 {}: {error}", target.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
let new_relative_path = normalize_relative_path(root, &target)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let new_page_id = local_markdown_path_page_id(&new_relative_path);
|
2026-05-08 00:41:03 +08:00
|
|
|
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> {
|
2026-05-20 10:43:38 +08:00
|
|
|
let metadata = load_local_folder_metadata(root)?;
|
2026-05-08 00:41:03 +08:00
|
|
|
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)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
if let Some(bundle_dir) = markdown_page_bundle_directory(&markdown_file.path) {
|
|
|
|
|
return move_local_markdown_bundle(
|
|
|
|
|
root,
|
|
|
|
|
document_id,
|
|
|
|
|
&markdown_file,
|
|
|
|
|
&bundle_dir,
|
|
|
|
|
&target_directory,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
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)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let new_document_id = local_markdown_path_page_id(&new_relative_path);
|
2026-05-08 00:41:03 +08:00
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
2026-05-20 10:43:38 +08:00
|
|
|
"id": new_document_id,
|
|
|
|
|
"documentId": new_document_id,
|
2026-05-08 00:41:03 +08:00
|
|
|
"relativePath": new_relative_path,
|
2026-05-20 10:43:38 +08:00
|
|
|
"previousDocumentId": document_id,
|
|
|
|
|
"previousRelativePath": old_relative_path,
|
|
|
|
|
"action": "move",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn move_local_markdown_bundle(
|
|
|
|
|
root: &Path,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
markdown_file: &LocalFolderEntry,
|
|
|
|
|
bundle_dir: &Path,
|
|
|
|
|
target_directory: &Path,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
if bundle_dir.parent() == Some(target_directory) {
|
|
|
|
|
return Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": document_id,
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"relativePath": markdown_file.relative_path,
|
|
|
|
|
"action": "move",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
if target_directory == bundle_dir || target_directory.starts_with(bundle_dir) {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
"禁止把本地页面移动到自身或后代",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let stem = bundle_dir
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.map(|name| sanitize_file_stem(name, "页面"))
|
|
|
|
|
.unwrap_or_else(|| "页面".to_string());
|
|
|
|
|
let target_bundle = next_available_directory_path(target_directory, &stem);
|
|
|
|
|
fs::rename(bundle_dir, &target_bundle).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法移动本地页面目录 {}: {error}", bundle_dir.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let main_file_name = markdown_file
|
|
|
|
|
.path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("page.md");
|
|
|
|
|
let moved_markdown = target_bundle.join(main_file_name);
|
|
|
|
|
let target_stem = target_bundle
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or(&stem);
|
|
|
|
|
let target_markdown = target_bundle.join(format!("{target_stem}.md"));
|
|
|
|
|
if moved_markdown != target_markdown {
|
|
|
|
|
fs::rename(&moved_markdown, &target_markdown).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法重命名移动后的 Markdown 文件 {}: {error}",
|
|
|
|
|
moved_markdown.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
let new_relative_path = normalize_relative_path(root, &target_markdown)?;
|
|
|
|
|
let new_document_id = local_markdown_path_page_id(&new_relative_path);
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": new_document_id,
|
|
|
|
|
"documentId": new_document_id,
|
|
|
|
|
"relativePath": new_relative_path,
|
|
|
|
|
"previousDocumentId": document_id,
|
|
|
|
|
"previousRelativePath": markdown_file.relative_path,
|
2026-05-08 00:41:03 +08:00
|
|
|
"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",
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-20 10:43:38 +08:00
|
|
|
let metadata = load_local_folder_metadata(root)?;
|
2026-05-08 00:41:03 +08:00
|
|
|
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);
|
|
|
|
|
fs::rename(directory, &target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法移动本地文件夹 {}: {error}", directory.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let new_relative_path = normalize_relative_path(root, &target)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": local_directory_group_id(&new_relative_path),
|
|
|
|
|
"relativePath": new_relative_path,
|
|
|
|
|
"action": "move",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
2026-05-20 10:43:38 +08:00
|
|
|
if let Some(directory) = resolve_local_directory_id(root, entry_id)? {
|
|
|
|
|
return trash_local_directory(root, entry_id, &directory);
|
|
|
|
|
}
|
2026-05-16 07:38:45 +08:00
|
|
|
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)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
if find_local_directory_trash_entry_key(&metadata, entry_id).is_some() {
|
|
|
|
|
return restore_local_directory(root, entry_id);
|
|
|
|
|
}
|
2026-05-16 07:38:45 +08:00
|
|
|
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)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
if find_local_directory_trash_entry_key(&metadata, entry_id).is_some() {
|
|
|
|
|
return purge_local_directory(root, entry_id);
|
|
|
|
|
}
|
2026-05-16 07:38:45 +08:00
|
|
|
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()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let bundle_dir = markdown_page_bundle_directory(&markdown_file.path);
|
|
|
|
|
let (source_path, target, resource_kind) = if let Some(bundle_dir) = bundle_dir.as_ref() {
|
|
|
|
|
if bundle_dir == root {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
"不能删除本地 workspace root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let directory_name = bundle_dir
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("page");
|
|
|
|
|
(
|
|
|
|
|
bundle_dir.clone(),
|
|
|
|
|
next_available_directory_path(&trash_dir, directory_name),
|
|
|
|
|
"markdown_bundle",
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
let file_name = markdown_file
|
|
|
|
|
.path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("page.md");
|
|
|
|
|
(
|
|
|
|
|
markdown_file.path.clone(),
|
|
|
|
|
next_available_path(&trash_dir, &file_stem_title(file_name), "md"),
|
|
|
|
|
"markdown",
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
fs::rename(&source_path, &target).map_err(|error| {
|
2026-05-08 00:41:03 +08:00
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
2026-05-20 10:43:38 +08:00
|
|
|
format!("无法移动到本地回收站 {}: {error}", source_path.display()),
|
2026-05-08 00:41:03 +08:00
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let original_relative_path = if let Some(bundle_dir) = bundle_dir.as_ref() {
|
|
|
|
|
normalize_relative_path(root, bundle_dir)?
|
|
|
|
|
} else {
|
|
|
|
|
markdown_file.relative_path.clone()
|
|
|
|
|
};
|
2026-05-08 00:41:03 +08:00
|
|
|
let trash_relative_path = normalize_relative_path(root, &target)?;
|
|
|
|
|
metadata.trash_entries.insert(
|
|
|
|
|
document_id.to_string(),
|
|
|
|
|
LocalTrashEntry {
|
|
|
|
|
document_id: document_id.to_string(),
|
2026-05-20 10:43:38 +08:00
|
|
|
resource_kind: resource_kind.to_string(),
|
2026-05-16 07:38:45 +08:00
|
|
|
resource_scope: "local_folder".to_string(),
|
2026-05-20 10:43:38 +08:00
|
|
|
original_file_path: original_relative_path.clone(),
|
2026-05-16 07:38:45 +08:00
|
|
|
trashed_file_path: trash_relative_path.clone(),
|
|
|
|
|
trash_entry_id: document_id.to_string(),
|
2026-05-20 10:43:38 +08:00
|
|
|
original_relative_path,
|
2026-05-08 00:41:03 +08:00
|
|
|
trash_relative_path: trash_relative_path.clone(),
|
|
|
|
|
deleted_at_ms: now_ms(),
|
2026-05-16 07:38:45 +08:00
|
|
|
archived_at: now_ms(),
|
|
|
|
|
purged_at: None,
|
2026-05-08 00:41:03 +08:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": document_id,
|
|
|
|
|
"documentId": document_id,
|
2026-05-20 10:43:38 +08:00
|
|
|
"resourceKind": resource_kind,
|
2026-05-08 00:41:03 +08:00
|
|
|
"trashPath": trash_relative_path,
|
|
|
|
|
"action": "delete",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
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-20 10:43:38 +08:00
|
|
|
fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Result<Value, WebError> {
|
|
|
|
|
if directory == root {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
"不能删除本地 workspace root",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let mut metadata = load_local_folder_metadata(root)?;
|
|
|
|
|
let relative_path = normalize_relative_path(root, directory)?;
|
|
|
|
|
if relative_path == ".mnote" || relative_path.starts_with(".mnote/") {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
"不能删除 MNote 元数据目录",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let trash_dir = root.join(".mnote").join("trash");
|
|
|
|
|
fs::create_dir_all(&trash_dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法创建本地回收站 {}: {error}", trash_dir.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let directory_name = directory
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("local-folder");
|
|
|
|
|
let target = next_available_directory_path(&trash_dir, directory_name);
|
|
|
|
|
fs::rename(directory, &target).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法移动本地文件夹到回收站 {}: {error}",
|
|
|
|
|
directory.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let trash_relative_path = normalize_relative_path(root, &target)?;
|
|
|
|
|
let trash_entry_id = local_directory_trash_entry_id(&relative_path);
|
|
|
|
|
let now = now_ms();
|
|
|
|
|
metadata.trash_entries.insert(
|
|
|
|
|
trash_entry_id.clone(),
|
|
|
|
|
LocalTrashEntry {
|
|
|
|
|
document_id: entry_id.to_string(),
|
|
|
|
|
resource_kind: "local_directory".to_string(),
|
|
|
|
|
resource_scope: "local_folder".to_string(),
|
|
|
|
|
original_file_path: relative_path.clone(),
|
|
|
|
|
trashed_file_path: trash_relative_path.clone(),
|
|
|
|
|
trash_entry_id: trash_entry_id.clone(),
|
|
|
|
|
original_relative_path: relative_path.clone(),
|
|
|
|
|
trash_relative_path: trash_relative_path.clone(),
|
|
|
|
|
deleted_at_ms: now,
|
|
|
|
|
archived_at: now,
|
|
|
|
|
purged_at: None,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": entry_id,
|
|
|
|
|
"documentId": entry_id,
|
|
|
|
|
"resourceKind": "local_directory",
|
|
|
|
|
"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)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
if trash_entry.resource_kind == "markdown_bundle" {
|
|
|
|
|
return restore_local_markdown_bundle(
|
|
|
|
|
root,
|
|
|
|
|
document_id,
|
|
|
|
|
&mut metadata,
|
|
|
|
|
trash_entry,
|
|
|
|
|
&trash_path,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
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)?;
|
2026-05-20 10:43:38 +08:00
|
|
|
let restored_document_id = local_markdown_path_page_id(&restored_relative_path);
|
2026-05-08 00:41:03 +08:00
|
|
|
metadata.trash_entries.remove(document_id);
|
|
|
|
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
2026-05-20 10:43:38 +08:00
|
|
|
"id": restored_document_id,
|
|
|
|
|
"documentId": restored_document_id,
|
2026-05-08 00:41:03 +08:00
|
|
|
"relativePath": restored_relative_path,
|
2026-05-20 10:43:38 +08:00
|
|
|
"previousDocumentId": document_id,
|
|
|
|
|
"action": "restore",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn restore_local_markdown_bundle(
|
|
|
|
|
root: &Path,
|
|
|
|
|
document_id: &str,
|
|
|
|
|
metadata: &mut LocalFolderMetadata,
|
|
|
|
|
trash_entry: LocalTrashEntry,
|
|
|
|
|
trash_path: &Path,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
if !trash_path.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_trash_entry_not_found",
|
|
|
|
|
"本地回收站页面目录不存在,无法恢复",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let original_bundle_path =
|
|
|
|
|
resolve_metadata_relative_path(root, &trash_entry.original_relative_path)?;
|
|
|
|
|
let parent = original_bundle_path.parent().ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_tree_command_failed", "无法解析页面恢复目标目录")
|
|
|
|
|
})?;
|
|
|
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法创建页面恢复目标目录 {}: {error}", parent.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let stem = original_bundle_path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.map(|name| sanitize_file_stem(name, "页面"))
|
|
|
|
|
.unwrap_or_else(|| "页面".to_string());
|
|
|
|
|
let target_bundle = if original_bundle_path.exists() {
|
|
|
|
|
next_available_directory_path(parent, &stem)
|
|
|
|
|
} else {
|
|
|
|
|
original_bundle_path
|
|
|
|
|
};
|
|
|
|
|
fs::rename(trash_path, &target_bundle).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法从本地回收站恢复页面目录 {}: {error}",
|
|
|
|
|
trash_path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let target_stem = target_bundle
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or(&stem);
|
|
|
|
|
let restored_markdown = target_bundle.join(format!("{target_stem}.md"));
|
|
|
|
|
if !restored_markdown.is_file() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_trash_entry_not_found",
|
|
|
|
|
"恢复后的页面目录缺少同名 Markdown 文件",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let restored_relative_path = normalize_relative_path(root, &restored_markdown)?;
|
|
|
|
|
let restored_document_id = local_markdown_path_page_id(&restored_relative_path);
|
|
|
|
|
metadata.trash_entries.remove(document_id);
|
|
|
|
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": restored_document_id,
|
|
|
|
|
"documentId": restored_document_id,
|
|
|
|
|
"relativePath": restored_relative_path,
|
|
|
|
|
"previousDocumentId": document_id,
|
2026-05-08 00:41:03 +08:00
|
|
|
"action": "restore",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
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-20 10:43:38 +08:00
|
|
|
fn restore_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
|
|
|
|
let mut metadata = load_local_folder_metadata(root)?;
|
|
|
|
|
let trash_key = find_local_directory_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_trash_entry_not_found",
|
|
|
|
|
"找不到要恢复的本地文件夹回收站记录",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let trash_entry = metadata
|
|
|
|
|
.trash_entries
|
|
|
|
|
.get(&trash_key)
|
|
|
|
|
.cloned()
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_trash_entry_not_found",
|
|
|
|
|
"找不到要恢复的本地文件夹回收站记录",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let original_relative_path = local_trash_original_path(&trash_entry);
|
|
|
|
|
let trash_relative_path = local_trash_file_path(&trash_entry);
|
|
|
|
|
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
|
|
|
|
if !trash_path.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_trash_entry_not_found",
|
|
|
|
|
"本地文件夹回收站目录不存在,无法恢复",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let original_path = resolve_metadata_relative_path(root, &original_relative_path)?;
|
|
|
|
|
if original_path.exists() {
|
|
|
|
|
return Err(WebError::new(
|
|
|
|
|
StatusCode::CONFLICT,
|
|
|
|
|
"local_directory_restore_conflict",
|
|
|
|
|
"本地文件夹原路径已存在,恢复会覆盖用户文件",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let parent = original_path.parent().ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_tree_command_failed", "无法解析文件夹恢复目标目录")
|
|
|
|
|
})?;
|
|
|
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法创建文件夹恢复目标目录 {}: {error}", parent.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
fs::rename(&trash_path, &original_path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法从本地回收站恢复文件夹 {}: {error}",
|
|
|
|
|
trash_path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
metadata.trash_entries.remove(&trash_key);
|
|
|
|
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": entry_id,
|
|
|
|
|
"documentId": entry_id,
|
|
|
|
|
"resourceKind": "local_directory",
|
|
|
|
|
"resourceScope": "local_folder",
|
|
|
|
|
"relativePath": normalize_relative_path(root, &original_path)?,
|
|
|
|
|
"trashEntryId": trash_key,
|
|
|
|
|
"action": "restore",
|
|
|
|
|
"canonicalCommand": "tree.resource.restore",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
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)? {
|
2026-05-20 10:43:38 +08:00
|
|
|
if let Some(bundle_dir) = markdown_page_bundle_directory(&markdown_file.path) {
|
|
|
|
|
fs::remove_dir_all(&bundle_dir).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!("无法永久删除本地页面目录 {}: {error}", bundle_dir.display()),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
} else {
|
|
|
|
|
fs::remove_file(&markdown_file.path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法永久删除本地 Markdown 文件 {}: {error}",
|
|
|
|
|
markdown_file.path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
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() {
|
2026-05-20 10:43:38 +08:00
|
|
|
let remove_result = if trash_path.is_dir() {
|
|
|
|
|
fs::remove_dir_all(&trash_path)
|
|
|
|
|
} else {
|
|
|
|
|
fs::remove_file(&trash_path)
|
|
|
|
|
};
|
|
|
|
|
remove_result.map_err(|error| {
|
2026-05-08 00:41:03 +08:00
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
2026-05-20 10:43:38 +08:00
|
|
|
"无法永久删除本地回收站对象 {}: {error}",
|
2026-05-08 00:41:03 +08:00
|
|
|
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",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn purge_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
|
|
|
|
let mut metadata = load_local_folder_metadata(root)?;
|
|
|
|
|
let trash_key = find_local_directory_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_trash_entry_not_found",
|
|
|
|
|
"找不到要永久删除的本地文件夹回收站记录",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let trash_entry = metadata.trash_entries.remove(&trash_key).ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_trash_entry_not_found",
|
|
|
|
|
"找不到要永久删除的本地文件夹回收站记录",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let trash_relative_path = local_trash_file_path(&trash_entry);
|
|
|
|
|
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
|
|
|
|
if trash_path.exists() {
|
|
|
|
|
fs::remove_dir_all(&trash_path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_tree_command_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法永久删除本地回收站文件夹 {}: {error}",
|
|
|
|
|
trash_path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": entry_id,
|
|
|
|
|
"documentId": entry_id,
|
|
|
|
|
"resourceKind": "local_directory",
|
|
|
|
|
"resourceScope": "local_folder",
|
|
|
|
|
"trashEntryId": trash_key,
|
|
|
|
|
"action": "purge",
|
|
|
|
|
"canonicalCommand": "tree.resource.purge",
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
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
|
2026-05-20 11:19:42 +08:00
|
|
|
.strip_prefix("local-file:")
|
2026-05-08 00:41:03 +08:00
|
|
|
.or_else(|| trimmed.strip_prefix("local:asset:"))
|
2026-05-20 11:19:42 +08:00
|
|
|
.or_else(|| trimmed.strip_prefix("local:node:"))
|
2026-05-08 00:41:03 +08:00
|
|
|
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",
|
|
|
|
|
"本地文件不存在",
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
fn local_file_trash_entry_id(relative_path: &str) -> String {
|
|
|
|
|
format!("local-file:{relative_path}")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn local_directory_trash_entry_id(relative_path: &str) -> String {
|
|
|
|
|
format!("local-dir-trash:{relative_path}")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
fn is_local_file_trash_entry(entry: &LocalTrashEntry) -> bool {
|
|
|
|
|
entry.resource_kind == "local_file" || entry.trash_entry_id.starts_with("local-file:")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn is_local_directory_trash_entry(entry: &LocalTrashEntry) -> bool {
|
|
|
|
|
entry.resource_kind == "local_directory" || entry.trash_entry_id.starts_with("local-dir-trash:")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:38:45 +08:00
|
|
|
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-20 10:43:38 +08:00
|
|
|
fn find_local_directory_trash_entry_key(
|
|
|
|
|
metadata: &LocalFolderMetadata,
|
|
|
|
|
entry_id: &str,
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
let trimmed = entry_id.trim();
|
|
|
|
|
if metadata
|
|
|
|
|
.trash_entries
|
|
|
|
|
.get(trimmed)
|
|
|
|
|
.map(is_local_directory_trash_entry)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
return Some(trimmed.to_string());
|
|
|
|
|
}
|
|
|
|
|
let relative_path = if let Some(encoded) = trimmed.strip_prefix("local-dir:") {
|
|
|
|
|
decode_local_id_segment(encoded).ok()?
|
|
|
|
|
} else {
|
|
|
|
|
trimmed
|
|
|
|
|
.strip_prefix("local:node:")
|
|
|
|
|
.or_else(|| trimmed.strip_prefix("local:folder:"))
|
|
|
|
|
.unwrap_or(trimmed)
|
|
|
|
|
.to_string()
|
|
|
|
|
};
|
|
|
|
|
let expected_key = local_directory_trash_entry_id(&relative_path);
|
|
|
|
|
if metadata
|
|
|
|
|
.trash_entries
|
|
|
|
|
.get(&expected_key)
|
|
|
|
|
.map(is_local_directory_trash_entry)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
return Some(expected_key);
|
|
|
|
|
}
|
|
|
|
|
metadata
|
|
|
|
|
.trash_entries
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|(_, entry)| {
|
|
|
|
|
is_local_directory_trash_entry(entry)
|
|
|
|
|
&& local_trash_original_path(entry) == relative_path
|
|
|
|
|
})
|
|
|
|
|
.map(|(key, _)| key.clone())
|
|
|
|
|
}
|
|
|
|
|
|
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_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"))?,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) fn load_local_trash_entries(
|
|
|
|
|
root_uri: &str,
|
|
|
|
|
) -> Result<BTreeMap<String, LocalTrashEntry>, WebError> {
|
|
|
|
|
let root_path = parse_file_root_uri(root_uri)?;
|
|
|
|
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_folder_unavailable",
|
|
|
|
|
format!("无法访问本地文件夹: {error}"),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if !canonical_root.is_dir() {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"local_folder_not_directory",
|
|
|
|
|
"本地 rootUri 必须指向目录",
|
|
|
|
|
));
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
2026-05-20 10:43:38 +08:00
|
|
|
Ok(load_local_folder_metadata(&canonical_root)?.trash_entries)
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-20 10:43:38 +08:00
|
|
|
Ok(local_markdown_path_page_id(relative_path))
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-19 11:11:08 +08:00
|
|
|
workspace_id: &str,
|
2026-05-08 00:41:03 +08:00
|
|
|
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) {
|
2026-05-20 10:43:38 +08:00
|
|
|
Some(local_markdown_path_page_id(&entry.relative_path))
|
2026-05-08 00:41:03 +08:00
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
},
|
|
|
|
|
capabilities: local_entry_capabilities(&entry),
|
2026-05-19 11:11:08 +08:00
|
|
|
workspace_id: workspace_id.to_string(),
|
|
|
|
|
root_source_uri: root_source_uri.to_string(),
|
2026-05-08 00:41:03 +08:00
|
|
|
});
|
|
|
|
|
if entry.is_dir && !entry.is_symlink {
|
|
|
|
|
scan_directory(
|
|
|
|
|
root,
|
|
|
|
|
&entry.path,
|
|
|
|
|
Some(node_id),
|
|
|
|
|
depth + 1,
|
|
|
|
|
root_source_uri,
|
2026-05-19 11:11:08 +08:00
|
|
|
workspace_id,
|
2026-05-08 00:41:03 +08:00
|
|
|
metadata,
|
|
|
|
|
rows,
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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,
|
2026-05-20 10:43:38 +08:00
|
|
|
skip_markdown_relative_path: Option<String>,
|
2026-05-08 00:41:03 +08:00
|
|
|
metadata: &LocalFolderMetadata,
|
|
|
|
|
rows: &mut Vec<LocalFolderRow>,
|
2026-05-19 11:11:08 +08:00
|
|
|
workspace_id: &str,
|
|
|
|
|
root_source_uri: &str,
|
2026-05-08 00:41:03 +08:00
|
|
|
) -> 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() {
|
2026-05-20 10:43:38 +08:00
|
|
|
if skip_markdown_relative_path
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|skip| skip == entry.relative_path)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
if entry.is_dir && !entry.is_symlink {
|
2026-05-20 10:43:38 +08:00
|
|
|
if let Some(nested_main) = nested_bundle_main_markdown(&entry.path) {
|
|
|
|
|
let nested_relative = normalize_relative_path(root, &nested_main)?;
|
|
|
|
|
let markdown = fs::read_to_string(&nested_main).unwrap_or_default();
|
|
|
|
|
let parsed = parse_markdown_page(
|
|
|
|
|
&markdown,
|
|
|
|
|
nested_main
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("page.md"),
|
|
|
|
|
);
|
|
|
|
|
let page_id = local_markdown_path_page_id(&nested_relative);
|
|
|
|
|
let mut child_rows = Vec::new();
|
|
|
|
|
let child_contains_markdown = scan_markdown_page_tree(
|
|
|
|
|
root,
|
|
|
|
|
&entry.path,
|
|
|
|
|
Some(page_id.clone()),
|
|
|
|
|
depth + 1,
|
|
|
|
|
Some(nested_relative.clone()),
|
|
|
|
|
metadata,
|
|
|
|
|
&mut child_rows,
|
|
|
|
|
workspace_id,
|
|
|
|
|
root_source_uri,
|
|
|
|
|
)?;
|
|
|
|
|
let child_count = child_rows.len() as u32;
|
|
|
|
|
directory_rows.push(LocalFolderRow {
|
|
|
|
|
node_id: page_id.clone(),
|
|
|
|
|
row_id: format!("local:page:{nested_relative}"),
|
|
|
|
|
parent_node_id: parent_node_id.clone(),
|
|
|
|
|
title: parsed.title,
|
|
|
|
|
depth,
|
|
|
|
|
position: position as u32,
|
|
|
|
|
row_kind: "document".to_string(),
|
|
|
|
|
icon_hint: "markdown".to_string(),
|
|
|
|
|
relative_path: nested_relative,
|
|
|
|
|
source_uri: file_uri_for_path(&nested_main),
|
|
|
|
|
child_count,
|
|
|
|
|
expandable: child_contains_markdown,
|
|
|
|
|
expanded_by_default: child_contains_markdown && depth < 2,
|
|
|
|
|
document_id: Some(page_id),
|
|
|
|
|
capabilities: local_entry_capabilities(&entry),
|
|
|
|
|
workspace_id: workspace_id.to_string(),
|
|
|
|
|
root_source_uri: root_source_uri.to_string(),
|
|
|
|
|
});
|
|
|
|
|
directory_rows.extend(child_rows);
|
|
|
|
|
contains_markdown = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// VSCode 风格 loose Markdown 也可作为页面节点管理;
|
|
|
|
|
// 若同级存在同名目录,该目录中的 Markdown 作为该页面的子页面投影。
|
|
|
|
|
let sibling_md = directory.join(format!("{}.md", entry.file_name));
|
|
|
|
|
let has_sibling_md =
|
|
|
|
|
sibling_md.exists() && sibling_md.starts_with(root) && sibling_md.is_file();
|
|
|
|
|
let parent_for_children = if has_sibling_md {
|
|
|
|
|
let sibling_relative = normalize_relative_path(root, &sibling_md)?;
|
|
|
|
|
Some(local_markdown_path_page_id(&sibling_relative))
|
|
|
|
|
} else {
|
|
|
|
|
Some(local_directory_group_id(&entry.relative_path))
|
|
|
|
|
};
|
2026-05-08 00:41:03 +08:00
|
|
|
let mut child_rows = Vec::new();
|
|
|
|
|
let child_contains_markdown = scan_markdown_page_tree(
|
|
|
|
|
root,
|
|
|
|
|
&entry.path,
|
2026-05-20 10:43:38 +08:00
|
|
|
parent_for_children,
|
2026-05-08 00:41:03 +08:00
|
|
|
depth + 1,
|
2026-05-20 10:43:38 +08:00
|
|
|
None,
|
2026-05-08 00:41:03 +08:00
|
|
|
metadata,
|
|
|
|
|
&mut child_rows,
|
2026-05-19 11:11:08 +08:00
|
|
|
workspace_id,
|
|
|
|
|
root_source_uri,
|
2026-05-08 00:41:03 +08:00
|
|
|
)?;
|
|
|
|
|
if child_contains_markdown {
|
2026-05-20 10:43:38 +08:00
|
|
|
if !has_sibling_md {
|
|
|
|
|
directory_rows.push(LocalFolderRow {
|
|
|
|
|
node_id: local_directory_group_id(&entry.relative_path),
|
|
|
|
|
row_id: format!("local:page-group:{}", entry.relative_path),
|
|
|
|
|
parent_node_id: parent_node_id.clone(),
|
|
|
|
|
title: entry.file_name.clone(),
|
|
|
|
|
depth,
|
|
|
|
|
position: position as u32,
|
|
|
|
|
row_kind: "document".to_string(),
|
|
|
|
|
icon_hint: "folder".to_string(),
|
|
|
|
|
relative_path: entry.relative_path.clone(),
|
|
|
|
|
source_uri: file_uri_for_path(&entry.path),
|
|
|
|
|
child_count: 0,
|
|
|
|
|
expandable: true,
|
|
|
|
|
expanded_by_default: depth < 2,
|
|
|
|
|
document_id: None,
|
|
|
|
|
capabilities: local_entry_capabilities(&entry),
|
|
|
|
|
workspace_id: workspace_id.to_string(),
|
|
|
|
|
root_source_uri: root_source_uri.to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
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);
|
2026-05-20 10:43:38 +08:00
|
|
|
let page_id = local_markdown_path_page_id(&entry.relative_path);
|
|
|
|
|
let md_stem = entry
|
|
|
|
|
.path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
let sibling_dir = directory.join(&md_stem);
|
|
|
|
|
let has_sibling_dir = sibling_dir.is_dir() && sibling_dir.starts_with(root);
|
2026-05-08 00:41:03 +08:00
|
|
|
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,
|
2026-05-20 10:43:38 +08:00
|
|
|
expandable: has_sibling_dir,
|
|
|
|
|
expanded_by_default: has_sibling_dir && depth < 2,
|
2026-05-08 00:41:03 +08:00
|
|
|
document_id: Some(page_id),
|
|
|
|
|
capabilities: local_entry_capabilities(&entry),
|
2026-05-19 11:11:08 +08:00
|
|
|
workspace_id: workspace_id.to_string(),
|
|
|
|
|
root_source_uri: root_source_uri.to_string(),
|
2026-05-08 00:41:03 +08:00
|
|
|
});
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-05-20 10:43:38 +08:00
|
|
|
let path_page_id = local_markdown_path_page_id(&entry.relative_path);
|
|
|
|
|
if path_page_id == document_id {
|
2026-05-08 00:41:03 +08:00
|
|
|
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());
|
|
|
|
|
}
|
2026-05-19 08:07:17 +08:00
|
|
|
if row.row_kind == "asset" && !row.relative_path.trim().is_empty() {
|
|
|
|
|
let asset_id = format!("local-file:{}", row.relative_path);
|
|
|
|
|
item["assetId"] = Value::String(asset_id.clone());
|
|
|
|
|
item["resourceMeta"]["assetId"] = Value::String(asset_id);
|
|
|
|
|
}
|
2026-05-19 11:11:08 +08:00
|
|
|
|
|
|
|
|
// Phase A1: 为 File/Page/Resource tree 输出统一 workspacePath
|
|
|
|
|
let object_kind = match row.row_kind.as_str() {
|
|
|
|
|
"markdown" | "document" => KernelObjectKind::Page,
|
|
|
|
|
"folder" | "index" => KernelObjectKind::Index,
|
|
|
|
|
"asset" => match row.icon_hint.as_str() {
|
|
|
|
|
"mindmap" => KernelObjectKind::Mindmap,
|
|
|
|
|
"onlyoffice" | "office" => KernelObjectKind::OnlyOffice,
|
|
|
|
|
_ => KernelObjectKind::Attachment,
|
|
|
|
|
},
|
|
|
|
|
_ => KernelObjectKind::Attachment,
|
|
|
|
|
};
|
|
|
|
|
let workspace_path = ObjectWorkspacePath {
|
|
|
|
|
workspace_id: row.workspace_id.clone(),
|
|
|
|
|
source_kind: WorkspaceSourceKind::LocalFolder,
|
|
|
|
|
root_uri: row.root_source_uri.clone(),
|
|
|
|
|
relative_path: row.relative_path.clone(),
|
|
|
|
|
object_identity: KernelObjectIdentity {
|
|
|
|
|
object_kind,
|
|
|
|
|
document_id: row.document_id.clone(),
|
|
|
|
|
block_id: None,
|
|
|
|
|
asset_id: None,
|
|
|
|
|
},
|
|
|
|
|
resource_kind: Some(row.row_kind.clone()),
|
|
|
|
|
};
|
|
|
|
|
if let Ok(path_value) = serde_json::to_value(&workspace_path) {
|
|
|
|
|
item["resourceMeta"]["workspacePath"] = path_value;
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn local_markdown_path_page_id(relative_path: &str) -> String {
|
2026-05-08 00:41:03 +08:00
|
|
|
format!("local-md:{}", encode_local_id_segment(relative_path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn page_options_from_metadata(value: &Value) -> PageOptions {
|
|
|
|
|
let mut options = PageOptions::default();
|
|
|
|
|
if let Some(wide_layout) = value
|
|
|
|
|
.get("wideLayout")
|
|
|
|
|
.or_else(|| value.get("wide_layout"))
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
{
|
|
|
|
|
options.wide_layout = wide_layout;
|
|
|
|
|
}
|
|
|
|
|
if let Some(small_text) = value
|
|
|
|
|
.get("smallText")
|
|
|
|
|
.or_else(|| value.get("small_text"))
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
{
|
|
|
|
|
options.small_text = small_text;
|
|
|
|
|
}
|
|
|
|
|
if let Some(layout_density) = value
|
|
|
|
|
.get("layoutDensity")
|
|
|
|
|
.or_else(|| value.get("layout_density"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|item| !item.is_empty())
|
|
|
|
|
{
|
|
|
|
|
options.layout_density = layout_density.to_string();
|
|
|
|
|
}
|
|
|
|
|
if let Some(show_toc) = value
|
|
|
|
|
.get("showToc")
|
|
|
|
|
.or_else(|| value.get("show_toc"))
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
{
|
|
|
|
|
options.show_toc = show_toc;
|
|
|
|
|
}
|
|
|
|
|
if let Some(show_structure) = value
|
|
|
|
|
.get("showStructure")
|
|
|
|
|
.or_else(|| value.get("show_structure"))
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
{
|
|
|
|
|
options.show_structure = show_structure;
|
|
|
|
|
}
|
|
|
|
|
if let Some(show_heading_numbers) = value
|
|
|
|
|
.get("showHeadingNumbers")
|
|
|
|
|
.or_else(|| value.get("show_heading_numbers"))
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
{
|
|
|
|
|
options.show_heading_numbers = show_heading_numbers;
|
|
|
|
|
}
|
|
|
|
|
if let Some(protect_editing) = value
|
|
|
|
|
.get("protectEditing")
|
|
|
|
|
.or_else(|| value.get("protect_editing"))
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
{
|
|
|
|
|
options.protect_editing = protect_editing;
|
|
|
|
|
}
|
|
|
|
|
options
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) fn local_workspace_id(root: &Path) -> String {
|
2026-05-19 08:07:17 +08:00
|
|
|
if let Ok(manifest) = load_local_workspace_manifest(root) {
|
|
|
|
|
if !manifest.workspace_id.trim().is_empty() {
|
|
|
|
|
return manifest.workspace_id;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
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();
|
2026-05-20 10:43:38 +08:00
|
|
|
if is_local_mindmap_file_name(&entry.file_name) {
|
|
|
|
|
"mindmap"
|
|
|
|
|
} else if lower.ends_with(".md") || lower.ends_with(".markdown") {
|
2026-05-08 00:41:03 +08:00
|
|
|
"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(),
|
2026-05-20 10:43:38 +08:00
|
|
|
Some(
|
|
|
|
|
"doc"
|
|
|
|
|
| "docx"
|
|
|
|
|
| "odt"
|
|
|
|
|
| "rtf"
|
|
|
|
|
| "ppt"
|
|
|
|
|
| "pptx"
|
|
|
|
|
| "odp"
|
|
|
|
|
| "xls"
|
|
|
|
|
| "xlsx"
|
|
|
|
|
| "ods"
|
|
|
|
|
| "csv"
|
|
|
|
|
)
|
2026-05-08 00:41:03 +08:00
|
|
|
) {
|
2026-05-20 10:43:38 +08:00
|
|
|
"office"
|
|
|
|
|
} else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) {
|
|
|
|
|
"book"
|
2026-05-08 00:41:03 +08:00
|
|
|
} 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
pub(crate) fn encode_local_id_segment(value: &str) -> String {
|
2026-05-08 00:41:03 +08:00
|
|
|
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 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn copy_directory_recursively(source: &Path, target: &Path) -> std::io::Result<()> {
|
|
|
|
|
fs::create_dir_all(target)?;
|
|
|
|
|
for entry in fs::read_dir(source)? {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
let source_path = entry.path();
|
|
|
|
|
let target_path = target.join(entry.file_name());
|
|
|
|
|
let metadata = fs::symlink_metadata(&source_path)?;
|
|
|
|
|
if metadata.file_type().is_symlink() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if metadata.is_dir() {
|
|
|
|
|
copy_directory_recursively(&source_path, &target_path)?;
|
|
|
|
|
} else if metadata.is_file() {
|
|
|
|
|
fs::copy(&source_path, &target_path)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
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()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
fn next_available_asset_path(directory: &Path, file_name: &str) -> PathBuf {
|
|
|
|
|
let first = directory.join(file_name);
|
|
|
|
|
if !first.exists() {
|
|
|
|
|
return first;
|
|
|
|
|
}
|
|
|
|
|
let path = Path::new(file_name);
|
|
|
|
|
let stem = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|value| value.to_str())
|
|
|
|
|
.unwrap_or(file_name);
|
|
|
|
|
let extension = path.extension().and_then(|value| value.to_str());
|
|
|
|
|
for index in 1..10_000 {
|
|
|
|
|
let candidate_name = if let Some(extension) = extension {
|
|
|
|
|
format!("{stem}-{index}.{extension}")
|
|
|
|
|
} else {
|
|
|
|
|
format!("{stem}-{index}")
|
|
|
|
|
};
|
|
|
|
|
let candidate = directory.join(candidate_name);
|
|
|
|
|
if !candidate.exists() {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
directory.join(format!("{stem}-{}", std::process::id()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_markdown_relative_asset_path(
|
|
|
|
|
markdown_dir: &Path,
|
|
|
|
|
asset_path: &Path,
|
|
|
|
|
) -> Result<String, WebError> {
|
|
|
|
|
let relative = asset_path.strip_prefix(markdown_dir).map_err(|_| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_asset_upload_relative_path_failed",
|
|
|
|
|
"本地资源文件不在 Markdown 文件目录内",
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
Ok(relative
|
|
|
|
|
.components()
|
|
|
|
|
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("/"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str {
|
|
|
|
|
let normalized_kind = kind.trim().to_ascii_lowercase();
|
|
|
|
|
if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") {
|
|
|
|
|
"image"
|
|
|
|
|
} else {
|
|
|
|
|
"file"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
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()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn editor_blocks_to_markdown_for_file(
|
|
|
|
|
content: &Value,
|
|
|
|
|
root: &Path,
|
|
|
|
|
markdown_path: &Path,
|
|
|
|
|
) -> String {
|
|
|
|
|
editor_blocks_to_markdown_with_rewrite(content, Some((root, markdown_path)))
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn editor_blocks_to_markdown_with_rewrite(
|
|
|
|
|
content: &Value,
|
|
|
|
|
local_file_context: Option<(&Path, &Path)>,
|
|
|
|
|
) -> String {
|
2026-05-08 00:41:03 +08:00
|
|
|
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
|
|
|
|
|
};
|
2026-05-20 10:43:38 +08:00
|
|
|
if text.is_empty()
|
|
|
|
|
&& !matches!(
|
|
|
|
|
block_type,
|
|
|
|
|
"divider" | "media" | "image" | "mindmap" | "table"
|
|
|
|
|
)
|
|
|
|
|
{
|
2026-05-08 00:41:03 +08:00
|
|
|
lines.push(String::new());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
match block_type {
|
2026-05-19 08:07:17 +08:00
|
|
|
"image" => {
|
|
|
|
|
let props = block.get("props").and_then(Value::as_object);
|
|
|
|
|
let src = props
|
|
|
|
|
.and_then(|props| props.get("src").or_else(|| props.get("sourcePath")))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.trim();
|
|
|
|
|
let alt = props
|
|
|
|
|
.and_then(|props| props.get("alt").or_else(|| props.get("title")))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or(text.as_str())
|
|
|
|
|
.trim();
|
|
|
|
|
if src.is_empty() {
|
|
|
|
|
lines.push(text);
|
|
|
|
|
} else {
|
2026-05-20 10:43:38 +08:00
|
|
|
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
|
|
|
|
|
.unwrap_or_else(|| src.to_string());
|
|
|
|
|
lines.push(format!(
|
|
|
|
|
"",
|
|
|
|
|
alt.replace(']', r"\]"),
|
|
|
|
|
markdown_link_target(&src)
|
|
|
|
|
));
|
2026-05-19 08:07:17 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
"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 {
|
2026-05-20 10:43:38 +08:00
|
|
|
let url = rewrite_local_open_url_to_markdown_relative(url, local_file_context)
|
|
|
|
|
.unwrap_or_else(|| url.to_string());
|
|
|
|
|
let label = if name.is_empty() { url.as_str() } else { name };
|
|
|
|
|
lines.push(format!("[{}]({})", label, markdown_link_target(&url)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"mindmap" => {
|
|
|
|
|
let props = block.get("props").and_then(Value::as_object);
|
|
|
|
|
let source = props
|
|
|
|
|
.and_then(|props| {
|
|
|
|
|
props
|
|
|
|
|
.get("sourcePath")
|
|
|
|
|
.or_else(|| props.get("mindmapId"))
|
|
|
|
|
.or_else(|| props.get("mindmap_id"))
|
|
|
|
|
})
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.trim();
|
|
|
|
|
let label = props
|
|
|
|
|
.and_then(|props| props.get("name").or_else(|| props.get("title")))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("思维导图")
|
|
|
|
|
.trim();
|
|
|
|
|
if source.is_empty() {
|
|
|
|
|
lines.push(text);
|
|
|
|
|
} else {
|
|
|
|
|
let source = rewrite_local_mindmap_source_to_markdown_relative(
|
|
|
|
|
source,
|
|
|
|
|
local_file_context,
|
|
|
|
|
)
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
if is_local_mindmap_file_name(source) || source.contains('/') {
|
|
|
|
|
source.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
local_mindmap_file_name(source)
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let label = if label.is_empty() {
|
|
|
|
|
"思维导图"
|
|
|
|
|
} else {
|
|
|
|
|
label
|
|
|
|
|
};
|
|
|
|
|
lines.push(format!("[{}]({})", label, markdown_link_target(&source)));
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn markdown_link_target(target: &str) -> String {
|
|
|
|
|
let trimmed = target.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return String::new();
|
|
|
|
|
}
|
|
|
|
|
if trimmed
|
|
|
|
|
.chars()
|
|
|
|
|
.any(|ch| ch.is_whitespace() || matches!(ch, '(' | ')' | '<' | '>'))
|
|
|
|
|
{
|
|
|
|
|
format!("<{}>", trimmed.replace('>', "%3E"))
|
|
|
|
|
} else {
|
|
|
|
|
trimmed.to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rewrite_local_mindmap_source_to_markdown_relative(
|
|
|
|
|
value: &str,
|
|
|
|
|
local_file_context: Option<(&Path, &Path)>,
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
if let Some(relative_path) = local_mindmap_relative_path_from_id(value) {
|
|
|
|
|
let (root, markdown_path) = local_file_context?;
|
|
|
|
|
let asset_path = root.canonicalize().ok()?.join(relative_path);
|
|
|
|
|
let markdown_dir = markdown_path.parent()?;
|
|
|
|
|
return normalize_markdown_relative_asset_path(markdown_dir, &asset_path).ok();
|
|
|
|
|
}
|
|
|
|
|
rewrite_local_open_url_to_markdown_relative(value, local_file_context)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rewrite_local_open_url_to_markdown_relative(
|
|
|
|
|
value: &str,
|
|
|
|
|
local_file_context: Option<(&Path, &Path)>,
|
|
|
|
|
) -> Option<String> {
|
|
|
|
|
let (root, markdown_path) = local_file_context?;
|
|
|
|
|
let parsed = Url::parse(value).ok()?;
|
|
|
|
|
if parsed.path() != "/api/local-folder/files/open" {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let root_uri = parsed
|
|
|
|
|
.query_pairs()
|
|
|
|
|
.find(|(key, _)| key == "rootUri")
|
|
|
|
|
.map(|(_, value)| value.into_owned())?;
|
|
|
|
|
let asset_relative_path = parsed
|
|
|
|
|
.query_pairs()
|
|
|
|
|
.find(|(key, _)| key == "path")
|
|
|
|
|
.map(|(_, value)| value.into_owned())?;
|
|
|
|
|
let parsed_root = parse_file_root_uri(&root_uri).ok()?.canonicalize().ok()?;
|
|
|
|
|
let canonical_root = root.canonicalize().ok()?;
|
|
|
|
|
if parsed_root != canonical_root {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let asset_path = canonical_root.join(asset_relative_path);
|
|
|
|
|
let markdown_dir = markdown_path.parent()?;
|
|
|
|
|
normalize_markdown_relative_asset_path(markdown_dir, &asset_path).ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn content_type_for_path(path: &Path) -> HeaderValue {
|
|
|
|
|
let extension = path
|
|
|
|
|
.extension()
|
|
|
|
|
.and_then(|value| value.to_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_ascii_lowercase();
|
|
|
|
|
let content_type = match extension.as_str() {
|
|
|
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
|
|
|
"png" => "image/png",
|
|
|
|
|
"gif" => "image/gif",
|
|
|
|
|
"webp" => "image/webp",
|
|
|
|
|
"svg" => "image/svg+xml; charset=utf-8",
|
|
|
|
|
"md" | "markdown" => "text/markdown; charset=utf-8",
|
|
|
|
|
"txt" | "log" => "text/plain; charset=utf-8",
|
|
|
|
|
"json" => "application/json; charset=utf-8",
|
|
|
|
|
"pdf" => "application/pdf",
|
|
|
|
|
"doc" => "application/msword",
|
|
|
|
|
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
|
|
|
"ppt" => "application/vnd.ms-powerpoint",
|
|
|
|
|
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
|
|
|
"xls" => "application/vnd.ms-excel",
|
|
|
|
|
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
"odt" => "application/vnd.oasis.opendocument.text",
|
|
|
|
|
"odp" => "application/vnd.oasis.opendocument.presentation",
|
|
|
|
|
"ods" => "application/vnd.oasis.opendocument.spreadsheet",
|
|
|
|
|
_ => "application/octet-stream",
|
|
|
|
|
};
|
|
|
|
|
HeaderValue::from_static(content_type)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
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| {
|
2026-05-08 10:57:24 +08:00
|
|
|
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();
|
|
|
|
|
}
|
2026-05-08 10:57:24 +08:00
|
|
|
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 {
|
2026-05-08 10:57:24 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:57:24 +08:00
|
|
|
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) {
|
2026-05-08 10:57:24 +08:00
|
|
|
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() {
|
2026-05-08 10:57:24 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:57:24 +08:00
|
|
|
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 {
|
2026-05-08 10:57:24 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:57:24 +08:00
|
|
|
fn local_markdown_conflict_detection_key(
|
|
|
|
|
document_id: &str,
|
|
|
|
|
markdown_path: &Path,
|
|
|
|
|
) -> Result<String, WebError> {
|
2026-05-08 23:15:00 +08:00
|
|
|
let content = fs::read(markdown_path).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"local_markdown_read_failed",
|
|
|
|
|
format!(
|
|
|
|
|
"无法读取本地 Markdown 文件 {}: {error}",
|
|
|
|
|
markdown_path.display()
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-05-08 10:57:24 +08:00
|
|
|
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));
|
2026-05-08 23:15:00 +08:00
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
|
content.hash(&mut hasher);
|
|
|
|
|
let content_hash = hasher.finish();
|
2026-05-08 10:57:24 +08:00
|
|
|
Ok(format!(
|
2026-05-08 23:15:00 +08:00
|
|
|
"local-md:{document_id}:{modified_ms}:{}:{content_hash:016x}",
|
2026-05-08 10:57:24 +08:00
|
|
|
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::{
|
2026-05-19 08:07:17 +08:00
|
|
|
add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base,
|
2026-05-20 10:43:38 +08:00
|
|
|
create_local_access_grant, create_share_grant, encode_local_id_segment,
|
|
|
|
|
ensure_local_path_read_access, ensure_local_workspace_access_for_actor,
|
|
|
|
|
ensure_local_workspace_read_access_for_actor, execute_local_tree_command,
|
|
|
|
|
get_local_access_policy, get_share_grants, initialize_local_page_id,
|
|
|
|
|
initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot,
|
|
|
|
|
load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_workspace_id,
|
|
|
|
|
open_local_file, record_shared_cache, record_sync_pending_change,
|
|
|
|
|
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
|
|
|
|
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
|
|
|
|
write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report,
|
|
|
|
|
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
|
|
|
|
LocalShareGrantRequest, LocalUploadFile, SharedCacheRecordRequest,
|
|
|
|
|
SyncConflictReportRequest, SyncPendingChangeRequest,
|
2026-05-08 00:41:03 +08:00
|
|
|
};
|
2026-05-19 08:07:17 +08:00
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use axum::extract::{Extension, Path as AxumPath, Query};
|
|
|
|
|
use axum::http::{HeaderMap, Method, StatusCode};
|
|
|
|
|
use axum::Json;
|
2026-05-08 00:41:03 +08:00
|
|
|
use serde_json::Value;
|
2026-05-19 08:07:17 +08:00
|
|
|
use std::sync::Mutex;
|
|
|
|
|
|
|
|
|
|
fn env_lock() -> &'static Mutex<()> {
|
|
|
|
|
crate::test_support::hermes_env_lock()
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
fn request_context(actor_id: &str, actor_type: &str) -> RequestContext {
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
headers.insert("x-mnote-actor-id", actor_id.parse().unwrap());
|
|
|
|
|
headers.insert("x-mnote-actor-type", actor_type.parse().unwrap());
|
|
|
|
|
RequestContext::from_http_parts(
|
|
|
|
|
&Method::POST,
|
|
|
|
|
&"/api/admin/access-policy".parse().expect("uri"),
|
|
|
|
|
&headers,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn init_workspace(root: &std::path::Path) {
|
|
|
|
|
initialize_local_workspace_for_actor("user_test", &format!("file://{}", root.display()))
|
|
|
|
|
.expect("init local workspace");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[test]
|
2026-05-20 10:43:38 +08:00
|
|
|
fn local_markdown_identity_uses_path_even_when_frontmatter_has_mnote_id() {
|
|
|
|
|
let root = temp_root("mnote-local-frontmatter-path-id");
|
2026-05-19 08:07:17 +08:00
|
|
|
init_workspace(&root);
|
2026-05-08 00:41:03 +08:00
|
|
|
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");
|
2026-05-20 10:43:38 +08:00
|
|
|
let first_json = first.projection.to_string();
|
|
|
|
|
assert!(first_json.contains("local-md:page.md"));
|
|
|
|
|
assert!(!first_json.contains("local-mdid:stable-frontmatter-id"));
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
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();
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(second_json.contains("local-md:docs~2Frenamed.md"));
|
|
|
|
|
assert!(!second_json.contains("local-mdid:stable-frontmatter-id"));
|
2026-05-08 00:41:03 +08:00
|
|
|
assert!(second_json.contains("renamed.md"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-20 10:43:38 +08:00
|
|
|
fn local_page_ids_metadata_does_not_override_path_derived_id() {
|
2026-05-08 00:41:03 +08:00
|
|
|
let root = temp_root("mnote-local-page-ids");
|
2026-05-19 08:07:17 +08:00
|
|
|
init_workspace(&root);
|
2026-05-08 00:41:03 +08:00
|
|
|
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();
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(html_json.contains("local-md:docs~2Fpage.md"));
|
|
|
|
|
assert!(!html_json.contains("local-mdid:stable-from-page-ids"));
|
2026-05-08 00:41:03 +08:00
|
|
|
assert!(!html_json.contains("page-ids.json"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-20 10:43:38 +08:00
|
|
|
fn local_page_id_initialization_returns_path_id_without_writing_page_ids() {
|
2026-05-08 00:41:03 +08:00
|
|
|
let root = temp_root("mnote-local-page-id-init");
|
2026-05-19 08:07:17 +08:00
|
|
|
init_workspace(&root);
|
2026-05-08 00:41:03 +08:00
|
|
|
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");
|
2026-05-20 10:43:38 +08:00
|
|
|
assert_eq!(page_id, "local-md:draft.md");
|
|
|
|
|
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
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");
|
2026-05-20 10:43:38 +08:00
|
|
|
std::fs::write(root.join(".mnote").join("page-options.json"), "{not-json")
|
2026-05-08 00:41:03 +08:00
|
|
|
.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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_page_aggregate_accepts_path_id_when_frontmatter_has_mnote_id() {
|
|
|
|
|
let root = temp_root("mnote-local-path-id-frontmatter");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("pages")).expect("create pages");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join("pages").join("初始化的新页面.md"),
|
|
|
|
|
"---\ntitle: 初始化的新页面\nmnote_id: initial-page\n---\n# 初始化的新页面\n\n正文\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
let path_id = format!(
|
|
|
|
|
"local-md:{}",
|
|
|
|
|
encode_local_id_segment("pages/初始化的新页面.md")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let aggregate =
|
|
|
|
|
resolve_local_markdown_page_aggregate(&root_uri, &path_id).expect("aggregate");
|
|
|
|
|
assert_eq!(aggregate.title, "初始化的新页面");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_parser_preserves_pipe_table_as_table_block() {
|
2026-05-08 10:57:24 +08:00
|
|
|
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() {
|
2026-05-08 10:57:24 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:15:00 +08:00
|
|
|
#[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-19 08:07:17 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_aggregate_exposes_file_version_alias() {
|
|
|
|
|
let root = temp_root("mnote-local-file-version-aggregate");
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Versioned\n").expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md")
|
|
|
|
|
.expect("aggregate");
|
|
|
|
|
let body = serde_json::to_value(&aggregate.body).expect("body json");
|
|
|
|
|
|
|
|
|
|
assert_eq!(body["fileVersion"], body["conflictDetectionKey"]);
|
|
|
|
|
assert!(body["fileVersion"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("file version")
|
|
|
|
|
.starts_with("local-md:local-md:README.md:"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_save_returns_file_version_alias() {
|
|
|
|
|
let root = temp_root("mnote-local-file-version-save");
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Old\n").expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let result = save_local_markdown_page(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-md:README.md",
|
|
|
|
|
None,
|
|
|
|
|
&serde_json::json!([
|
|
|
|
|
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"New"}]}
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.expect("save");
|
|
|
|
|
|
|
|
|
|
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
|
|
|
|
assert!(result["fileVersion"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("file version")
|
|
|
|
|
.starts_with("local-md:local-md:README.md:"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_write_contract_returns_page_body_write_command() {
|
|
|
|
|
let root = temp_root("mnote-local-page-body-write-contract");
|
|
|
|
|
std::fs::write(root.join("README.md"), "---\ntitle: Contract\n---\n# Old\n")
|
|
|
|
|
.expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md")
|
|
|
|
|
.expect("aggregate");
|
|
|
|
|
let request = core_protocol::PageBodyWriteRequest {
|
|
|
|
|
document_id: "local-md:README.md".into(),
|
|
|
|
|
workspace_id: "local-ws:test".into(),
|
|
|
|
|
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
expected_file_version: aggregate.body.file_version.as_str().map(ToOwned::to_owned),
|
|
|
|
|
base_content_hash: Some("sha256:test-base".into()),
|
|
|
|
|
content_format: "editorBlocks".into(),
|
|
|
|
|
content: serde_json::json!([
|
|
|
|
|
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Written"}]}
|
|
|
|
|
]),
|
|
|
|
|
editor_source: Some("unit-test".into()),
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
let result = write_local_markdown_page_body(&request, None).expect("write");
|
2026-05-19 08:07:17 +08:00
|
|
|
|
|
|
|
|
assert_eq!(result["canonicalCommand"], "page.body.write");
|
|
|
|
|
assert_eq!(result["compatCommand"], "page.body.save");
|
|
|
|
|
assert_eq!(result["contentFormat"], "editorBlocks");
|
|
|
|
|
assert_eq!(result["editorSource"], "unit-test");
|
|
|
|
|
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
|
|
|
|
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
|
|
|
|
assert!(saved.contains("# Written"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() {
|
2026-05-08 10:57:24 +08:00
|
|
|
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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:57:24 +08:00
|
|
|
#[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,
|
2026-05-20 10:43:38 +08:00
|
|
|
"local-md:README.md",
|
2026-05-08 10:57:24 +08:00
|
|
|
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",
|
2026-05-08 10:57:24 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-08 10:57:24 +08:00
|
|
|
|
|
|
|
|
#[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");
|
2026-05-08 10:57:24 +08:00
|
|
|
|
|
|
|
|
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 文件已被外部修改"));
|
2026-05-08 10:57:24 +08:00
|
|
|
|
|
|
|
|
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-19 08:07:17 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_save_writes_image_blocks_as_markdown_images() {
|
|
|
|
|
let root = temp_root("mnote-local-markdown-save-image-block");
|
|
|
|
|
std::fs::write(root.join("README.md"), "---\ntitle: Image\n---\n# Old\n")
|
|
|
|
|
.expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
save_local_markdown_page(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-md:README.md",
|
|
|
|
|
None,
|
|
|
|
|
&serde_json::json!([
|
|
|
|
|
{
|
|
|
|
|
"type": "image",
|
|
|
|
|
"props": {
|
|
|
|
|
"src": "README.assets/photo.png",
|
|
|
|
|
"alt": "示例图片",
|
|
|
|
|
"title": "示例图片"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.expect("save");
|
|
|
|
|
|
|
|
|
|
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
|
|
|
|
assert!(saved.contains(""));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_save_rewrites_local_file_open_url_to_markdown_relative_path() {
|
|
|
|
|
let root = temp_root("mnote-local-markdown-save-image-open-url");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
|
|
|
|
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
|
|
|
|
|
std::fs::write(root.join("Page").join("屏幕截图 2025.png"), b"jpg").expect("write image");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
let image_url = format!(
|
|
|
|
|
"http://localhost:3000/api/local-folder/files/open?rootUri={}&path=Page%2F%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%202025.png",
|
|
|
|
|
root_uri
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
save_local_markdown_page(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-md:Page~2FPage.md",
|
|
|
|
|
None,
|
|
|
|
|
&serde_json::json!([
|
|
|
|
|
{
|
|
|
|
|
"type": "image",
|
|
|
|
|
"props": {
|
|
|
|
|
"src": image_url,
|
|
|
|
|
"alt": "示例图片"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.expect("save");
|
|
|
|
|
|
|
|
|
|
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
|
|
|
|
assert!(saved.contains(""));
|
|
|
|
|
assert!(!saved.contains("/api/local-folder/files/open"));
|
|
|
|
|
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page~2FPage.md")
|
|
|
|
|
.expect("aggregate");
|
|
|
|
|
let image = aggregate.body.content.as_array().expect("blocks")[0].clone();
|
|
|
|
|
assert_eq!(image["type"], "image");
|
|
|
|
|
assert_eq!(image["props"]["src"], "屏幕截图 2025.png");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_mindmap_blocks_roundtrip_next_to_page() {
|
|
|
|
|
let root = temp_root("mnote-local-markdown-mindmap-roundtrip");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
|
|
|
|
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let write_result = write_local_mindmap_data(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-md:Page~2FPage.md",
|
|
|
|
|
"思维导图123456.json",
|
|
|
|
|
serde_json::json!({"data":{"text":"中心主题"},"children":[]}),
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.expect("write mindmap");
|
|
|
|
|
assert_eq!(write_result["relativePath"], "Page/思维导图123456.json");
|
|
|
|
|
assert!(root.join("Page").join("思维导图123456.json").is_file());
|
|
|
|
|
|
|
|
|
|
save_local_markdown_page(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-md:Page~2FPage.md",
|
|
|
|
|
None,
|
|
|
|
|
&serde_json::json!([
|
|
|
|
|
{
|
|
|
|
|
"type": "mindmap",
|
|
|
|
|
"props": {
|
|
|
|
|
"name": "主页思维导图",
|
|
|
|
|
"mindmapId": "思维导图123456.json",
|
|
|
|
|
"rootNodeId": "root"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.expect("save");
|
|
|
|
|
|
|
|
|
|
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
|
|
|
|
assert!(saved.contains("[主页思维导图](思维导图123456.json)"));
|
|
|
|
|
|
|
|
|
|
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page~2FPage.md")
|
|
|
|
|
.expect("aggregate");
|
|
|
|
|
let mindmap = aggregate.body.content.as_array().expect("blocks")[0].clone();
|
|
|
|
|
assert_eq!(mindmap["type"], "mindmap");
|
|
|
|
|
assert_eq!(mindmap["props"]["mindmapId"], "思维导图123456.json");
|
|
|
|
|
assert_eq!(mindmap["props"]["sourcePath"], "思维导图123456.json");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_workspace_access_rejects_owner_mismatch() {
|
|
|
|
|
let root = temp_root("mnote-local-workspace-owner-mismatch");
|
|
|
|
|
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join(".mnote").join("workspace.json"),
|
|
|
|
|
r#"{"workspaceId":"local-ws-demo","ownerId":"user_a","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write workspace manifest");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let error = ensure_local_workspace_access_for_actor("user_b", "user", &root_uri)
|
|
|
|
|
.expect_err("owner mismatch must be rejected");
|
|
|
|
|
|
|
|
|
|
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
|
|
|
|
|
assert!(error.message().contains("无权"));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_workspace_access_allows_admin_without_manifest() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-local-workspace-admin-any-root");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
std::env::set_var("MNOTE_ADMIN_USER_IDS", "user_admin");
|
|
|
|
|
|
|
|
|
|
let allowed = ensure_local_workspace_access_for_actor("user_admin", "user", &root_uri)
|
|
|
|
|
.expect("admin can write any local directory");
|
|
|
|
|
|
|
|
|
|
assert_eq!(allowed, root.canonicalize().expect("canonical root"));
|
|
|
|
|
std::env::remove_var("MNOTE_ADMIN_USER_IDS");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_workspace_access_policy_grants_read_and_write_separately() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-local-workspace-policy-root");
|
|
|
|
|
let policy_root = temp_root("mnote-local-workspace-policy-config");
|
|
|
|
|
let policy_file = policy_root.join("access-policy.json");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&policy_file,
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"grants": [
|
|
|
|
|
{
|
|
|
|
|
"userId": "user_reader",
|
|
|
|
|
"rootUri": root_uri,
|
|
|
|
|
"permission": "read",
|
|
|
|
|
"recursive": true
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.expect("write policy");
|
|
|
|
|
|
|
|
|
|
ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri)
|
|
|
|
|
.expect("read grant can read local directory");
|
|
|
|
|
let write_error = ensure_local_workspace_access_for_actor("user_reader", "user", &root_uri)
|
|
|
|
|
.expect_err("read grant must not write");
|
|
|
|
|
assert_eq!(write_error.status(), axum::http::StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&policy_file,
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"grants": [
|
|
|
|
|
{
|
|
|
|
|
"userId": "user_writer",
|
|
|
|
|
"rootUri": root_uri,
|
|
|
|
|
"permission": "write",
|
|
|
|
|
"recursive": true
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.expect("write policy");
|
|
|
|
|
ensure_local_workspace_access_for_actor("user_writer", "user", &root_uri)
|
|
|
|
|
.expect("write grant can write local directory");
|
|
|
|
|
|
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&policy_root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_path_read_access_rejects_root_escape() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-local-path-read-root");
|
|
|
|
|
let outside = temp_root("mnote-local-path-read-outside");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::write(root.join("inside.md"), "inside").expect("write inside");
|
|
|
|
|
std::fs::write(outside.join("outside.md"), "outside").expect("write outside");
|
|
|
|
|
let context = request_context("user_test", "user");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let inside = ensure_local_path_read_access(&context, &root_uri, "inside.md")
|
|
|
|
|
.expect("relative file inside root can be read");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
inside,
|
|
|
|
|
root.join("inside.md").canonicalize().expect("inside")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let escape_error = ensure_local_path_read_access(
|
|
|
|
|
&context,
|
|
|
|
|
&root_uri,
|
|
|
|
|
&outside.join("outside.md").display().to_string(),
|
|
|
|
|
)
|
|
|
|
|
.expect_err("absolute path outside root must be rejected");
|
|
|
|
|
assert_eq!(escape_error.status(), StatusCode::BAD_REQUEST);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&outside);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn local_access_policy_admin_api_rejects_non_admin_and_validates_root() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-local-access-policy-validate-root");
|
|
|
|
|
let policy_root = temp_root("mnote-local-access-policy-validate-config");
|
|
|
|
|
let policy_file = policy_root.join("access-policy.json");
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
|
|
|
|
|
|
|
|
|
let non_admin_error = get_local_access_policy(Extension(request_context("user_1", "user")))
|
|
|
|
|
.await
|
|
|
|
|
.expect_err("non-admin cannot read access policy");
|
|
|
|
|
assert_eq!(non_admin_error.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1");
|
|
|
|
|
let (_, Json(payload)) = validate_local_access_root(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
Json(LocalAccessValidateRootRequest {
|
|
|
|
|
root_uri: format!("file://{}", root.display()),
|
|
|
|
|
root_path: String::new(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin can validate root");
|
|
|
|
|
assert_eq!(payload["ok"], true);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["rootPath"],
|
|
|
|
|
root.canonicalize()
|
|
|
|
|
.expect("canonical root")
|
|
|
|
|
.display()
|
|
|
|
|
.to_string()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let invalid_error = validate_local_access_root(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
Json(LocalAccessValidateRootRequest {
|
|
|
|
|
root_uri: format!("file://{}", root.join("missing").display()),
|
|
|
|
|
root_path: String::new(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect_err("missing root should be rejected");
|
|
|
|
|
assert_eq!(invalid_error.status(), StatusCode::BAD_REQUEST);
|
|
|
|
|
|
|
|
|
|
std::env::remove_var("MNOTE_ADMIN_USER_IDS");
|
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&policy_root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn local_access_policy_admin_api_creates_and_deletes_grant() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-local-access-policy-grant-root");
|
|
|
|
|
let policy_root = temp_root("mnote-local-access-policy-grant-config");
|
|
|
|
|
let policy_file = policy_root.join("access-policy.json");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
|
|
|
|
std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1");
|
|
|
|
|
|
|
|
|
|
let (_, Json(created)) = create_local_access_grant(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
Json(LocalAccessGrantRequest {
|
|
|
|
|
id: "grant_reader".into(),
|
|
|
|
|
user_id: "user_reader".into(),
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
root_path: String::new(),
|
|
|
|
|
permission: "read".into(),
|
|
|
|
|
recursive: true,
|
|
|
|
|
capabilities: vec!["ai".into(), "share".into()],
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin can create read grant");
|
|
|
|
|
assert_eq!(created["grant"]["id"], "grant_reader");
|
|
|
|
|
assert_eq!(created["grant"]["permission"], "read");
|
|
|
|
|
ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri)
|
|
|
|
|
.expect("created read grant can read");
|
|
|
|
|
let write_error = ensure_local_workspace_access_for_actor("user_reader", "user", &root_uri)
|
|
|
|
|
.expect_err("created read grant cannot write");
|
|
|
|
|
assert_eq!(write_error.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
let duplicate_error = add_local_access_grant_for_context(
|
|
|
|
|
&request_context("admin_1", "user"),
|
|
|
|
|
LocalAccessGrantRequest {
|
|
|
|
|
id: "grant_reader".into(),
|
|
|
|
|
user_id: "user_reader".into(),
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
root_path: String::new(),
|
|
|
|
|
permission: "read".into(),
|
|
|
|
|
recursive: true,
|
|
|
|
|
capabilities: vec!["ai".into(), "share".into()],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect_err("duplicate grant id should be rejected");
|
|
|
|
|
assert_eq!(duplicate_error.status(), StatusCode::BAD_REQUEST);
|
|
|
|
|
|
|
|
|
|
let (_, Json(deleted)) = super::delete_local_access_grant(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
AxumPath("grant_reader".into()),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin can delete grant");
|
|
|
|
|
assert_eq!(deleted["deletedGrantId"], "grant_reader");
|
|
|
|
|
let read_error =
|
|
|
|
|
ensure_local_workspace_read_access_for_actor("user_reader", "user", &root_uri)
|
|
|
|
|
.expect_err("deleted grant should remove read access");
|
|
|
|
|
assert_eq!(read_error.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
std::env::remove_var("MNOTE_ADMIN_USER_IDS");
|
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&policy_root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:11:08 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn share_grant_admin_api_creates_reads_and_revokes_without_local_access() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-share-grant-workspace-root");
|
|
|
|
|
let config_root = temp_root("mnote-share-grant-config-root");
|
|
|
|
|
let access_policy_file = config_root.join("access-policy.json");
|
|
|
|
|
let share_grants_file = config_root.join("share-grants.json");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &access_policy_file);
|
|
|
|
|
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
|
|
|
|
|
std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1");
|
|
|
|
|
|
|
|
|
|
let non_admin_error = get_share_grants(Extension(request_context("user_1", "user")))
|
|
|
|
|
.await
|
|
|
|
|
.expect_err("non-admin cannot read share grants");
|
|
|
|
|
assert_eq!(non_admin_error.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
let (_, Json(created)) = create_share_grant(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
Json(LocalShareGrantRequest {
|
|
|
|
|
id: "share_grant_1".into(),
|
|
|
|
|
share_id: "share_1".into(),
|
|
|
|
|
owner_user_id: "owner_1".into(),
|
|
|
|
|
target_user_id: "target_1".into(),
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
root_path: String::new(),
|
|
|
|
|
document_id: "doc_1".into(),
|
|
|
|
|
allowed_resource_ids: vec!["doc_1".into()],
|
|
|
|
|
permission: "read".into(),
|
|
|
|
|
capabilities: vec!["ai".into(), "share".into()],
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin can create share grant");
|
|
|
|
|
assert_eq!(created["grant"]["id"], "share_grant_1");
|
|
|
|
|
assert_eq!(created["grant"]["shareId"], "share_1");
|
|
|
|
|
assert_eq!(created["grant"]["active"], true);
|
|
|
|
|
|
|
|
|
|
let read_error =
|
|
|
|
|
ensure_local_workspace_read_access_for_actor("target_1", "user", &root_uri)
|
|
|
|
|
.expect_err("share grant must not grant local filesystem read access");
|
|
|
|
|
assert_eq!(read_error.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
let write_error = ensure_local_workspace_access_for_actor("target_1", "user", &root_uri)
|
|
|
|
|
.expect_err("share grant must not grant local filesystem write access");
|
|
|
|
|
assert_eq!(write_error.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
let (_, Json(listed)) = get_share_grants(Extension(request_context("admin_1", "user")))
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin can list share grants");
|
|
|
|
|
assert_eq!(listed["grants"][0]["id"], "share_grant_1");
|
|
|
|
|
assert_eq!(listed["grants"][0]["active"], true);
|
|
|
|
|
|
|
|
|
|
let (_, Json(revoked)) = super::delete_share_grant(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
AxumPath("share_1".into()),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("admin can revoke by share id");
|
|
|
|
|
assert_eq!(revoked["revokedShareId"], "share_1");
|
|
|
|
|
assert_eq!(revoked["grant"]["active"], false);
|
|
|
|
|
assert!(
|
|
|
|
|
revoked["grant"]["revokedAt"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.len()
|
|
|
|
|
> 0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let stored = std::fs::read_to_string(&share_grants_file).expect("read share grants file");
|
|
|
|
|
let stored_json: Value = serde_json::from_str(&stored).expect("share grants json");
|
|
|
|
|
assert_eq!(stored_json["grants"][0]["active"], false);
|
|
|
|
|
|
|
|
|
|
std::env::remove_var("MNOTE_ADMIN_USER_IDS");
|
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
|
|
|
|
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&config_root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn shared_cache_and_sync_state_record_permissions_and_conflict_report() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-shared-cache-sync-root");
|
|
|
|
|
let config_root = temp_root("mnote-shared-cache-sync-config");
|
|
|
|
|
let access_policy_file = config_root.join("access-policy.json");
|
|
|
|
|
let share_grants_file = config_root.join("share-grants.json");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
std::fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &access_policy_file);
|
|
|
|
|
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
|
|
|
|
|
std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1");
|
|
|
|
|
|
|
|
|
|
let (read_status, Json(read_share_payload)) = create_share_grant(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
Json(LocalShareGrantRequest {
|
|
|
|
|
id: "share_grant_sync_read".into(),
|
|
|
|
|
share_id: "share_sync_read".into(),
|
|
|
|
|
owner_user_id: "owner_1".into(),
|
|
|
|
|
target_user_id: "target_1".into(),
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
root_path: String::new(),
|
|
|
|
|
document_id: "local-md:README.md".into(),
|
|
|
|
|
allowed_resource_ids: vec!["local-md:README.md".into()],
|
|
|
|
|
permission: "read".into(),
|
|
|
|
|
capabilities: vec!["ai".into(), "share".into()],
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("create read share");
|
|
|
|
|
assert_eq!(read_status, StatusCode::OK);
|
|
|
|
|
assert_eq!(read_share_payload["grant"]["shareId"], "share_sync_read");
|
|
|
|
|
let (write_status, Json(write_share_payload)) = create_share_grant(
|
|
|
|
|
Extension(request_context("admin_1", "user")),
|
|
|
|
|
Json(LocalShareGrantRequest {
|
|
|
|
|
id: "share_grant_sync_write".into(),
|
|
|
|
|
share_id: "share_sync_write".into(),
|
|
|
|
|
owner_user_id: "owner_1".into(),
|
|
|
|
|
target_user_id: "target_1".into(),
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
root_path: String::new(),
|
|
|
|
|
document_id: "local-md:README.md".into(),
|
|
|
|
|
allowed_resource_ids: vec!["local-md:README.md".into()],
|
|
|
|
|
permission: "write".into(),
|
|
|
|
|
capabilities: vec!["ai".into(), "share".into()],
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("create write share");
|
|
|
|
|
assert_eq!(write_status, StatusCode::OK);
|
|
|
|
|
assert_eq!(write_share_payload["grant"]["shareId"], "share_sync_write");
|
|
|
|
|
|
|
|
|
|
let (_, Json(cache_payload)) = record_shared_cache(
|
|
|
|
|
Extension(request_context("target_1", "user")),
|
|
|
|
|
Json(SharedCacheRecordRequest {
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
share_id: "share_sync_read".into(),
|
|
|
|
|
permission: "read".into(),
|
|
|
|
|
remote_version: "remote-1".into(),
|
|
|
|
|
base_version: "base-1".into(),
|
|
|
|
|
source_actor: "owner_1".into(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("record cache");
|
|
|
|
|
assert_eq!(cache_payload["share"]["remoteVersion"], "remote-1");
|
|
|
|
|
let cache_json =
|
|
|
|
|
std::fs::read_to_string(root.join(".mnote").join("share-cache.json")).expect("cache");
|
|
|
|
|
assert!(cache_json.contains("share_sync_read"));
|
|
|
|
|
assert!(cache_json.contains("remote-1"));
|
|
|
|
|
|
|
|
|
|
let read_pending_error = record_sync_pending_change(
|
|
|
|
|
Extension(request_context("target_1", "user")),
|
|
|
|
|
Json(SyncPendingChangeRequest {
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
share_id: "share_sync_read".into(),
|
|
|
|
|
resource_id: "local-md:README.md".into(),
|
|
|
|
|
local_version: "local-1".into(),
|
|
|
|
|
base_version: "remote-1".into(),
|
|
|
|
|
remote_version: "remote-1".into(),
|
|
|
|
|
change_summary: "read share write attempt".into(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect_err("shared_read cannot write pending changes");
|
|
|
|
|
assert_eq!(read_pending_error.status(), StatusCode::FORBIDDEN);
|
|
|
|
|
|
|
|
|
|
let (_, Json(pending_payload)) = record_sync_pending_change(
|
|
|
|
|
Extension(request_context("target_1", "user")),
|
|
|
|
|
Json(SyncPendingChangeRequest {
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
share_id: "share_sync_write".into(),
|
|
|
|
|
resource_id: "local-md:README.md".into(),
|
|
|
|
|
local_version: "local-2".into(),
|
|
|
|
|
base_version: "remote-1".into(),
|
|
|
|
|
remote_version: "remote-1".into(),
|
|
|
|
|
change_summary: "写入共享页面".into(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("shared_write can record pending change");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
pending_payload["pendingChange"]["resourceId"],
|
|
|
|
|
"local-md:README.md"
|
|
|
|
|
);
|
|
|
|
|
let sync_state =
|
|
|
|
|
std::fs::read_to_string(root.join(".mnote").join("sync-state.json")).expect("sync");
|
|
|
|
|
assert!(sync_state.contains("share_sync_write"));
|
|
|
|
|
assert!(sync_state.contains("写入共享页面"));
|
|
|
|
|
|
|
|
|
|
let conflict_error = record_sync_pending_change(
|
|
|
|
|
Extension(request_context("target_1", "user")),
|
|
|
|
|
Json(SyncPendingChangeRequest {
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
share_id: "share_sync_write".into(),
|
|
|
|
|
resource_id: "local-md:README.md".into(),
|
|
|
|
|
local_version: "local-3".into(),
|
|
|
|
|
base_version: "remote-1".into(),
|
|
|
|
|
remote_version: "remote-2".into(),
|
|
|
|
|
change_summary: "冲突写入".into(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect_err("remote changed since base");
|
|
|
|
|
assert_eq!(conflict_error.status(), StatusCode::CONFLICT);
|
|
|
|
|
|
|
|
|
|
let (_, Json(report_payload)) = write_sync_conflict_report(
|
|
|
|
|
Extension(request_context("target_1", "user")),
|
|
|
|
|
Json(SyncConflictReportRequest {
|
|
|
|
|
root_uri,
|
|
|
|
|
share_id: "share_sync_write".into(),
|
|
|
|
|
resource_id: "local-md:README.md".into(),
|
|
|
|
|
local_version: "local-3".into(),
|
|
|
|
|
remote_version: "remote-2".into(),
|
|
|
|
|
base_version: "remote-1".into(),
|
|
|
|
|
actor_id: "target_1".into(),
|
|
|
|
|
summary: "同步冲突".into(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("write conflict report");
|
|
|
|
|
assert_eq!(report_payload["report"]["source"], "shared_sync");
|
|
|
|
|
assert_eq!(report_payload["report"]["shareId"], "share_sync_write");
|
|
|
|
|
assert!(root.join(".mnote").join("sync-reports").is_dir());
|
|
|
|
|
|
|
|
|
|
std::env::remove_var("MNOTE_ADMIN_USER_IDS");
|
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
|
|
|
|
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&config_root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[test]
|
|
|
|
|
fn create_default_local_workspace_uses_managed_data_root() {
|
|
|
|
|
let base = temp_root("mnote-local-managed-data-root");
|
|
|
|
|
let payload =
|
|
|
|
|
create_default_local_workspace_for_actor_at_base("user@example.com", "user", &base)
|
|
|
|
|
.expect("create default workspace");
|
|
|
|
|
let root_uri = payload["workspace"]["rootUri"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("root uri")
|
|
|
|
|
.to_string();
|
|
|
|
|
let root_path = payload["workspace"]["rootPath"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("root path");
|
|
|
|
|
let expected_root = base
|
|
|
|
|
.join("users")
|
|
|
|
|
.join("user~40example.com")
|
|
|
|
|
.join("workspaces")
|
|
|
|
|
.join("my-space");
|
|
|
|
|
|
|
|
|
|
assert_eq!(std::path::Path::new(root_path), expected_root.as_path());
|
|
|
|
|
assert!(expected_root.join(".mnote").join("workspace.json").exists());
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(expected_root
|
|
|
|
|
.join("初始化的新页面")
|
|
|
|
|
.join("初始化的新页面.md")
|
|
|
|
|
.exists());
|
|
|
|
|
assert!(!expected_root.join("pages").exists());
|
|
|
|
|
assert!(!expected_root.join("assets").exists());
|
|
|
|
|
assert!(!expected_root.join("mindmaps").exists());
|
|
|
|
|
assert!(!expected_root.join("ai-sessions").exists());
|
2026-05-19 08:07:17 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
payload["workspace"]["manifest"]["workspaceId"],
|
|
|
|
|
"local-ws:user~40example.com:my-space"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["workspace"]["manifest"]["ownerId"],
|
|
|
|
|
"user@example.com"
|
|
|
|
|
);
|
|
|
|
|
assert!(payload["workspace"]["manifest"]["capabilities"]
|
|
|
|
|
.as_array()
|
|
|
|
|
.expect("capabilities")
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|value| value.as_str() == Some("markdown_edit")));
|
|
|
|
|
ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri)
|
|
|
|
|
.expect("owner can access managed workspace");
|
|
|
|
|
|
|
|
|
|
let second =
|
|
|
|
|
create_default_local_workspace_for_actor_at_base("user@example.com", "user", &base)
|
|
|
|
|
.expect("idempotent create");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
second["workspace"]["rootUri"],
|
|
|
|
|
payload["workspace"]["rootUri"]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_file_tree_empty_root_does_not_emit_workspace_folder_row() {
|
|
|
|
|
let root = temp_root("mnote-local-empty-root-file-tree");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree");
|
|
|
|
|
let items = snapshot.projection["items"].as_array().expect("items");
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
items.is_empty(),
|
|
|
|
|
"只剩 .mnote 的空 workspace 不应投影出 root 文件夹行: {items:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 09:38:57 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_tree_command_refreshes_search_index_after_rename() {
|
|
|
|
|
let root = temp_root("mnote-local-tree-refresh-search-index");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join("search-target.md"),
|
|
|
|
|
"# Search Target\nrename-token\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
crate::routes::local_search_index::refresh_local_search_index(
|
|
|
|
|
&root,
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-ws-search-index-test",
|
|
|
|
|
)
|
|
|
|
|
.expect("initial index");
|
|
|
|
|
let before =
|
|
|
|
|
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
|
|
|
|
|
.expect("before index");
|
|
|
|
|
assert!(before.contains("search-target.md"));
|
|
|
|
|
|
|
|
|
|
execute_local_tree_command(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"rename",
|
|
|
|
|
"local-md:search-target.md",
|
|
|
|
|
None,
|
|
|
|
|
Some("Renamed Target"),
|
|
|
|
|
)
|
|
|
|
|
.expect("rename");
|
|
|
|
|
|
|
|
|
|
let after =
|
|
|
|
|
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
|
|
|
|
|
.expect("after index");
|
|
|
|
|
assert!(after.contains("Renamed Target.md"), "{after}");
|
|
|
|
|
assert!(!after.contains("search-target.md"), "{after}");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_page_title_comes_from_file_name_not_body_heading() {
|
|
|
|
|
let root = temp_root("mnote-local-title-file-name");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::write(root.join("File Name.md"), "# Body Heading\n").expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
|
|
|
|
|
let items = snapshot.projection["items"].as_array().expect("items");
|
|
|
|
|
let row = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|item| {
|
|
|
|
|
item["resourceMeta"]["documentId"].as_str() == Some("local-md:File~20Name.md")
|
|
|
|
|
})
|
|
|
|
|
.expect("page row");
|
|
|
|
|
|
|
|
|
|
assert_eq!(row["title"].as_str(), Some("File Name"));
|
|
|
|
|
|
|
|
|
|
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md")
|
|
|
|
|
.expect("aggregate");
|
|
|
|
|
assert_eq!(aggregate.title, "File Name");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_document_title_save_renames_nested_bundle_directory_and_markdown() {
|
|
|
|
|
let root = temp_root("mnote-local-title-save-renames-file");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("Old")).expect("create Old dir");
|
|
|
|
|
std::fs::write(root.join("Old").join("Old.md"), "# Body Heading\n").expect("write Old.md");
|
|
|
|
|
std::fs::write(root.join("Old").join("Child.md"), "# Child\n").expect("write child");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let result = update_local_markdown_title(&root_uri, "local-md:Old~2FOld.md", "New")
|
|
|
|
|
.expect("save title");
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
root.join("New").join("New.md").is_file(),
|
|
|
|
|
"New/New.md should exist"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
root.join("New").join("Child.md").is_file(),
|
|
|
|
|
"New/Child.md should exist"
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join("Old").exists(), "Old/ should not exist");
|
|
|
|
|
assert_eq!(result["documentId"].as_str(), Some("local-md:New~2FNew.md"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result["previousDocumentId"].as_str(),
|
|
|
|
|
Some("local-md:Old~2FOld.md")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(result["title"].as_str(), Some("New"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_tree_command_manages_loose_markdown_like_regular_file() {
|
|
|
|
|
let root = temp_root("mnote-local-loose-markdown-file-management");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
|
|
|
std::fs::write(root.join("docs").join("README.md"), "# Loose\n").expect("write loose md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let renamed = execute_local_tree_command(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"rename",
|
|
|
|
|
"local-md:docs~2FREADME.md",
|
|
|
|
|
None,
|
|
|
|
|
Some("Renamed"),
|
|
|
|
|
)
|
|
|
|
|
.expect("rename loose markdown");
|
|
|
|
|
assert!(root.join("docs").join("Renamed.md").is_file());
|
|
|
|
|
assert!(!root.join("docs").join("README.md").exists());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
renamed["documentId"].as_str(),
|
|
|
|
|
Some("local-md:docs~2FRenamed.md")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let moved =
|
|
|
|
|
execute_local_tree_command(&root_uri, "move", "local-md:docs~2FRenamed.md", None, None)
|
|
|
|
|
.expect("move loose markdown");
|
|
|
|
|
assert!(root.join("Renamed.md").is_file());
|
|
|
|
|
assert!(!root.join("docs").join("Renamed.md").exists());
|
|
|
|
|
assert_eq!(moved["documentId"].as_str(), Some("local-md:Renamed.md"));
|
|
|
|
|
|
|
|
|
|
let copied =
|
|
|
|
|
execute_local_tree_command(&root_uri, "copy", "local-md:Renamed.md", None, None)
|
|
|
|
|
.expect("copy loose markdown");
|
|
|
|
|
assert!(root.join("Renamed 2.md").is_file());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
copied["documentId"].as_str(),
|
|
|
|
|
Some("local-md:Renamed~202.md")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let deleted =
|
|
|
|
|
execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None)
|
|
|
|
|
.expect("delete loose markdown");
|
|
|
|
|
assert!(!root.join("Renamed.md").exists());
|
|
|
|
|
assert!(root
|
|
|
|
|
.join(".mnote")
|
|
|
|
|
.join("trash")
|
|
|
|
|
.join("Renamed.md")
|
|
|
|
|
.is_file());
|
|
|
|
|
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
|
|
|
|
|
|
|
|
|
|
let restored =
|
|
|
|
|
execute_local_tree_command(&root_uri, "restore", "local-md:Renamed.md", None, None)
|
|
|
|
|
.expect("restore loose markdown");
|
|
|
|
|
assert!(root.join("Renamed.md").is_file());
|
|
|
|
|
assert_eq!(restored["documentId"].as_str(), Some("local-md:Renamed.md"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_tree_command_delete_folder_moves_directory_to_trash() {
|
|
|
|
|
let root = temp_root("mnote-local-delete-folder");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("Folder").join("Nested")).expect("create folder");
|
|
|
|
|
std::fs::write(root.join("Folder").join("Nested").join("note.txt"), "note")
|
|
|
|
|
.expect("write nested file");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let folder_id = format!("local-dir:{}", encode_local_id_segment("Folder"));
|
|
|
|
|
let result = execute_local_tree_command(&root_uri, "delete", &folder_id, None, None)
|
|
|
|
|
.expect("delete folder");
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!root.join("Folder").exists(),
|
|
|
|
|
"Folder should move away from root"
|
|
|
|
|
);
|
|
|
|
|
let trash_path = result["trashPath"].as_str().expect("trashPath");
|
|
|
|
|
assert!(
|
|
|
|
|
root.join(trash_path)
|
|
|
|
|
.join("Nested")
|
|
|
|
|
.join("note.txt")
|
|
|
|
|
.is_file(),
|
|
|
|
|
"folder contents should exist in trash"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(result["resourceKind"].as_str(), Some("local_directory"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result["trashEntryId"].as_str(),
|
|
|
|
|
Some("local-dir-trash:Folder")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 11:19:42 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_tree_command_delete_local_file_id_uses_trash_index() {
|
|
|
|
|
let root = temp_root("mnote-local-delete-local-file-id");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
|
|
|
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let result = execute_local_tree_command(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"delete",
|
|
|
|
|
"local-file:docs/photo.png",
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.expect("delete local-file asset");
|
|
|
|
|
|
|
|
|
|
assert_eq!(result["resourceKind"].as_str(), Some("local_file"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result["trashEntryId"].as_str(),
|
|
|
|
|
Some("local-file:docs/photo.png")
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join("docs").join("photo.png").exists());
|
|
|
|
|
assert!(root.join(".mnote").join("trash").join("photo.png").exists());
|
|
|
|
|
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
|
|
|
|
|
.expect("trash index");
|
|
|
|
|
assert!(trash_index.contains("local-file:docs/photo.png"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 09:38:57 +08:00
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_save_refreshes_search_index_after_write() {
|
|
|
|
|
let root = temp_root("mnote-local-save-refresh-search-index");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
crate::routes::local_search_index::refresh_local_search_index(
|
|
|
|
|
&root,
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-ws-search-index-test",
|
|
|
|
|
)
|
|
|
|
|
.expect("initial index");
|
|
|
|
|
save_local_markdown_page(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-md:README.md",
|
|
|
|
|
None,
|
|
|
|
|
&serde_json::json!([
|
|
|
|
|
{
|
|
|
|
|
"id": "heading_1",
|
|
|
|
|
"type": "heading",
|
|
|
|
|
"props": { "level": 1 },
|
|
|
|
|
"content": [{ "type": "text", "text": "After" }]
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "p_1",
|
|
|
|
|
"type": "paragraph",
|
|
|
|
|
"content": [{ "type": "text", "text": "new-token" }]
|
|
|
|
|
}
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.expect("save markdown");
|
|
|
|
|
|
|
|
|
|
let index =
|
|
|
|
|
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
|
|
|
|
|
.expect("index");
|
|
|
|
|
assert!(index.contains("new-token"), "{index}");
|
|
|
|
|
assert!(!index.contains("old-token"), "{index}");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn local_file_open_rejects_root_escape() {
|
|
|
|
|
let root = temp_root("mnote-local-file-open-root-escape");
|
|
|
|
|
std::fs::write(root.join("README.txt"), "hello").expect("write file");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
|
|
|
|
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&Method::GET,
|
|
|
|
|
&"/api/local-folder/files/open".parse().expect("uri"),
|
|
|
|
|
&headers,
|
|
|
|
|
);
|
|
|
|
|
let query = LocalFileOpenQuery {
|
|
|
|
|
root_uri,
|
|
|
|
|
path: "../escape.txt".into(),
|
|
|
|
|
download: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let error = open_local_file(Extension(context), Query(query))
|
|
|
|
|
.await
|
|
|
|
|
.expect_err("should reject root escape");
|
|
|
|
|
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
|
|
|
|
|
assert!(error.message().contains("root"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn local_file_open_allows_read_grant() {
|
|
|
|
|
let _guard = env_lock().lock().expect("env lock");
|
|
|
|
|
let root = temp_root("mnote-local-file-open-read-grant-root");
|
|
|
|
|
let policy_root = temp_root("mnote-local-file-open-read-grant-config");
|
|
|
|
|
let policy_file = policy_root.join("access-policy.json");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
std::fs::write(root.join("README.txt"), "hello").expect("write file");
|
|
|
|
|
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&policy_file,
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"grants": [
|
|
|
|
|
{
|
|
|
|
|
"userId": "user_reader",
|
|
|
|
|
"rootUri": root_uri,
|
|
|
|
|
"permission": "read",
|
|
|
|
|
"recursive": true
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.expect("write policy");
|
|
|
|
|
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
headers.insert("x-mnote-actor-id", "user_reader".parse().unwrap());
|
|
|
|
|
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&Method::GET,
|
|
|
|
|
&"/api/local-folder/files/open".parse().expect("uri"),
|
|
|
|
|
&headers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (_, _, bytes) = open_local_file(
|
|
|
|
|
Extension(context),
|
|
|
|
|
Query(LocalFileOpenQuery {
|
|
|
|
|
root_uri,
|
|
|
|
|
path: "README.txt".into(),
|
|
|
|
|
download: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("read grant can open local file");
|
|
|
|
|
assert_eq!(bytes, b"hello");
|
|
|
|
|
|
|
|
|
|
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&policy_root);
|
|
|
|
|
}
|
2026-05-20 10:43:38 +08:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn local_file_open_sets_image_and_markdown_content_type() {
|
|
|
|
|
let root = temp_root("mnote-local-file-open-content-type");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::write(root.join("photo.jpg"), b"jpg").expect("write image");
|
|
|
|
|
std::fs::write(root.join("note.md"), "# Note\n").expect("write markdown");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
|
|
|
|
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&Method::GET,
|
|
|
|
|
&"/api/local-folder/files/open".parse().expect("uri"),
|
|
|
|
|
&headers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (_, image_headers, _) = open_local_file(
|
|
|
|
|
Extension(context.clone()),
|
|
|
|
|
Query(LocalFileOpenQuery {
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
path: "photo.jpg".into(),
|
|
|
|
|
download: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("open image");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
image_headers
|
|
|
|
|
.get(axum::http::header::CONTENT_TYPE)
|
|
|
|
|
.and_then(|v| v.to_str().ok()),
|
|
|
|
|
Some("image/jpeg")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (_, markdown_headers, _) = open_local_file(
|
|
|
|
|
Extension(context),
|
|
|
|
|
Query(LocalFileOpenQuery {
|
|
|
|
|
root_uri,
|
|
|
|
|
path: "note.md".into(),
|
|
|
|
|
download: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("open markdown");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
markdown_headers
|
|
|
|
|
.get(axum::http::header::CONTENT_TYPE)
|
|
|
|
|
.and_then(|v| v.to_str().ok()),
|
|
|
|
|
Some("text/markdown; charset=utf-8")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn local_file_open_sets_pdf_and_office_content_type() {
|
|
|
|
|
let root = temp_root("mnote-local-file-open-office-content-type");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::write(root.join("spec.pdf"), b"pdf").expect("write pdf");
|
|
|
|
|
std::fs::write(root.join("report.docx"), b"docx").expect("write docx");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
|
|
|
|
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&Method::GET,
|
|
|
|
|
&"/api/local-folder/files/open".parse().expect("uri"),
|
|
|
|
|
&headers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (_, pdf_headers, _) = open_local_file(
|
|
|
|
|
Extension(context.clone()),
|
|
|
|
|
Query(LocalFileOpenQuery {
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
path: "spec.pdf".into(),
|
|
|
|
|
download: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("open pdf");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
pdf_headers
|
|
|
|
|
.get(axum::http::header::CONTENT_TYPE)
|
|
|
|
|
.and_then(|v| v.to_str().ok()),
|
|
|
|
|
Some("application/pdf")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (_, office_headers, _) = open_local_file(
|
|
|
|
|
Extension(context),
|
|
|
|
|
Query(LocalFileOpenQuery {
|
|
|
|
|
root_uri,
|
|
|
|
|
path: "report.docx".into(),
|
|
|
|
|
download: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("open docx");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
office_headers
|
|
|
|
|
.get(axum::http::header::CONTENT_TYPE)
|
|
|
|
|
.and_then(|v| v.to_str().ok()),
|
|
|
|
|
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_file_tree_classifies_mindmap_and_office_assets() {
|
|
|
|
|
let root = temp_root("mnote-local-filetree-resource-kinds");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
|
|
|
|
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
|
|
|
|
|
std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap");
|
|
|
|
|
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree");
|
|
|
|
|
let items = snapshot.projection["items"].as_array().expect("items");
|
|
|
|
|
let mindmap = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|item| item["title"].as_str() == Some("思维导图123456.json"))
|
|
|
|
|
.expect("mindmap row");
|
|
|
|
|
assert_eq!(mindmap["rowKind"].as_str(), Some("asset"));
|
|
|
|
|
assert_eq!(mindmap["iconHint"].as_str(), Some("mindmap"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
|
|
|
|
|
Some("mindmap")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let office = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|item| item["title"].as_str() == Some("report.docx"))
|
|
|
|
|
.expect("office row");
|
|
|
|
|
assert_eq!(office["rowKind"].as_str(), Some("asset"));
|
|
|
|
|
assert_eq!(office["iconHint"].as_str(), Some("office"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
office["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
|
|
|
|
|
Some("only_office")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Gap A2 BufferStore 运行时接入集成测试 ────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn document_buffer_mark_saved_after_page_body_write() {
|
|
|
|
|
let root = temp_root("mnote-buf-save-after-write");
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Old\n").expect("write md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let store = crate::document_buffer_store::BufferStore::new();
|
|
|
|
|
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
|
|
|
|
|
&local_workspace_id(&root),
|
|
|
|
|
&root_uri,
|
|
|
|
|
"README.md",
|
|
|
|
|
"local-md:README.md",
|
|
|
|
|
);
|
|
|
|
|
// 模拟编辑器打开文档:先初始化 buffer
|
|
|
|
|
store.get_or_create(&ws_path);
|
|
|
|
|
|
|
|
|
|
// 经过 write_local_markdown_page_body 保存入口
|
|
|
|
|
write_local_markdown_page_body(
|
|
|
|
|
&core_protocol::PageBodyWriteRequest {
|
|
|
|
|
document_id: "local-md:README.md".into(),
|
|
|
|
|
workspace_id: String::new(),
|
|
|
|
|
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
expected_file_version: None,
|
|
|
|
|
base_content_hash: None,
|
|
|
|
|
content_format: "editorBlocks".into(),
|
|
|
|
|
content: serde_json::json!([
|
|
|
|
|
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Written"}]}
|
|
|
|
|
]),
|
|
|
|
|
editor_source: Some("unit-test".into()),
|
|
|
|
|
},
|
|
|
|
|
Some(&store),
|
|
|
|
|
)
|
|
|
|
|
.expect("write_local_markdown_page_body");
|
|
|
|
|
|
|
|
|
|
// 验证 buffer store 已被消费
|
|
|
|
|
let buf = store
|
|
|
|
|
.get_by_path(&ws_path)
|
|
|
|
|
.expect("buffer should exist after write");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
buf.dirty_state,
|
|
|
|
|
core_protocol::DocBufferDirtyState::Clean,
|
|
|
|
|
"保存后 buffer 应为 Clean"
|
|
|
|
|
);
|
|
|
|
|
assert!(buf.file_version.is_some(), "file_version 应被更新");
|
|
|
|
|
assert!(buf.last_saved_at.is_some(), "last_saved_at 应被更新");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn document_buffer_marks_external_modified_from_local_folder_event() {
|
|
|
|
|
let store = crate::document_buffer_store::BufferStore::new();
|
|
|
|
|
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
|
|
|
|
|
"ws_test",
|
|
|
|
|
"file:///tmp/test-root",
|
|
|
|
|
"doc.md",
|
|
|
|
|
"local-md:doc.md",
|
|
|
|
|
);
|
|
|
|
|
// 模拟编辑器打开文档:Clean 状态 buffer
|
|
|
|
|
store.get_or_create(&ws_path);
|
|
|
|
|
|
|
|
|
|
// 模拟 watcher 事件调用
|
|
|
|
|
let result = store.mark_external_modified(&ws_path, Some("external-editor".into()));
|
|
|
|
|
assert!(result.is_some(), "外部修改应返回 buffer");
|
|
|
|
|
|
|
|
|
|
let buf = result.unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
buf.dirty_state,
|
|
|
|
|
core_protocol::DocBufferDirtyState::ExternalModified,
|
|
|
|
|
"Clean buffer 受外部修改后应变为 ExternalModified"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(buf.external_actor.as_deref(), Some("external-editor"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn document_buffer_marks_stale_from_local_folder_event_when_dirty() {
|
|
|
|
|
let store = crate::document_buffer_store::BufferStore::new();
|
|
|
|
|
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
|
|
|
|
|
"ws_test",
|
|
|
|
|
"file:///tmp/test-root",
|
|
|
|
|
"doc.md",
|
|
|
|
|
"local-md:doc.md",
|
|
|
|
|
);
|
|
|
|
|
// 模拟编辑器打开并且有未保存修改:Dirty 状态 buffer
|
|
|
|
|
let buf = store.get_or_create(&ws_path);
|
|
|
|
|
let mut dirty_buf = buf;
|
|
|
|
|
dirty_buf.mark_dirty("sha256:dirty_content".into());
|
|
|
|
|
let key = crate::document_buffer_store::BufferKey::from_workspace_path(&ws_path);
|
|
|
|
|
store.update(&key, dirty_buf);
|
|
|
|
|
|
|
|
|
|
// 模拟 watcher 事件调用
|
|
|
|
|
let result = store.mark_external_modified(&ws_path, Some("vim".into()));
|
|
|
|
|
assert!(result.is_some(), "外部修改应返回 buffer");
|
|
|
|
|
|
|
|
|
|
let buf = result.unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
buf.dirty_state,
|
|
|
|
|
core_protocol::DocBufferDirtyState::Stale,
|
|
|
|
|
"Dirty buffer 受外部修改后应变为 Stale"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(buf.external_actor.as_deref(), Some("vim"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_tree_command_create_page_creates_timestamped_nested_bundle() {
|
|
|
|
|
let root = temp_root("mnote-local-tree-create-page-timestamp");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let result = execute_local_tree_command(&root_uri, "create", "", None, Some("新页面"))
|
|
|
|
|
.expect("create page with default title");
|
|
|
|
|
|
|
|
|
|
let relative_path = result["relativePath"].as_str().expect("relativePath");
|
|
|
|
|
let document_id = result["documentId"].as_str().expect("documentId");
|
|
|
|
|
|
|
|
|
|
// 应形如“新页面HHMMSS/新页面HHMMSS.md”。
|
|
|
|
|
assert!(
|
|
|
|
|
relative_path.starts_with("新页面"),
|
|
|
|
|
"relativePath should start with 新页面, got: {relative_path}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
relative_path.ends_with(".md"),
|
|
|
|
|
"relativePath should end with .md, got: {relative_path}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
relative_path.len() > "新页面.md".len(),
|
|
|
|
|
"expected timestamp suffix"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (dir, file) = relative_path.split_once('/').expect("nested bundle path");
|
|
|
|
|
assert_eq!(file, format!("{dir}.md"));
|
|
|
|
|
assert!(
|
|
|
|
|
root.join(dir).is_dir(),
|
|
|
|
|
"bundle directory should exist: {}",
|
|
|
|
|
dir
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Markdown 文件应已创建。
|
|
|
|
|
assert!(
|
|
|
|
|
root.join(relative_path).is_file(),
|
|
|
|
|
"markdown file should exist: {relative_path}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// documentId 继续使用路径型身份。
|
|
|
|
|
assert!(
|
|
|
|
|
document_id.starts_with("local-md:"),
|
|
|
|
|
"documentId should be path-based"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(result["action"], "create");
|
|
|
|
|
assert_eq!(result["sourceKind"], "local_folder");
|
|
|
|
|
|
|
|
|
|
// 页面标题来自文件名,默认正文不再写第二个标题真相。
|
|
|
|
|
let content = std::fs::read_to_string(root.join(relative_path)).expect("read markdown");
|
|
|
|
|
assert!(
|
|
|
|
|
content.is_empty(),
|
|
|
|
|
"new page body should be empty: {content}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_page_tree_projects_nested_bundle_as_page_node() {
|
|
|
|
|
let root = temp_root("mnote-local-page-tree-nested-bundle");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
// 构造 Root/Root.md + Root/Child/Child.md。
|
|
|
|
|
std::fs::create_dir_all(root.join("Root")).expect("create Root dir");
|
|
|
|
|
std::fs::write(root.join("Root").join("Root.md"), "# Body Root\n").expect("write Root.md");
|
|
|
|
|
std::fs::create_dir_all(root.join("Root").join("Child")).expect("create Child dir");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join("Root").join("Child").join("Child.md"),
|
|
|
|
|
"# Body Child\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write Child.md");
|
|
|
|
|
|
|
|
|
|
// 读取页面树。
|
|
|
|
|
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("load page tree");
|
|
|
|
|
let items = snapshot.projection["items"].as_array().expect("items");
|
|
|
|
|
|
|
|
|
|
// 找到 Root 页面节点和 Child 页面节点。
|
|
|
|
|
let root_node = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|item| {
|
|
|
|
|
item["title"].as_str() == Some("Root")
|
|
|
|
|
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
|
|
|
|
|
})
|
|
|
|
|
.expect("Root page node");
|
|
|
|
|
|
|
|
|
|
let child_node = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|item| {
|
|
|
|
|
item["title"].as_str() == Some("Child")
|
|
|
|
|
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
|
|
|
|
|
})
|
|
|
|
|
.expect("Child page node");
|
|
|
|
|
|
|
|
|
|
// Child 的父节点应是 Root 的 documentId。
|
|
|
|
|
let root_doc_id = root_node["resourceMeta"]["documentId"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.or_else(|| root_node["nodeId"].as_str())
|
|
|
|
|
.expect("root documentId");
|
|
|
|
|
assert_eq!(child_node["parentNodeId"].as_str(), Some(root_doc_id));
|
|
|
|
|
assert_eq!(root_doc_id, "local-md:Root~2FRoot.md");
|
|
|
|
|
|
|
|
|
|
// Root bundle 目录不应额外生成 page-group 行。
|
|
|
|
|
let page_group_for_root = items.iter().find(|item| {
|
|
|
|
|
item["rowId"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.map_or(false, |id| id.contains("page-group:Root"))
|
|
|
|
|
});
|
|
|
|
|
assert!(
|
|
|
|
|
page_group_for_root.is_none(),
|
|
|
|
|
"should not have page-group for Root"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_file_tree_keeps_nested_bundle_filesystem_details() {
|
|
|
|
|
let root = temp_root("mnote-local-file-tree-nested-bundle");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
// 构造 Root/Root.md + Root/Child/Child.md。
|
|
|
|
|
std::fs::create_dir_all(root.join("Root")).expect("create Root dir");
|
|
|
|
|
std::fs::write(root.join("Root").join("Root.md"), "# Root\n").expect("write Root.md");
|
|
|
|
|
std::fs::create_dir_all(root.join("Root").join("Child")).expect("create Child dir");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join("Root").join("Child").join("Child.md"),
|
|
|
|
|
"# Child\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write Child.md");
|
|
|
|
|
|
|
|
|
|
// 读取文件树。
|
|
|
|
|
let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree");
|
|
|
|
|
let items = snapshot.projection["items"].as_array().expect("items");
|
|
|
|
|
|
|
|
|
|
// 文件树应保留 Root 文件夹和其中的 Root.md 真实条目。
|
|
|
|
|
let root_md = items.iter().find(|item| {
|
|
|
|
|
item["title"].as_str() == Some("Root.md")
|
|
|
|
|
&& item["resourceMeta"]["resourceKind"].as_str() == Some("markdown")
|
|
|
|
|
});
|
|
|
|
|
assert!(
|
|
|
|
|
root_md.is_some(),
|
|
|
|
|
"file tree should have Root.md as markdown row"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let root_folder = items.iter().find(|item| {
|
|
|
|
|
item["title"].as_str() == Some("Root")
|
|
|
|
|
&& item["resourceMeta"]["resourceKind"].as_str() == Some("folder")
|
|
|
|
|
});
|
|
|
|
|
assert!(
|
|
|
|
|
root_folder.is_some(),
|
|
|
|
|
"file tree should have Root as folder row"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Child.md 应作为 Root/Child 文件夹下的真实文件条目存在。
|
|
|
|
|
let child_md = items.iter().find(|item| {
|
|
|
|
|
item["title"].as_str() == Some("Child.md")
|
|
|
|
|
&& item["resourceMeta"]["resourceKind"].as_str() == Some("markdown")
|
|
|
|
|
});
|
|
|
|
|
assert!(
|
|
|
|
|
child_md.is_some(),
|
|
|
|
|
"file tree should have Child.md under Root"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() {
|
|
|
|
|
let root = temp_root("mnote-local-markdown-asset-upload");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
|
|
|
std::fs::create_dir_all(root.join("docs").join("README")).expect("create page dir");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join("docs").join("README").join("README.md"),
|
|
|
|
|
"# Assets\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write md");
|
|
|
|
|
std::fs::create_dir_all(root.join("docs").join("README")).expect("create page dir");
|
|
|
|
|
std::fs::write(root.join("docs").join("README").join("photo.png"), b"old")
|
|
|
|
|
.expect("write existing asset");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let asset = write_local_markdown_asset(
|
|
|
|
|
&root_uri,
|
|
|
|
|
"local-md:docs~2FREADME~2FREADME.md",
|
|
|
|
|
"image",
|
|
|
|
|
LocalUploadFile {
|
|
|
|
|
name: "photo.png".to_string(),
|
|
|
|
|
content_type: "image/png".to_string(),
|
|
|
|
|
bytes: b"new-image".to_vec(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("upload asset");
|
|
|
|
|
|
|
|
|
|
assert_eq!(asset["sourcePath"], "photo-1.png");
|
|
|
|
|
assert_eq!(asset["file_url"], "photo-1.png");
|
|
|
|
|
assert_eq!(asset["asset_type"], "image");
|
|
|
|
|
assert_eq!(asset["sourceKind"], "local_folder");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
std::fs::read(root.join("docs").join("README").join("photo-1.png"))
|
|
|
|
|
.expect("read copied asset"),
|
|
|
|
|
b"new-image"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_rename_markdown_page_renames_nested_bundle() {
|
|
|
|
|
let root = temp_root("mnote-local-rename-nested-bundle");
|
|
|
|
|
init_workspace(&root);
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
// 构造 Old/Old.md + Old/Child.md。
|
|
|
|
|
std::fs::create_dir_all(root.join("Old")).expect("create Old dir");
|
|
|
|
|
std::fs::write(root.join("Old").join("Old.md"), "# Old\n").expect("write Old.md");
|
|
|
|
|
std::fs::write(root.join("Old").join("Child.md"), "# Child\n").expect("write Child.md");
|
|
|
|
|
|
|
|
|
|
// 将 Old 重命名为 New。
|
|
|
|
|
let old_document_id = "local-md:Old~2FOld.md";
|
|
|
|
|
let result =
|
|
|
|
|
execute_local_tree_command(&root_uri, "rename", old_document_id, None, Some("New"))
|
|
|
|
|
.expect("rename page");
|
|
|
|
|
|
|
|
|
|
// New/New.md 应存在。
|
|
|
|
|
assert!(
|
|
|
|
|
root.join("New").join("New.md").is_file(),
|
|
|
|
|
"New/New.md should exist"
|
|
|
|
|
);
|
|
|
|
|
// New/Child.md 应存在。
|
|
|
|
|
assert!(
|
|
|
|
|
root.join("New").join("Child.md").is_file(),
|
|
|
|
|
"New/Child.md should exist"
|
|
|
|
|
);
|
|
|
|
|
// Old/ 不应再存在。
|
|
|
|
|
assert!(
|
|
|
|
|
!root.join("Old").exists(),
|
|
|
|
|
"Old/ directory should not exist"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(result["action"], "rename");
|
|
|
|
|
assert_eq!(result["sourceKind"], "local_folder");
|
|
|
|
|
assert_eq!(result["documentId"].as_str(), Some("local-md:New~2FNew.md"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|