1439 lines
46 KiB
Rust
1439 lines
46 KiB
Rust
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<MarkdownBlock>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
enum MarkdownBlock {
|
||
Paragraph(Vec<MarkdownInline>),
|
||
Heading {
|
||
level: u8,
|
||
content: Vec<MarkdownInline>,
|
||
},
|
||
Quote(Vec<MarkdownInline>),
|
||
CodeBlock {
|
||
language: String,
|
||
text: String,
|
||
},
|
||
Divider,
|
||
BulletListItem(Vec<MarkdownInline>),
|
||
NumberedListItem(Vec<MarkdownInline>),
|
||
Todo {
|
||
checked: bool,
|
||
content: Vec<MarkdownInline>,
|
||
},
|
||
Table {
|
||
alignments: Vec<TableAlignment>,
|
||
rows: Vec<MarkdownTableRow>,
|
||
},
|
||
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<MarkdownTableCell>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct MarkdownTableCell {
|
||
content: Vec<MarkdownInline>,
|
||
}
|
||
|
||
#[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<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub resolved_absolute_path: Option<String>,
|
||
pub relative_path: Option<String>,
|
||
pub ext: Option<String>,
|
||
pub content_type: Option<String>,
|
||
pub exists: Option<bool>,
|
||
pub authorized: Option<bool>,
|
||
pub open_kind: String,
|
||
pub source_range: Option<AttachmentSourceRange>,
|
||
}
|
||
|
||
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<String>,
|
||
) -> 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<AttachmentRef> {
|
||
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<String>,
|
||
) -> 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<AttachmentRef>,
|
||
) {
|
||
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<AttachmentRef>,
|
||
) {
|
||
let lower = markdown.to_ascii_lowercase();
|
||
let mut offset = 0usize;
|
||
while let Some(relative_start) = lower[offset..].find("<embed") {
|
||
let start = offset + relative_start;
|
||
let Some(relative_end) = lower[start..].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<AttachmentSourceRange>,
|
||
) -> 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<String>, Option<String>) {
|
||
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<PathBuf> {
|
||
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<PathBuf> {
|
||
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<String> {
|
||
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<AttachmentSourceRange> {
|
||
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<String> {
|
||
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<u8> {
|
||
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<AttachmentSourceRange>,
|
||
) -> 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<String>,
|
||
) -> 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<MarkdownBlock>,
|
||
attachment_paths: &BTreeSet<String>,
|
||
) {
|
||
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<MarkdownBlock>,
|
||
attachment_paths: &BTreeSet<String>,
|
||
) {
|
||
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<MarkdownBlock>,
|
||
attachment_paths: &BTreeSet<String>,
|
||
) {
|
||
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<TableAlignment>) -> 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::<Vec<_>>();
|
||
MarkdownTableRow { is_header, cells }
|
||
})
|
||
.collect::<Vec<_>>();
|
||
MarkdownBlock::Table { alignments, rows }
|
||
}
|
||
|
||
fn collect_inline_children<'a>(node: &'a AstNode<'a>) -> Vec<MarkdownInline> {
|
||
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<MarkdownInline>,
|
||
) {
|
||
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<MarkdownInline>,
|
||
) {
|
||
for child in node.children() {
|
||
collect_inline_nodes(child, active_styles, nodes);
|
||
}
|
||
}
|
||
|
||
fn push_inline_text_node(
|
||
nodes: &mut Vec<MarkdownInline>,
|
||
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<MarkdownInline>) -> Vec<MarkdownInline> {
|
||
let mut merged = Vec::<MarkdownInline>::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<String>,
|
||
) -> 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<String>,
|
||
) -> Option<(String, String, Vec<MarkdownInline>)> {
|
||
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<String>,
|
||
) -> 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::<Vec<_>>();
|
||
json!({
|
||
"type": "tableRow",
|
||
"content": cells,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>();
|
||
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<Value>, 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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<String>, &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("\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\
|
||
\n\
|
||
[外部](file://{})\n\
|
||
[网页资料](https://example.com/paper.pdf)\n\
|
||
[裸绝对]({})\n\
|
||
<embed src=\"./missing.pptx\">\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");
|
||
}
|
||
}
|