use crate::error::{CoreError, CoreResult}; use crate::model::{BlockType, DocumentBlock, DocumentModel, ReferenceKind}; pub fn import_markdown(input: &str) -> CoreResult { let mut blocks = Vec::new(); let mut ancestry: Vec = 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 { 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)> { 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}"), } }