fix local markdown attachment regressions

This commit is contained in:
lix-2026
2026-05-29 11:13:05 +08:00
parent 1109e3c0d8
commit cbe789e034
63 changed files with 3249 additions and 1502 deletions
@@ -6,13 +6,13 @@ use crate::page_aggregate::{
PagePermissions, PageStats, PageTree,
};
use crate::routes::local_markdown_parser::{
file_stem_title, parse_markdown_page, split_frontmatter,
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_search_index;
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::Json;
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::project_legacy_content_to_block_document;
use control_plane::AppendAuditInput;
use control_plane::{
@@ -24,7 +24,7 @@ use core_protocol::{
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use serde_json::{json, Map, Value};
use std::cmp::Ordering;
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet};
@@ -55,8 +55,8 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
> = OnceLock::new();
fn local_page_tree_snapshot_cache()
-> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
fn local_page_tree_snapshot_cache(
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
}
@@ -2966,6 +2966,7 @@ pub fn resolve_local_markdown_page_aggregate(
"本地 rootUri 必须指向目录",
));
}
let root_source_uri = file_uri_for_path(&canonical_root);
let metadata = load_local_folder_metadata(&canonical_root)?;
let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
.ok_or_else(|| {
@@ -2995,6 +2996,20 @@ pub fn resolve_local_markdown_page_aggregate(
&parsed.body,
&attachment_paths,
);
let attachment_refs = parse_markdown_attachment_refs(
&parsed.body,
&markdown_file.path.display().to_string(),
&root_source_uri,
)
.into_iter()
.map(|mut attachment_ref| {
if let Some(path) = attachment_ref.resolved_absolute_path.as_deref() {
let resolved_path = Path::new(path);
attachment_ref.authorized = Some(resolved_path.starts_with(&canonical_root));
}
attachment_ref
})
.collect::<Vec<_>>();
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);
@@ -3063,6 +3078,7 @@ pub fn resolve_local_markdown_page_aggregate(
block_document,
block_projection_version: 1,
projection_source: "local_markdown.content".into(),
attachment_refs: serde_json::to_value(attachment_refs).unwrap_or_else(|_| json!([])),
},
tree: PageTree { page_subtree },
stats: PageStats {
@@ -3927,14 +3943,7 @@ pub(crate) fn write_local_markdown_asset(
));
}
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;
let asset_dir = markdown_dir.to_path_buf();
if !asset_dir.starts_with(&canonical_root) {
return Err(WebError::bad_request_code(
"local_folder_root_escape",
@@ -3965,6 +3974,24 @@ pub(crate) fn write_local_markdown_asset(
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?;
let markdown_href = markdown_href_for_relative_path(&markdown_relative_path);
let mut attachment_ref = parse_markdown_attachment_refs(
&format!(
"[{}]({})",
target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(&sanitized_name),
markdown_href
),
&markdown_file.path.display().to_string(),
&file_uri_for_path(&canonical_root),
)
.into_iter()
.next();
if let Some(ref mut value) = attachment_ref {
value.authorized = Some(true);
}
let asset_type = local_upload_asset_type(kind, &file.content_type);
let mut uploaded_assets = metadata.uploaded_assets;
uploaded_assets.insert(
@@ -3997,6 +4024,8 @@ pub(crate) fn write_local_markdown_asset(
"rootUri": file_uri_for_path(&canonical_root),
"rootRelativePath": root_relative_path,
"markdownRelativePath": markdown_relative_path,
"markdownHref": markdown_href,
"attachmentRef": attachment_ref,
}))
}
@@ -7063,7 +7092,11 @@ fn parent_key_for_relative_path(relative_path: &str) -> String {
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
if value.is_empty() { None } else { Some(value) }
if value.is_empty() {
None
} else {
Some(value)
}
})
.map(|value| normalize_file_order_parent_key(&value))
.unwrap_or_else(|| ".".to_string())
@@ -8130,6 +8163,15 @@ fn normalize_markdown_relative_asset_path(
.join("/"))
}
fn markdown_href_for_relative_path(relative_path: &str) -> String {
let trimmed = relative_path.trim().replace('\\', "/");
if trimmed.starts_with("./") || trimmed.starts_with("../") {
trimmed
} else {
format!("./{trimmed}")
}
}
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/") {
@@ -8384,6 +8426,7 @@ fn editor_blocks_to_markdown_with_rewrite(
lines.push(text);
} else {
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
.map(|value| markdown_href_for_relative_path(&value))
.unwrap_or_else(|| src.to_string());
lines.push(format!(
"![{}]({})",
@@ -8412,9 +8455,7 @@ fn editor_blocks_to_markdown_with_rewrite(
if url.is_empty() {
lines.push(text);
} else {
let url = rewrite_local_open_url_to_markdown_relative(url, local_file_context)
.unwrap_or_else(|| url.to_string());
let label = if name.is_empty() { url.as_str() } else { name };
let label = if name.is_empty() { url } else { name };
lines.push(format!("[{}]({})", label, markdown_link_target(&url)));
}
}
@@ -9041,7 +9082,7 @@ fn inline_styles_from_object(object: &Map<String, Value>) -> Value {
fn markdown_text_with_styles(
text: &str,
styles: &Value,
local_file_context: Option<(&Path, &Path)>,
_local_file_context: Option<(&Path, &Path)>,
) -> String {
let mut value = escape_markdown_inline_text(text);
let link = styles
@@ -9078,8 +9119,6 @@ fn markdown_text_with_styles(
value = format!("~~{value}~~");
}
if let Some(href) = link {
let href =
rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href);
value = format!("[{}]({href})", value.replace(']', r"\]"));
}
value
@@ -9224,10 +9263,6 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
#[cfg(test)]
mod tests {
use super::{
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
SyncConflictReportRequest, SyncPendingChangeRequest,
add_sqlite_local_access_grant_for_context,
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
@@ -9246,15 +9281,18 @@ mod tests {
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
write_sync_conflict_report,
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
};
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, Method, StatusCode};
use axum::Json;
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
@@ -9526,25 +9564,28 @@ mod tests {
"tableCell"
);
assert_eq!(
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]["text"],
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"],
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"],
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"],
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"],
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
["marks"][0]["type"],
"bold"
);
}
@@ -9647,12 +9688,10 @@ mod tests {
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:")
);
assert!(body["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:"));
let _ = std::fs::remove_dir_all(&root);
}
@@ -9674,12 +9713,10 @@ mod tests {
.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:")
);
assert!(result["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:"));
let _ = std::fs::remove_dir_all(&root);
}
@@ -9842,7 +9879,7 @@ fn main() {}
}
#[test]
fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() {
fn local_markdown_save_does_not_migrate_runtime_open_url_inline_link() {
let root = temp_root("mnote-local-uploaded-md-inline-link");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
@@ -9888,28 +9925,18 @@ fn main() {}
)
.expect("save inline attachment link");
assert_eq!(asset["sourcePath"], "notes.md");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[notes.md](notes.md)"));
assert!(!saved.contains("/api/local-folder/files/open"));
let aggregate =
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
let media = aggregate
.body
.content
.as_array()
.expect("blocks")
.iter()
.find(|block| block["type"].as_str() == Some("media"))
.expect("uploaded md inline link should reload as media block");
assert_eq!(media["props"]["sourcePath"], "notes.md");
assert_eq!(asset["sourcePath"], "notes.md");
assert!(
saved.contains("/api/local-folder/files/open"),
"开发态不再把旧 runtime open URL 自动迁移为标准 href"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() {
fn local_markdown_save_does_not_migrate_relative_runtime_open_url() {
let root = temp_root("mnote-local-uploaded-md-relative-open-link");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
@@ -9956,7 +9983,10 @@ fn main() {}
.expect("save relative open url");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[notes.md](notes.md)"));
assert!(
saved.contains("/api/local-folder/files/open"),
"开发态不再把旧 runtime open URL 自动迁移为标准 href"
);
let _ = std::fs::remove_dir_all(&root);
}
@@ -10800,12 +10830,10 @@ fn main() {}
assert_eq!(created["grant"]["ownerUserId"], "user_owner");
assert_eq!(created["grant"]["targetUserId"], "user_target");
assert_eq!(created["grant"]["permission"], "write");
assert!(
created["grant"]["shareId"]
.as_str()
.unwrap_or_default()
.starts_with("folder_")
);
assert!(created["grant"]["shareId"]
.as_str()
.unwrap_or_default()
.starts_with("folder_"));
let outside_error = create_user_share_grant(
Extension(request_context("user_owner", "user")),
@@ -11075,13 +11103,11 @@ fn main() {}
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].id, link_id);
assert_ne!(stored[0].token_hash, "visible-token");
assert!(
state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve share link")
.is_some()
);
assert!(state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve share link")
.is_some());
let (_, Json(listed)) = get_share_links(
State(state.clone()),
@@ -11114,13 +11140,11 @@ fn main() {}
.expect("share revoked broadcast delta");
assert_eq!(revoked_delta["kind"], "control_plane_event");
assert_eq!(revoked_delta["eventType"], "control.share.revoked");
assert!(
state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve revoked share link")
.is_none()
);
assert!(state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve revoked share link")
.is_none());
assert_eq!(
state
.control_plane()
@@ -11324,13 +11348,11 @@ fn main() {}
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"))
);
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");
@@ -11581,12 +11603,11 @@ fn main() {}
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!(root
.join(".mnote")
.join("trash")
.join("Renamed.md")
.is_file());
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
let restored =
@@ -13098,6 +13119,11 @@ fn main() {}
assert_eq!(asset["uploadIntent"], "editor.markdown.attach");
assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png");
assert_eq!(asset["markdownRelativePath"], "photo-1.png");
assert_eq!(asset["markdownHref"], "./photo-1.png");
assert_eq!(asset["attachmentRef"]["rawHref"], "./photo-1.png");
assert_eq!(asset["attachmentRef"]["kind"], "pageLocal");
assert_eq!(asset["attachmentRef"]["openKind"], "image");
assert_eq!(asset["attachmentRef"]["authorized"], true);
assert_eq!(
asset["ownerDocumentId"],
"local-md:docs~2FREADME~2FREADME.md"
@@ -13122,6 +13148,8 @@ fn main() {}
assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach");
assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md");
assert_eq!(markdown_asset["markdownRelativePath"], "notes.md");
assert_eq!(markdown_asset["markdownHref"], "./notes.md");
assert_eq!(markdown_asset["attachmentRef"]["openKind"], "text");
assert_eq!(
markdown_asset["ownerDocumentId"],
"local-md:docs~2FREADME~2FREADME.md"
@@ -13155,6 +13183,31 @@ fn main() {}
.expect("uploaded asset index");
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
std::fs::write(root.join("docs").join("Loose.md"), "# Loose\n").expect("write loose md");
let loose_asset = write_local_markdown_asset(
&root_uri,
"local-md:docs~2FLoose.md",
"attachment",
LocalUploadFile {
name: "loose.pdf".to_string(),
content_type: "application/pdf".to_string(),
bytes: b"%PDF-1.4\n".to_vec(),
},
)
.expect("upload loose markdown asset");
assert_eq!(loose_asset["sourcePath"], "loose.pdf");
assert_eq!(loose_asset["rootRelativePath"], "docs/loose.pdf");
assert_eq!(loose_asset["markdownRelativePath"], "loose.pdf");
assert_eq!(loose_asset["markdownHref"], "./loose.pdf");
assert!(
root.join("docs").join("loose.pdf").is_file(),
"非 bundle Markdown 上传应写入 md 同目录"
);
assert!(
!root.join("docs").join("Loose").join("loose.pdf").exists(),
"非 bundle Markdown 上传不应写入同名子目录"
);
let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README")
.expect("file tree");
let items = snapshot.projection["items"].as_array().expect("items");