feat: add main editor resource tabs
This commit is contained in:
@@ -206,6 +206,23 @@ struct LocalAssetUploadFields {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
fn local_folder_ok_response(
|
||||
context: &RequestContext,
|
||||
result: Value,
|
||||
) -> (StatusCode, HeaderMap, Json<Value>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"result": result,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalFileOpenQuery {
|
||||
@@ -215,6 +232,30 @@ pub struct LocalFileOpenQuery {
|
||||
pub download: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalResourceReadQuery {
|
||||
pub root_uri: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalResourceWriteRequest {
|
||||
#[serde(default, alias = "root_uri")]
|
||||
pub root_uri: String,
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
#[serde(default, alias = "expected_file_version")]
|
||||
pub expected_file_version: Option<String>,
|
||||
#[serde(default, alias = "content_format")]
|
||||
pub content_format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Value,
|
||||
#[serde(default, alias = "tiptap_document")]
|
||||
pub tiptap_document: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalAccessValidateRootRequest {
|
||||
@@ -2337,6 +2378,143 @@ pub async fn open_local_file(
|
||||
Ok((StatusCode::OK, headers, bytes))
|
||||
}
|
||||
|
||||
pub async fn read_local_resource(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalResourceReadQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root = parse_file_root_uri(&query.root_uri)?
|
||||
.canonicalize()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let target = resolve_local_file_open_path(&query.root_uri, &query.path)?;
|
||||
let text = fs::read_to_string(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_read_failed",
|
||||
format!("无法读取本地资源文件 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("资源")
|
||||
.to_string();
|
||||
let content = if is_markdown_file(&file_name) {
|
||||
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
|
||||
} else {
|
||||
text_to_editor_blocks(&text, &file_name)
|
||||
};
|
||||
let file_version = local_resource_conflict_detection_key(&root, &target)?;
|
||||
Ok(local_folder_ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"rootUri": query.root_uri,
|
||||
"path": query.path,
|
||||
"fileName": file_name,
|
||||
"contentType": content_type_for_path(&target).to_str().unwrap_or("application/octet-stream"),
|
||||
"text": text,
|
||||
"content": content,
|
||||
"contentFormat": "editorBlocks",
|
||||
"fileVersion": file_version,
|
||||
"conflictDetectionKey": file_version,
|
||||
"sourceKind": "local_folder",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn write_local_resource(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<LocalResourceWriteRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let root_uri = request.root_uri.trim();
|
||||
let path = request.path.trim();
|
||||
if root_uri.is_empty() || path.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_resource_write_required_missing",
|
||||
"缺少 rootUri 或 path",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root = parse_file_root_uri(root_uri)?
|
||||
.canonicalize()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let target = resolve_local_file_open_path(root_uri, path)?;
|
||||
let current_key = local_resource_conflict_detection_key(&root, &target)?;
|
||||
if let Some(expected_key) = request
|
||||
.expected_file_version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if expected_key != current_key {
|
||||
return Err(WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_resource_external_change",
|
||||
"本地资源文件已被外部修改,请刷新后再保存",
|
||||
)
|
||||
.with_details(json!({
|
||||
"conflict": {
|
||||
"code": "local_resource_external_change",
|
||||
"rootUri": root_uri,
|
||||
"path": path,
|
||||
"currentDiskVersion": current_key,
|
||||
"editorBaseVersion": expected_key,
|
||||
}
|
||||
}))
|
||||
.with_context(&context));
|
||||
}
|
||||
}
|
||||
let content = local_resource_write_editor_blocks(&request);
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("资源")
|
||||
.to_string();
|
||||
let next_text = if is_markdown_file(&file_name) {
|
||||
editor_blocks_to_markdown_for_file(&content, &root, &target)
|
||||
} else {
|
||||
editor_blocks_to_plain_text(&content)
|
||||
};
|
||||
fs::write(&target, next_text).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_write_failed",
|
||||
format!("无法保存本地资源文件 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let next_key = local_resource_conflict_detection_key(&root, &target)?;
|
||||
Ok(local_folder_ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"ok": true,
|
||||
"rootUri": root_uri,
|
||||
"path": path,
|
||||
"fileName": file_name,
|
||||
"fileVersion": next_key,
|
||||
"conflictDetectionKey": next_key,
|
||||
"contentFormat": request.content_format.unwrap_or_else(|| "editorBlocks".into()),
|
||||
"executedCommand": "resource.file.write",
|
||||
"canonicalCommand": "resource.file.write",
|
||||
"sourceKind": "local_folder",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_local_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| {
|
||||
@@ -5630,6 +5808,167 @@ fn editor_blocks_to_markdown_for_file(
|
||||
editor_blocks_to_markdown_with_rewrite(content, Some((root, markdown_path)))
|
||||
}
|
||||
|
||||
fn local_resource_write_editor_blocks(request: &LocalResourceWriteRequest) -> Value {
|
||||
let format = request
|
||||
.content_format
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let source = if request.content.is_null() {
|
||||
request.tiptap_document.as_ref().unwrap_or(&Value::Null)
|
||||
} else {
|
||||
&request.content
|
||||
};
|
||||
if format == "tiptapdocument" || is_tiptap_document(source) {
|
||||
tiptap_document_to_editor_blocks(source)
|
||||
} else {
|
||||
source.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tiptap_document(value: &Value) -> bool {
|
||||
value.get("type").and_then(Value::as_str) == Some("doc")
|
||||
&& value.get("content").and_then(Value::as_array).is_some()
|
||||
}
|
||||
|
||||
fn tiptap_document_to_editor_blocks(value: &Value) -> Value {
|
||||
let nodes = value
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let blocks = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| {
|
||||
let node_type = node
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("paragraph");
|
||||
let content = node.get("content").cloned().unwrap_or_else(|| json!([]));
|
||||
let text = extract_block_text(&content);
|
||||
let block_type = match node_type {
|
||||
"heading" => "heading",
|
||||
"codeBlock" | "code_block" | "code" => "codeBlock",
|
||||
"blockquote" => "quote",
|
||||
"bulletListItem" | "listItem" => "bulletListItem",
|
||||
"orderedListItem" | "numberedListItem" => "numberedListItem",
|
||||
_ => "paragraph",
|
||||
};
|
||||
let mut block = json!({
|
||||
"id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(&format!("{node_type}:{text}"))),
|
||||
"type": block_type,
|
||||
"content": content,
|
||||
});
|
||||
if node_type == "heading" {
|
||||
let level = node
|
||||
.get("attrs")
|
||||
.and_then(|attrs| attrs.get("level"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 6);
|
||||
block["props"] = json!({ "level": level });
|
||||
}
|
||||
block
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Value::Array(blocks)
|
||||
}
|
||||
|
||||
fn text_to_editor_blocks(text: &str, file_name: &str) -> Value {
|
||||
if is_code_like_file(file_name) {
|
||||
return json!([{
|
||||
"id": "resource-block-1",
|
||||
"type": "code_block",
|
||||
"content": text,
|
||||
"props": {
|
||||
"language": file_extension(file_name).unwrap_or_default()
|
||||
}
|
||||
}]);
|
||||
}
|
||||
let blocks = text
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
json!({
|
||||
"id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(line)),
|
||||
"type": "paragraph",
|
||||
"content": line
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Value::Array(blocks)
|
||||
}
|
||||
|
||||
fn editor_blocks_to_plain_text(content: &Value) -> String {
|
||||
let blocks = if let Some(array) = content.as_array() {
|
||||
array.clone()
|
||||
} else {
|
||||
content
|
||||
.get("blocks")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let text = blocks
|
||||
.iter()
|
||||
.map(extract_block_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if text.ends_with('\n') {
|
||||
text
|
||||
} else {
|
||||
format!("{text}\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn file_extension(file_name: &str) -> Option<String> {
|
||||
Path::new(file_name)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_code_like_file(file_name: &str) -> bool {
|
||||
matches!(
|
||||
file_extension(file_name).as_deref(),
|
||||
Some(
|
||||
"rs" | "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "json"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "html"
|
||||
| "xml"
|
||||
| "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "swift"
|
||||
| "c"
|
||||
| "h"
|
||||
| "cpp"
|
||||
| "hpp"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "sql"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn stable_hash_hex(value: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
value.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
fn editor_blocks_to_markdown_with_rewrite(
|
||||
content: &Value,
|
||||
local_file_context: Option<(&Path, &Path)>,
|
||||
@@ -6201,6 +6540,34 @@ fn local_markdown_conflict_detection_key(
|
||||
))
|
||||
}
|
||||
|
||||
fn local_resource_conflict_detection_key(root: &Path, target: &Path) -> Result<String, WebError> {
|
||||
let content = fs::read(target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_read_failed",
|
||||
format!("无法读取本地资源文件 {}: {error}", target.display()),
|
||||
)
|
||||
})?;
|
||||
let meta = fs::metadata(target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_resource_stat_failed",
|
||||
format!("无法读取本地资源文件状态 {}: {error}", target.display()),
|
||||
)
|
||||
})?;
|
||||
let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
|
||||
let relative_path = target
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|path| path.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_else(|| target.to_string_lossy().to_string());
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
let content_hash = hasher.finish();
|
||||
Ok(format!(
|
||||
"local-resource:{relative_path}:{modified_ms}:{}:{content_hash:016x}",
|
||||
meta.len()
|
||||
))
|
||||
}
|
||||
|
||||
fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value {
|
||||
let outline = content
|
||||
.as_array()
|
||||
@@ -6267,19 +6634,20 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
|
||||
mod tests {
|
||||
use super::{
|
||||
add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base,
|
||||
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,
|
||||
create_local_access_grant, create_share_grant, editor_blocks_to_markdown_for_file,
|
||||
encode_local_id_segment, ensure_local_path_read_access,
|
||||
ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor,
|
||||
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_resource_write_editor_blocks, 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,
|
||||
LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
|
||||
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
};
|
||||
use crate::context::RequestContext;
|
||||
use axum::extract::{Extension, Path as AxumPath, Query};
|
||||
@@ -7734,6 +8102,65 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_resource_markdown_write_targets_asset_not_page_body() {
|
||||
let root = temp_root("mnote-local-resource-md-write");
|
||||
init_workspace(&root);
|
||||
std::fs::write(root.join("Page.md"), "# Page\n\n正文\n").expect("write page");
|
||||
std::fs::create_dir_all(root.join("Page.assets")).expect("create assets");
|
||||
let asset_path = root.join("Page.assets").join("note.md");
|
||||
std::fs::write(&asset_path, "# Asset\n\n旧内容\n").expect("write asset");
|
||||
let blocks = serde_json::json!([
|
||||
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Asset"}]},
|
||||
{"type":"paragraph","content":[{"type":"text","text":"新内容"}]}
|
||||
]);
|
||||
|
||||
let next = editor_blocks_to_markdown_for_file(&blocks, &root, &asset_path);
|
||||
std::fs::write(&asset_path, next).expect("write asset next");
|
||||
|
||||
let asset_text = std::fs::read_to_string(&asset_path).expect("read asset");
|
||||
let page_text = std::fs::read_to_string(root.join("Page.md")).expect("read page");
|
||||
assert!(asset_text.contains("新内容"));
|
||||
assert!(page_text.contains("正文"));
|
||||
assert!(!page_text.contains("新内容"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_resource_write_accepts_tiptap_document_payload() {
|
||||
let request = LocalResourceWriteRequest {
|
||||
root_uri: "file:///tmp/mnote".into(),
|
||||
path: "Page.assets/note.md".into(),
|
||||
content_format: Some("tiptapDocument".into()),
|
||||
tiptap_document: Some(serde_json::json!({
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 2 },
|
||||
"content": [{ "type": "text", "text": "资源标题" }]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "资源正文" }]
|
||||
}
|
||||
]
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let blocks = local_resource_write_editor_blocks(&request);
|
||||
let markdown = editor_blocks_to_markdown_for_file(
|
||||
&blocks,
|
||||
std::path::Path::new("/tmp"),
|
||||
std::path::Path::new("/tmp/note.md"),
|
||||
);
|
||||
|
||||
assert!(markdown.contains("## 资源标题"));
|
||||
assert!(markdown.contains("资源正文"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tree_command_delete_folder_moves_directory_to_trash() {
|
||||
let root = temp_root("mnote-local-delete-folder");
|
||||
|
||||
Reference in New Issue
Block a user