264 lines
9.0 KiB
Rust
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}"),
|
||
|
|
}
|
||
|
|
}
|