Files
mnote/rust/crates/mnote-editor-core/src/markdown.rs
T

264 lines
9.0 KiB
Rust

use crate::error::{CoreError, CoreResult};
use crate::model::{BlockType, DocumentBlock, DocumentModel, ReferenceKind};
pub fn import_markdown(input: &str) -> CoreResult<DocumentModel> {
let mut blocks = Vec::new();
let mut ancestry: Vec<String> = Vec::new();
let mut next_id = 1usize;
let mut lines = input.lines().peekable();
while let Some(line) = lines.next() {
if line.trim().is_empty() {
continue;
}
if line.trim_start().starts_with("```") {
let language = line
.trim_start()
.trim_start_matches("```")
.trim()
.to_string();
let mut code_lines = Vec::new();
let mut closed = false;
for next in lines.by_ref() {
if next.trim_start().starts_with("```") {
closed = true;
break;
}
code_lines.push(next.to_string());
}
if !closed {
return Err(CoreError::MarkdownParse("代码块缺少结束围栏".into()));
}
ancestry.clear();
let mut block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::CodeBlock)
.with_text(code_lines.join("\n"));
if !language.is_empty() {
block = block.with_language(language);
}
blocks.push(block);
continue;
}
let indent = count_indent(line);
while ancestry.len() > indent as usize {
ancestry.pop();
}
let trimmed = line.trim_start();
let mut block = if let Some((level, title)) = parse_heading(trimmed) {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Heading)
.with_heading_level(level)
.with_text(title)
} else if let Some((checked, text)) = parse_todo(trimmed) {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Todo)
.with_checked(checked)
.with_text(text)
} else if let Some(text) = parse_bullet(trimmed) {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BulletListItem)
.with_text(text)
} else if let Some(text) = parse_numbered(trimmed) {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::NumberedListItem)
.with_text(text)
} else if let Some(text) = trimmed.strip_prefix("> ") {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Quote).with_text(text)
} else if trimmed == "---" {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Divider)
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "[[", "]]") {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::PageReference)
.with_reference(ReferenceKind::Page, target_id, label)
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "((", "))") {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BlockReference)
.with_reference(ReferenceKind::Block, target_id, label)
} else {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph).with_text(trimmed)
};
if indent > 0 {
let parent_id = ancestry
.last()
.cloned()
.ok_or(CoreError::MarkdownParse("缩进层级缺少父块".into()))?;
block = block.with_parent(parent_id, indent);
}
ancestry.push(block.id.clone());
blocks.push(block);
}
Ok(DocumentModel::new(blocks))
}
pub fn import_plain_text(input: &str) -> CoreResult<DocumentModel> {
let mut blocks = Vec::new();
let normalized = input.replace("\r\n", "\n");
let mut next_id = 1usize;
for paragraph in normalized
.split("\n\n")
.map(str::trim)
.filter(|value| !value.is_empty())
{
let block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph)
.with_text(paragraph.replace('\n', " "));
blocks.push(block);
}
Ok(DocumentModel::new(blocks))
}
pub fn export_markdown(document: &DocumentModel) -> String {
let mut lines = Vec::new();
for block in document.blocks() {
let indent = " ".repeat(block.indent as usize);
match block.block_type {
BlockType::Heading => {
let level = block.heading_level.unwrap_or(1).clamp(1, 6);
lines.push(format!(
"{indent}{} {}",
"#".repeat(level as usize),
block.content.text
));
}
BlockType::BulletListItem => {
lines.push(format!("{indent}- {}", block.content.text));
}
BlockType::NumberedListItem => {
lines.push(format!("{indent}1. {}", block.content.text));
}
BlockType::Todo => {
let marker = if block.checked.unwrap_or(false) {
"x"
} else {
" "
};
lines.push(format!("{indent}- [{marker}] {}", block.content.text));
}
BlockType::Quote => {
lines.push(format!("{indent}> {}", block.content.text));
}
BlockType::Divider => {
lines.push(format!("{indent}---"));
}
BlockType::CodeBlock => {
let fence = match block.content.language.as_deref() {
Some(language) if !language.is_empty() => format!("{indent}```{language}"),
_ => format!("{indent}```"),
};
lines.push(fence);
for code_line in block.content.text.lines() {
lines.push(format!("{indent}{code_line}"));
}
lines.push(format!("{indent}```"));
}
BlockType::PageReference => {
lines.push(format!(
"{indent}{}",
render_reference_token(block.content.reference.as_ref(), "[[", "]]")
));
}
BlockType::BlockReference => {
lines.push(format!(
"{indent}{}",
render_reference_token(block.content.reference.as_ref(), "((", "))")
));
}
_ => {
lines.push(format!("{indent}{}", block.content.text));
}
}
}
lines.join("\n")
}
fn next_block_id(next_id: &mut usize) -> String {
let current = *next_id;
*next_id += 1;
format!("imported_{current}")
}
fn count_indent(line: &str) -> u16 {
let mut spaces = 0usize;
for ch in line.chars() {
match ch {
' ' => spaces += 1,
'\t' => spaces += 2,
_ => break,
}
}
(spaces / 2) as u16
}
fn parse_heading(line: &str) -> Option<(u8, &str)> {
let level = line.chars().take_while(|ch| *ch == '#').count();
if level == 0 || level > 6 {
return None;
}
let title = line.get(level + 1..)?;
if !line.as_bytes().get(level).is_some_and(|ch| *ch == b' ') {
return None;
}
Some((level as u8, title))
}
fn parse_todo(line: &str) -> Option<(bool, &str)> {
if let Some(rest) = line.strip_prefix("- [ ] ") {
return Some((false, rest));
}
if let Some(rest) = line.strip_prefix("- [x] ") {
return Some((true, rest));
}
if let Some(rest) = line.strip_prefix("- [X] ") {
return Some((true, rest));
}
None
}
fn parse_bullet(line: &str) -> Option<&str> {
line.strip_prefix("- ").or_else(|| line.strip_prefix("* "))
}
fn parse_numbered(line: &str) -> Option<&str> {
let dot_index = line.find(". ")?;
if line[..dot_index].chars().all(|ch| ch.is_ascii_digit()) {
line.get(dot_index + 2..)
} else {
None
}
}
fn parse_reference_token(
line: &str,
prefix: &str,
suffix: &str,
) -> Option<(String, Option<String>)> {
let body = line.strip_prefix(prefix)?.strip_suffix(suffix)?;
let mut parts = body.splitn(2, '|');
let target_id = parts.next()?.trim();
if target_id.is_empty() {
return None;
}
let label = parts.next().map(|value| value.trim().to_string());
Some((
target_id.to_string(),
label.filter(|value| !value.is_empty()),
))
}
fn render_reference_token(
reference: Option<&crate::model::BlockReference>,
prefix: &str,
suffix: &str,
) -> String {
match reference {
Some(reference) => match reference.label.as_deref() {
Some(label) if !label.is_empty() => {
format!("{prefix}{}|{label}{suffix}", reference.target_id)
}
_ => format!("{prefix}{}{suffix}", reference.target_id),
},
None => format!("{prefix}{suffix}"),
}
}