use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment}; use comrak::{parse_document, Arena, Options}; use serde::Serialize; use serde_json::{json, Map, Value}; use std::collections::BTreeSet; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct ParsedLocalMarkdownPage { pub title: String, pub body: String, } #[derive(Debug, Clone)] struct MarkdownAstDocument { blocks: Vec, } #[derive(Debug, Clone)] enum MarkdownBlock { Paragraph(Vec), Heading { level: u8, content: Vec, }, Quote(Vec), CodeBlock { language: String, text: String, }, Divider, BulletListItem(Vec), NumberedListItem(Vec), Todo { checked: bool, content: Vec, }, Table { alignments: Vec, rows: Vec, }, Image { alt: String, source_path: String, }, Mindmap { name: String, source_path: String, }, Media { name: String, source_path: String, }, } #[derive(Debug, Clone)] struct MarkdownTableRow { is_header: bool, cells: Vec, } #[derive(Debug, Clone)] struct MarkdownTableCell { content: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] struct MarkdownInline { text: String, styles: MarkdownInlineStyles, } #[derive(Debug, Clone, Default, PartialEq, Eq)] struct MarkdownInlineStyles { bold: bool, italic: bool, strike: bool, underline: bool, code: bool, link: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "camelCase")] pub struct AttachmentSourceRange { pub start: usize, pub end: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AttachmentRef { pub ref_id: String, pub owner_document_path: String, pub owner_root_uri: String, pub raw_href: String, pub normalized_href: String, pub label: String, pub kind: String, pub resolved_uri: Option, pub resolved_absolute_path: Option, pub relative_path: Option, pub ext: Option, pub content_type: Option, pub exists: Option, pub authorized: Option, pub open_kind: String, pub source_range: Option, } pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage { let (_frontmatter, body) = split_frontmatter(markdown); let body_owned = body.to_string(); // 本地 Markdown 的树标题和页头标题统一来自文件名; // frontmatter title 与正文 H1 都保留为正文/元数据内容,不作为资源标题真相。 let title = file_stem_title(file_name); ParsedLocalMarkdownPage { title, body: body_owned, } } pub fn markdown_to_blocks(markdown: &str) -> Value { markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new()) } pub fn markdown_to_blocks_with_attachment_paths( markdown: &str, attachment_paths: &BTreeSet, ) -> Value { markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths)) } pub fn parse_markdown_attachment_refs( markdown: &str, owner_document_path: &str, owner_root_uri: &str, ) -> Vec { let arena = Arena::new(); let options = markdown_options(); let root = parse_document(&arena, markdown, &options); let mut refs = Vec::new(); let mut cursor = 0usize; collect_attachment_refs_from_ast( root, markdown, owner_document_path, owner_root_uri, &mut cursor, &mut refs, ); collect_html_embed_attachment_refs(markdown, owner_document_path, owner_root_uri, &mut refs); refs } fn markdown_options() -> Options<'static> { let mut options = Options::default(); options.extension.table = true; options.extension.tasklist = true; options.extension.strikethrough = true; options.extension.autolink = true; options.extension.front_matter_delimiter = Some("---".to_string()); options.parse.tasklist_in_table = true; options } pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> { parse_markdown_attachment_link_with_paths(trimmed, &BTreeSet::new()) } fn parse_markdown_attachment_link_with_paths( trimmed: &str, attachment_paths: &BTreeSet, ) -> Option<(String, String)> { let value = trimmed .strip_prefix('!') .unwrap_or(trimmed) .strip_prefix('[')?; let (label, rest) = value.split_once("](")?; let raw_target = rest.strip_suffix(')')?.trim(); let target = raw_target .strip_prefix('<') .and_then(|value| value.strip_suffix('>')) .unwrap_or(raw_target) .trim(); if target.is_empty() || target.starts_with("http://") || target.starts_with("https://") || target.starts_with('#') || target.starts_with("mailto:") { return None; } let target_path = std::path::Path::new(target); let extension = target_path.extension().and_then(|value| value.to_str())?; if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")) && !attachment_paths.contains(target) { return None; } let fallback_name = target_path .file_name() .and_then(|value| value.to_str()) .unwrap_or(target) .trim(); let name = if label.trim().is_empty() { fallback_name } else { label.trim() }; Some((name.to_string(), target.to_string())) } fn collect_attachment_refs_from_ast<'a>( node: &'a AstNode<'a>, markdown: &str, owner_document_path: &str, owner_root_uri: &str, cursor: &mut usize, refs: &mut Vec, ) { match &node.data.borrow().value { NodeValue::Link(link) => { let raw_href = link.url.trim(); if should_collect_attachment_href(raw_href) { let label = collect_plain_text(node); let source_range = find_href_source_range(markdown, raw_href, cursor); refs.push(build_attachment_ref( owner_document_path, owner_root_uri, raw_href, label.trim(), source_range, )); } } NodeValue::Image(link) => { let raw_href = link.url.trim(); if should_collect_attachment_href(raw_href) { let label = collect_plain_text(node); let source_range = find_href_source_range(markdown, raw_href, cursor); refs.push(build_attachment_ref( owner_document_path, owner_root_uri, raw_href, label.trim(), source_range, )); } } _ => {} } for child in node.children() { collect_attachment_refs_from_ast( child, markdown, owner_document_path, owner_root_uri, cursor, refs, ); } } fn collect_html_embed_attachment_refs( markdown: &str, owner_document_path: &str, owner_root_uri: &str, refs: &mut Vec, ) { let lower = markdown.to_ascii_lowercase(); let mut offset = 0usize; while let Some(relative_start) = lower[offset..].find("') else { break; }; let end = start + relative_end + 1; let fragment = &markdown[start..end]; if let Some((raw_href, value_start, value_end)) = extract_html_attr(fragment, "src").or_else(|| extract_html_attr(fragment, "href")) { let source_range = Some(AttachmentSourceRange { start: start + value_start, end: start + value_end, }); refs.push(build_attachment_ref( owner_document_path, owner_root_uri, raw_href, raw_href, source_range, )); } offset = end; } } fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usize, usize)> { let lower = fragment.to_ascii_lowercase(); let needle = format!("{name}="); let attr_start = lower.find(&needle)? + needle.len(); let bytes = fragment.as_bytes(); let quote = *bytes.get(attr_start)?; if quote != b'"' && quote != b'\'' { return None; } let value_start = attr_start + 1; let value_end = fragment[value_start..] .find(quote as char) .map(|index| value_start + index)?; Some((&fragment[value_start..value_end], value_start, value_end)) } fn should_collect_attachment_href(raw_href: &str) -> bool { let trimmed = raw_href.trim(); !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:") } fn build_attachment_ref( owner_document_path: &str, owner_root_uri: &str, raw_href: &str, label: &str, source_range: Option, ) -> AttachmentRef { let raw_href = raw_href.trim(); let normalized_href = normalize_attachment_href(raw_href); let owner_document = Path::new(owner_document_path); let owner_dir = owner_document.parent(); let (kind, resolved_absolute_path, relative_path) = resolve_attachment_path(&normalized_href, owner_dir, owner_root_uri); let resolved_uri = resolved_absolute_path .as_ref() .map(|path| file_uri_for_attachment_path(Path::new(path))) .or_else(|| { if is_remote_href(&normalized_href) { Some(normalized_href.clone()) } else { None } }); let ext = attachment_extension(&normalized_href, resolved_absolute_path.as_deref()); let content_type = ext .as_deref() .and_then(attachment_content_type) .map(str::to_string); let exists = resolved_absolute_path .as_ref() .map(|path| Path::new(path).exists()); let open_kind = attachment_open_kind(ext.as_deref()).to_string(); let label = if label.trim().is_empty() { resolved_absolute_path .as_ref() .and_then(|path| Path::new(path).file_name()) .and_then(|name| name.to_str()) .or_else(|| { Path::new(&normalized_href) .file_name() .and_then(|name| name.to_str()) }) .unwrap_or(raw_href) .to_string() } else { label.trim().to_string() }; AttachmentRef { ref_id: attachment_ref_id(owner_document_path, raw_href, &source_range), owner_document_path: owner_document_path.to_string(), owner_root_uri: owner_root_uri.to_string(), raw_href: raw_href.to_string(), normalized_href, label, kind, resolved_uri, resolved_absolute_path, relative_path, ext, content_type, exists, authorized: None, open_kind, source_range, } } fn resolve_attachment_path( href: &str, owner_dir: Option<&Path>, owner_root_uri: &str, ) -> (String, Option, Option) { if is_remote_href(href) { return ("remoteUrl".to_string(), None, None); } if let Some(path) = href.strip_prefix("file://").and_then(file_uri_path) { return ( "externalFile".to_string(), Some(normalize_path_string(&path)), None, ); } let href_path = Path::new(href); if href_path.is_absolute() { return ( "externalFile".to_string(), Some(normalize_path_string(href_path)), None, ); } let decoded_href = percent_decode_lossy(href); if decoded_href.starts_with("../") || decoded_href.contains("/../") { return ("unknown".to_string(), None, None); } let relative_path = decoded_href .strip_prefix("./") .unwrap_or(&decoded_href) .replace('\\', "/"); let resolved = owner_dir.map(|dir| normalize_path_string(&dir.join(&decoded_href))); let root_path = owner_root_path(owner_root_uri); let relative_to_root = resolved .as_deref() .and_then(|path| { root_path .as_ref() .and_then(|root| relative_to_root_path(path, root)) }) .or(Some(relative_path)); ("pageLocal".to_string(), resolved, relative_to_root) } fn normalize_attachment_href(raw_href: &str) -> String { let trimmed = raw_href .trim() .strip_prefix('<') .and_then(|value| value.strip_suffix('>')) .unwrap_or(raw_href.trim()) .trim(); if Path::new(trimmed).is_absolute() { file_uri_for_attachment_path(Path::new(trimmed)) } else { trimmed.to_string() } } fn file_uri_path(value: &str) -> Option { let path = if let Some(rest) = value.strip_prefix("localhost/") { format!("/{rest}") } else if value.starts_with('/') { value.to_string() } else { format!("/{value}") }; Some(PathBuf::from(percent_decode_lossy(&path))) } fn owner_root_path(root_uri: &str) -> Option { root_uri .strip_prefix("file://") .and_then(file_uri_path) .or_else(|| { let path = Path::new(root_uri); if path.is_absolute() { Some(path.to_path_buf()) } else { None } }) } fn relative_to_root_path(path: &str, root: &Path) -> Option { Path::new(path) .strip_prefix(root) .ok() .map(|value| value.to_string_lossy().replace('\\', "/")) } fn find_href_source_range( markdown: &str, raw_href: &str, cursor: &mut usize, ) -> Option { let raw_href = raw_href.trim(); let start = markdown .get(*cursor..) .and_then(|tail| tail.find(raw_href).map(|index| *cursor + index)) .or_else(|| markdown.find(raw_href))?; let end = start + raw_href.len(); *cursor = end; Some(AttachmentSourceRange { start, end }) } fn is_remote_href(href: &str) -> bool { href.starts_with("http://") || href.starts_with("https://") } fn attachment_extension(href: &str, path: Option<&str>) -> Option { path.or(Some(href)) .and_then(|value| Path::new(value).extension()) .and_then(|value| value.to_str()) .map(|value| value.trim_start_matches('.').to_ascii_lowercase()) .filter(|value| !value.is_empty()) } fn attachment_content_type(ext: &str) -> Option<&'static str> { match ext { "png" => Some("image/png"), "jpg" | "jpeg" => Some("image/jpeg"), "gif" => Some("image/gif"), "webp" => Some("image/webp"), "svg" => Some("image/svg+xml"), "pdf" => Some("application/pdf"), "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"), "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), "mp3" => Some("audio/mpeg"), "wav" => Some("audio/wav"), "mp4" => Some("video/mp4"), "webm" => Some("video/webm"), "md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml" | "yaml" | "yml" => Some("text/plain"), _ => None, } } fn attachment_open_kind(ext: Option<&str>) -> &'static str { match ext.unwrap_or_default() { "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "image", "pdf" => "pdf", "doc" | "docx" | "ppt" | "pptx" | "xls" | "xlsx" => "office", "mp3" | "wav" | "ogg" | "m4a" => "audio", "mp4" | "webm" | "mov" | "mkv" => "video", "md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml" | "yaml" | "yml" => "text", "" => "unknown", _ => "download", } } fn normalize_path_string(path: &Path) -> String { let mut normalized = PathBuf::new(); for component in path.components() { match component { std::path::Component::CurDir => {} std::path::Component::ParentDir => { normalized.pop(); } _ => normalized.push(component.as_os_str()), } } normalized.to_string_lossy().replace('\\', "/") } fn file_uri_for_attachment_path(path: &Path) -> String { format!("file://{}", normalize_path_string(path)) } fn percent_decode_lossy(value: &str) -> String { let bytes = value.as_bytes(); let mut output = Vec::with_capacity(bytes.len()); let mut index = 0usize; while index < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() { if let (Some(high), Some(low)) = (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) { output.push(high * 16 + low); index += 3; continue; } } output.push(bytes[index]); index += 1; } String::from_utf8_lossy(&output).into_owned() } fn hex_value(value: u8) -> Option { match value { b'0'..=b'9' => Some(value - b'0'), b'a'..=b'f' => Some(value - b'a' + 10), b'A'..=b'F' => Some(value - b'A' + 10), _ => None, } } fn attachment_ref_id( owner_document_path: &str, raw_href: &str, source_range: &Option, ) -> String { let mut hasher = std::collections::hash_map::DefaultHasher::new(); owner_document_path.hash(&mut hasher); raw_href.hash(&mut hasher); source_range.hash(&mut hasher); format!("attachment:{:016x}", hasher.finish()) } fn parse_markdown_ast_document( markdown: &str, attachment_paths: &BTreeSet, ) -> MarkdownAstDocument { let arena = Arena::new(); let options = markdown_options(); let root = parse_document(&arena, markdown, &options); let mut blocks = Vec::new(); for node in root.children() { append_ast_block(node, &mut blocks, attachment_paths); } MarkdownAstDocument { blocks } } fn append_ast_block<'a>( node: &'a AstNode<'a>, blocks: &mut Vec, attachment_paths: &BTreeSet, ) { match node.data.borrow().value.clone() { NodeValue::Paragraph => append_ast_paragraph(node, blocks, attachment_paths), NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading { level: heading.level, content: collect_inline_children(node), }), NodeValue::BlockQuote => blocks.push(MarkdownBlock::Quote(collect_inline_children(node))), NodeValue::ThematicBreak => blocks.push(MarkdownBlock::Divider), NodeValue::CodeBlock(code_block) => blocks.push(MarkdownBlock::CodeBlock { language: code_block.info.clone(), text: code_block.literal.clone(), }), NodeValue::List(list) => { for item in node.children() { append_ast_list_item( item, list.list_type == ListType::Ordered, blocks, attachment_paths, ); } } NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)), NodeValue::HtmlBlock(html) => blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline { text: html.literal.clone(), styles: MarkdownInlineStyles::default(), }])), NodeValue::FrontMatter(_) => {} _ => { let text = collect_plain_text(node); if !text.is_empty() { blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline { text, styles: MarkdownInlineStyles::default(), }])); } } } } fn append_ast_paragraph<'a>( node: &'a AstNode<'a>, blocks: &mut Vec, attachment_paths: &BTreeSet, ) { if let Some((alt, source_path)) = paragraph_image(node) { blocks.push(MarkdownBlock::Image { alt, source_path }); return; } if let Some((name, source_path)) = paragraph_mindmap(node) { blocks.push(MarkdownBlock::Mindmap { name, source_path }); return; } if let Some((name, source_path)) = paragraph_attachment_media(node, attachment_paths) { blocks.push(MarkdownBlock::Media { name, source_path }); return; } if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node, attachment_paths) { blocks.push(MarkdownBlock::Media { name, source_path }); let content = merge_adjacent_inline_nodes(remaining); if !content.is_empty() { blocks.push(MarkdownBlock::Paragraph(content)); } return; } blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node))); } fn append_ast_list_item<'a>( node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec, attachment_paths: &BTreeSet, ) { let (is_task, checked) = match node.data.borrow().value.clone() { NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()), NodeValue::Item(_) => (false, false), _ => return append_ast_block(node, blocks, attachment_paths), }; let mut content = Vec::new(); for child in node.children() { match child.data.borrow().value.clone() { NodeValue::Paragraph => content.extend(collect_inline_children(child)), _ => append_ast_block(child, blocks, attachment_paths), } } if is_task { blocks.push(MarkdownBlock::Todo { checked, content }); } else if ordered { blocks.push(MarkdownBlock::NumberedListItem(content)); } else { blocks.push(MarkdownBlock::BulletListItem(content)); } } fn ast_table_to_ir<'a>(node: &'a AstNode<'a>, alignments: Vec) -> MarkdownBlock { let rows = node .children() .map(|row| { let is_header = matches!(row.data.borrow().value, NodeValue::TableRow(true)); let cells = row .children() .map(|cell| MarkdownTableCell { content: collect_inline_children(cell), }) .collect::>(); MarkdownTableRow { is_header, cells } }) .collect::>(); MarkdownBlock::Table { alignments, rows } } fn collect_inline_children<'a>(node: &'a AstNode<'a>) -> Vec { let mut nodes = Vec::new(); for child in node.children() { collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut nodes); } merge_adjacent_inline_nodes(nodes) } fn collect_inline_nodes<'a>( node: &'a AstNode<'a>, active_styles: &MarkdownInlineStyles, nodes: &mut Vec, ) { match node.data.borrow().value.clone() { NodeValue::Text(text) => push_inline_text_node(nodes, text.as_ref(), active_styles), NodeValue::TaskItem(task_item) => { let marker = if task_item.symbol.is_some() { "[x] " } else { "[ ] " }; push_inline_text_node(nodes, marker, active_styles); for child in node.children() { collect_inline_nodes(child, active_styles, nodes); } } NodeValue::Code(code) => { let mut styles = active_styles.clone(); styles.code = true; push_inline_text_node(nodes, &code.literal, &styles); } NodeValue::Strong => { let mut styles = active_styles.clone(); styles.bold = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Emph => { let mut styles = active_styles.clone(); styles.italic = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Strikethrough => { let mut styles = active_styles.clone(); styles.strike = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Underline => { let mut styles = active_styles.clone(); styles.underline = true; collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::Link(link) => { let mut styles = active_styles.clone(); styles.link = Some(link.url.clone()); collect_inline_children_with_styles(node, &styles, nodes); } NodeValue::SoftBreak | NodeValue::LineBreak => { push_inline_text_node(nodes, " ", active_styles); } NodeValue::HtmlInline(text) => push_inline_text_node(nodes, text.as_ref(), active_styles), NodeValue::Image(link) => { let mut styles = active_styles.clone(); styles.link = Some(link.url.clone()); push_inline_text_node(nodes, link.url.as_str(), &styles); } _ => collect_inline_children_with_styles(node, active_styles, nodes), } } fn collect_inline_children_with_styles<'a>( node: &'a AstNode<'a>, active_styles: &MarkdownInlineStyles, nodes: &mut Vec, ) { for child in node.children() { collect_inline_nodes(child, active_styles, nodes); } } fn push_inline_text_node( nodes: &mut Vec, text: &str, styles: &MarkdownInlineStyles, ) { if text.is_empty() { return; } nodes.push(MarkdownInline { text: text.to_string(), styles: styles.clone(), }); } fn merge_adjacent_inline_nodes(nodes: Vec) -> Vec { let mut merged = Vec::::new(); for node in nodes { if let Some(last) = merged.last_mut() { if last.styles == node.styles { last.text.push_str(&node.text); continue; } } merged.push(node); } merged } fn paragraph_attachment_media<'a>( node: &'a AstNode<'a>, attachment_paths: &BTreeSet, ) -> Option<(String, String)> { let mut children = node.children(); let first = children.next()?; if children.next().is_some() { return None; } link_attachment_media(first, attachment_paths) } fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { let mut children = node.children(); let first = children.next()?; if children.next().is_some() { return None; } link_mindmap(first) } fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { let mut children = node.children(); let first = children.next()?; if children.next().is_some() { return None; } let NodeValue::Image(link) = &first.data.borrow().value else { return None; }; let alt = collect_plain_text(first).trim().to_string(); Some((alt, link.url.clone())) } fn paragraph_leading_attachment_media<'a>( node: &'a AstNode<'a>, attachment_paths: &BTreeSet, ) -> Option<(String, String, Vec)> { let mut children = node.children(); let first = children.next()?; let (name, source_path) = link_attachment_media(first, attachment_paths)?; let second = children.next()?; if !matches!( second.data.borrow().value, NodeValue::SoftBreak | NodeValue::LineBreak ) { return None; } let mut remaining = Vec::new(); for child in children { collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut remaining); } Some((name, source_path, remaining)) } fn link_attachment_media<'a>( node: &'a AstNode<'a>, attachment_paths: &BTreeSet, ) -> Option<(String, String)> { let NodeValue::Link(link) = &node.data.borrow().value else { return None; }; parse_markdown_attachment_link_with_paths( &format!("[{}]({})", collect_plain_text(node), link.url), attachment_paths, ) } fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { let NodeValue::Link(link) = &node.data.borrow().value else { return None; }; let target = link.url.trim(); let file_name = std::path::Path::new(target) .file_name() .and_then(|value| value.to_str()) .unwrap_or(target) .trim(); let lower = file_name.to_ascii_lowercase(); let is_mindmap = lower.ends_with(".mindmap.json") || (file_name.starts_with("思维导图") && lower.ends_with(".json")); if !is_mindmap { return None; } let name = collect_plain_text(node).trim().to_string(); Some(( if name.is_empty() { "思维导图".to_string() } else { name }, target.to_string(), )) } fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value { Value::Array( document .blocks .iter() .enumerate() .map(|(index, block)| markdown_block_to_json(block, index + 1)) .collect(), ) } fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value { match block { MarkdownBlock::Paragraph(content) => json_block( "paragraph", legacy_inline_nodes_to_json(content), block_number, ), MarkdownBlock::Heading { level, content } => json!({ "id": format!("local-block-{block_number}"), "type": "heading", "props": { "level": level }, "content": legacy_inline_nodes_to_json(content), "children": [], }), MarkdownBlock::Quote(content) => { json_block("quote", legacy_inline_nodes_to_json(content), block_number) } MarkdownBlock::CodeBlock { language, text } => json!({ "id": format!("local-block-{block_number}"), "type": "codeBlock", "props": { "language": language }, "content": [{ "type": "text", "text": text, "styles": {} }], "children": [], }), MarkdownBlock::Divider => json!({ "id": format!("local-block-{block_number}"), "type": "divider", "content": [], "children": [], }), MarkdownBlock::BulletListItem(content) => json_block( "bulletListItem", legacy_inline_nodes_to_json(content), block_number, ), MarkdownBlock::NumberedListItem(content) => json_block( "numberedListItem", legacy_inline_nodes_to_json(content), block_number, ), MarkdownBlock::Todo { checked, content } => json!({ "id": format!("local-block-{block_number}"), "type": "todo", "props": { "checked": checked }, "content": legacy_inline_nodes_to_json(content), "children": [], }), MarkdownBlock::Table { alignments, rows } => { table_block_to_json(alignments, rows, block_number) } MarkdownBlock::Image { alt, source_path } => json!({ "id": format!("local-block-{block_number}"), "type": "image", "props": { "src": source_path, "alt": alt, "title": alt, }, "content": [], "children": [], }), MarkdownBlock::Mindmap { name, source_path } => json!({ "id": format!("local-block-{block_number}"), "type": "mindmap", "props": { "name": name, "sourcePath": source_path, "mindmapId": source_path, "rootNodeId": "root", }, "content": [], "children": [], }), MarkdownBlock::Media { name, source_path } => json!({ "id": format!("local-block-{block_number}"), "type": "media", "props": { "name": name, "sourcePath": source_path, }, "content": [], "children": [], }), } } fn table_block_to_json( alignments: &[TableAlignment], rows: &[MarkdownTableRow], block_number: usize, ) -> Value { let content_rows = rows .iter() .map(|row| { let cells = row .cells .iter() .enumerate() .map(|(column_index, cell)| { let cell_type = if row.is_header { "tableHeader" } else { "tableCell" }; let text_align = match alignments .get(column_index) .copied() .unwrap_or(TableAlignment::None) { TableAlignment::Left => "left", TableAlignment::Center => "center", TableAlignment::Right => "right", TableAlignment::None => "", }; json!({ "type": cell_type, "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null, "textAlign": text_align, }, "content": [{ "type": "paragraph", "content": tiptap_inline_nodes_to_json(&cell.content), }] }) }) .collect::>(); json!({ "type": "tableRow", "content": cells, }) }) .collect::>(); json!({ "id": format!("local-block-{block_number}"), "type": "table", "props": { "tiptapTable": { "type": "table", "attrs": { "blockId": format!("local-block-{block_number}") }, "content": content_rows, } }, "content": [], "children": [], }) } fn json_block(block_type: &str, content: Vec, block_number: usize) -> Value { json!({ "id": format!("local-block-{block_number}"), "type": block_type, "content": content, "children": [], }) } fn legacy_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec { nodes .iter() .map(|node| { json!({ "type": "text", "text": node.text, "styles": legacy_styles_to_json(&node.styles), }) }) .collect() } fn legacy_styles_to_json(styles: &MarkdownInlineStyles) -> Value { let mut object = Map::new(); if styles.bold { object.insert("bold".to_string(), Value::Bool(true)); } if styles.italic { object.insert("italic".to_string(), Value::Bool(true)); } if styles.strike { object.insert("strike".to_string(), Value::Bool(true)); } if styles.underline { object.insert("underline".to_string(), Value::Bool(true)); } if styles.code { object.insert("code".to_string(), Value::Bool(true)); } if let Some(href) = styles .link .as_deref() .map(str::trim) .filter(|href| !href.is_empty()) { object.insert("link".to_string(), Value::String(href.to_string())); } Value::Object(object) } fn tiptap_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec { nodes .iter() .filter(|node| !node.text.is_empty()) .map(|node| { let mut object = Map::new(); object.insert("type".to_string(), Value::String("text".to_string())); object.insert("text".to_string(), Value::String(node.text.clone())); let marks = tiptap_marks_from_styles(&node.styles); if !marks.is_empty() { object.insert("marks".to_string(), Value::Array(marks)); } Value::Object(object) }) .collect() } fn tiptap_marks_from_styles(styles: &MarkdownInlineStyles) -> Vec { let mut marks = Vec::new(); if styles.bold { marks.push(json!({ "type": "bold" })); } if styles.italic { marks.push(json!({ "type": "italic" })); } if styles.underline { marks.push(json!({ "type": "underline" })); } if styles.strike { marks.push(json!({ "type": "strike" })); } if styles.code { marks.push(json!({ "type": "code" })); } if let Some(href) = styles .link .as_deref() .map(str::trim) .filter(|href| !href.is_empty()) { marks.push(json!({ "type": "link", "attrs": { "href": href } })); } marks } fn collect_plain_text<'a>(node: &'a AstNode<'a>) -> String { let mut parts = Vec::new(); for descendant in node.descendants() { if let NodeValue::Text(text) = &descendant.data.borrow().value { parts.push(text.as_ref().to_string()); } } parts.join("") } pub(crate) fn split_frontmatter(markdown: &str) -> (Option, &str) { let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown); if !normalized.starts_with("---\n") { return (None, normalized); } let rest = &normalized[4..]; if let Some(end) = rest.find("\n---\n") { let frontmatter = rest[..end].to_string(); let body = &rest[end + 5..]; return (Some(frontmatter), body); } (None, normalized) } pub(crate) fn file_stem_title(file_name: &str) -> String { std::path::Path::new(file_name) .file_stem() .and_then(|stem| stem.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(file_name) .to_string() } #[cfg(test)] mod tests { use super::{markdown_to_blocks, parse_markdown_attachment_refs, parse_markdown_page}; #[test] fn markdown_image_parses_as_image_block() { let blocks = markdown_to_blocks("![示例图片](assets/photo.jpg)\n"); let first = blocks .as_array() .and_then(|items| items.first()) .expect("first block"); assert_eq!(first["type"].as_str(), Some("image")); assert_eq!(first["props"]["src"].as_str(), Some("assets/photo.jpg")); assert_eq!(first["props"]["alt"].as_str(), Some("示例图片")); } #[test] fn markdown_attachment_refs_parse_standard_href_variants() { let root = std::env::temp_dir().join(format!( "mnote-attachment-ref-parser-{}", std::process::id() )); let owner_dir = root.join("docs"); std::fs::create_dir_all(&owner_dir).expect("create owner dir"); std::fs::write(owner_dir.join("同目录 文件.pdf"), b"pdf").expect("write relative file"); std::fs::write(owner_dir.join("figure.png"), b"png").expect("write image file"); let owner = owner_dir.join("page.md"); let external = root.join("external.docx"); std::fs::write(&external, b"docx").expect("write external file"); let markdown = format!( "[同目录 PDF](./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf)\n\ ![图片](./figure.png)\n\ [外部](file://{})\n\ [网页资料](https://example.com/paper.pdf)\n\ [裸绝对]({})\n\ \n", external.display(), external.display() ); let refs = parse_markdown_attachment_refs( &markdown, &owner.display().to_string(), &format!("file://{}", root.display()), ); assert_eq!(refs.len(), 6); assert_eq!( refs[0].raw_href, "./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf" ); assert_eq!(refs[0].kind, "pageLocal"); assert_eq!(refs[0].open_kind, "pdf"); assert_eq!(refs[0].exists, Some(true)); assert_eq!( refs[0].relative_path, Some("docs/同目录 文件.pdf".to_string()) ); assert_eq!(refs[1].open_kind, "image"); assert_eq!(refs[2].kind, "externalFile"); assert_eq!(refs[2].open_kind, "office"); assert_eq!(refs[3].kind, "remoteUrl"); assert_eq!( refs[3].resolved_uri, Some("https://example.com/paper.pdf".to_string()) ); assert_eq!( refs[4].normalized_href, format!("file://{}", external.display()) ); assert_eq!(refs[5].raw_href, "./missing.pptx"); assert_eq!(refs[5].open_kind, "office"); assert_eq!(refs[5].exists, Some(false)); assert!(refs.iter().all(|item| item.source_range.is_some())); let _ = std::fs::remove_dir_all(&root); } #[test] fn markdown_attachment_refs_do_not_resolve_parent_directory_relative_paths() { let root = std::env::temp_dir().join(format!( "mnote-attachment-ref-parent-relative-{}", std::process::id() )); let owner_dir = root.join("docs"); std::fs::create_dir_all(&owner_dir).expect("create owner dir"); let owner = owner_dir.join("page.md"); let refs = parse_markdown_attachment_refs( "[上级目录](../assets/a.pdf)\n", &owner.display().to_string(), &format!("file://{}", root.display()), ); assert_eq!(refs.len(), 1); assert_eq!(refs[0].raw_href, "../assets/a.pdf"); assert_eq!(refs[0].kind, "unknown"); assert_eq!(refs[0].resolved_absolute_path, None); assert_eq!(refs[0].relative_path, None); assert_eq!(refs[0].exists, None); let _ = std::fs::remove_dir_all(&root); } /// 固定空引用块行为:`>` 在 GFM AST 中产生 BlockQuote 节点, /// collect_inline_children 返回空 vec → 输出 type=quote content=[]。 #[test] fn markdown_empty_blockquote_parse() { let blocks = markdown_to_blocks("> \n\n>"); let array = blocks.as_array().expect("blocks"); assert!(!array.is_empty(), "应至少产生一个引用块"); for block in array.iter() { if block["type"].as_str() == Some("quote") { let content = block["content"].as_array().expect("quote content"); assert!( content.is_empty(), "空引用块 content 应为空,实际 {:?}", content ); } } } /// 固定空表格单元格行为:comrak 解析空单元格为空的 tiptap paragraph, /// content 字段为 []。 #[test] fn markdown_table_empty_cells_parse() { let blocks = markdown_to_blocks("| 左 | |\n| --- | --- |\n| | 右 |\n"); let array = blocks.as_array().expect("blocks"); let table = array .iter() .find(|b| b["type"].as_str() == Some("table")) .expect("should have table block"); let tiptap_table = table["props"]["tiptapTable"] .as_object() .expect("tiptapTable object"); let content_rows = tiptap_table["content"] .as_array() .expect("table content rows"); // 表头行:两个单元格 let header_row = &content_rows[0]["content"] .as_array() .expect("header row cells"); assert_eq!(header_row.len(), 2); // 表头第一个单元格(非空) let h1_cell = &header_row[0]["content"] .as_array() .expect("header cell paragraphs"); assert!(!h1_cell.is_empty(), "表头第一个单元格不应为空"); // 表头第二个单元格(空):单元格的 content 为 [{type:"paragraph", content:[]}] // 段落层的 content 应为空数组 let h2_par_content = &header_row[1]["content"][0]["content"]; let h2_inline = h2_par_content.as_array().expect("header cell para content"); assert!( h2_inline.is_empty(), "空表头单元格的 paragraph content 应为空,实际值: {}", serde_json::to_string_pretty(h2_par_content).unwrap() ); // 数据行 let data_row = &content_rows[1]["content"] .as_array() .expect("data row cells"); assert_eq!(data_row.len(), 2); // 数据行第一个单元格(空):段落层的 content 应为空数组 let d1_par_content = &data_row[0]["content"][0]["content"]; let d1_inline = d1_par_content.as_array().expect("data cell para content"); assert!( d1_inline.is_empty(), "空数据单元格的 paragraph content 应为空,实际值: {}", serde_json::to_string_pretty(d1_par_content).unwrap() ); // 数据行第二个单元格(非空):段落层的 content 不应为空 let d2_par_content = &data_row[1]["content"][0]["content"]; let d2_inline = d2_par_content.as_array().expect("data cell para content"); assert!(!d2_inline.is_empty(), "非空数据单元格不应为空"); } // 本地 Markdown 标题统一来自文件名,frontmatter title 和 H1 都只保留为正文/元数据。 #[test] fn parse_markdown_title_uses_filename_over_frontmatter_and_h1() { let parsed = parse_markdown_page( "---\ntitle: Frontmatter Title\n---\n# H1 Heading\nbody\n", "File Name.md", ); assert_eq!(parsed.title, "File Name"); } #[test] fn parse_markdown_title_uses_filename_when_h1_exists() { let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "File Name.md"); assert_eq!(parsed.title, "File Name"); } #[test] fn parse_markdown_title_uses_filename_when_body_has_no_h1() { let parsed = parse_markdown_page("plain text\nmore text\n", "My File.md"); assert_eq!(parsed.title, "My File"); } #[test] fn parse_markdown_title_ignores_frontmatter_without_title() { let parsed = parse_markdown_page("---\ntags: foo\n---\nplain text\n", "NoH1.md"); assert_eq!(parsed.title, "NoH1"); } #[test] fn parse_markdown_title_ignores_hash_inside_code_block() { let parsed = parse_markdown_page("```md\n# Not A Title\n```\nplain text\n", "Fallback.md"); assert_eq!(parsed.title, "Fallback"); } }