Implement local Markdown GFM parser and live refresh
This commit is contained in:
@@ -520,7 +520,12 @@ pub async fn save(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let result = save_local_markdown_page(root_uri, document_id, &body.content)?;
|
||||
let result = save_local_markdown_page(
|
||||
root_uri,
|
||||
document_id,
|
||||
body.conflict_detection_key.as_deref(),
|
||||
&body.content,
|
||||
)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
|
||||
use comrak::{parse_document, Arena, Options};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedLocalMarkdownPage {
|
||||
pub title: String,
|
||||
pub mnote_id: Option<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>,
|
||||
},
|
||||
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>,
|
||||
}
|
||||
|
||||
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
|
||||
let (frontmatter, body) = split_frontmatter(markdown);
|
||||
let title = frontmatter
|
||||
.as_deref()
|
||||
.and_then(|content| read_frontmatter_field(content, "title"))
|
||||
.or_else(|| extract_first_h1_title(body))
|
||||
.unwrap_or_else(|| file_stem_title(file_name));
|
||||
let mnote_id = frontmatter
|
||||
.as_deref()
|
||||
.and_then(|content| read_frontmatter_field(content, "mnote_id"));
|
||||
ParsedLocalMarkdownPage {
|
||||
title,
|
||||
mnote_id,
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn markdown_to_blocks(markdown: &str) -> Value {
|
||||
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown))
|
||||
}
|
||||
|
||||
pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> {
|
||||
let value = trimmed
|
||||
.strip_prefix('!')
|
||||
.unwrap_or(trimmed)
|
||||
.strip_prefix('[')?;
|
||||
let (label, rest) = value.split_once("](")?;
|
||||
let target = rest.strip_suffix(')')?.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") {
|
||||
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 parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument {
|
||||
let arena = Arena::new();
|
||||
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;
|
||||
|
||||
let root = parse_document(&arena, markdown, &options);
|
||||
let mut blocks = Vec::new();
|
||||
for node in root.children() {
|
||||
append_ast_block(node, &mut blocks);
|
||||
}
|
||||
MarkdownAstDocument { blocks }
|
||||
}
|
||||
|
||||
fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
|
||||
match node.data.borrow().value.clone() {
|
||||
NodeValue::Paragraph => append_ast_paragraph(node, blocks),
|
||||
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);
|
||||
}
|
||||
}
|
||||
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>) {
|
||||
if let Some((name, source_path)) = paragraph_attachment_media(node) {
|
||||
blocks.push(MarkdownBlock::Media { name, source_path });
|
||||
return;
|
||||
}
|
||||
if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) {
|
||||
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>) {
|
||||
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),
|
||||
};
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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>) -> Option<(String, String)> {
|
||||
let mut children = node.children();
|
||||
let first = children.next()?;
|
||||
if children.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
link_attachment_media(first)
|
||||
}
|
||||
|
||||
fn paragraph_leading_attachment_media<'a>(
|
||||
node: &'a AstNode<'a>,
|
||||
) -> Option<(String, String, Vec<MarkdownInline>)> {
|
||||
let mut children = node.children();
|
||||
let first = children.next()?;
|
||||
let (name, source_path) = link_attachment_media(first)?;
|
||||
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>) -> Option<(String, String)> {
|
||||
let NodeValue::Link(link) = &node.data.borrow().value else {
|
||||
return None;
|
||||
};
|
||||
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
|
||||
}
|
||||
|
||||
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::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)
|
||||
}
|
||||
|
||||
fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option<String> {
|
||||
frontmatter.lines().find_map(|line| {
|
||||
let (candidate_key, value) = line.split_once(':')?;
|
||||
if candidate_key.trim() != key {
|
||||
return None;
|
||||
}
|
||||
let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_first_h1_title(body: &str) -> Option<String> {
|
||||
body.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed
|
||||
.strip_prefix("# ")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
mod local_folder_source;
|
||||
mod local_markdown_parser;
|
||||
mod mindmap_shell;
|
||||
mod query_support;
|
||||
mod search;
|
||||
|
||||
@@ -323,6 +323,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
|
||||
const READY_EVENT = `${EVENT_PREFIX}:ready`;
|
||||
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
|
||||
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
|
||||
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
|
||||
|
||||
const parseJsonScript = (id) => {
|
||||
const node = document.getElementById(id);
|
||||
@@ -644,11 +646,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const pageBody = aggregate.body || {};
|
||||
const permissions = aggregate.head?.permissions || {};
|
||||
const conflictDetectionKey = typeof pageBody.conflictDetectionKey === 'string'
|
||||
? pageBody.conflictDetectionKey
|
||||
: typeof pageBody.conflict_detection_key === 'string'
|
||||
? pageBody.conflict_detection_key
|
||||
const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string'
|
||||
? body.conflictDetectionKey
|
||||
: typeof body?.conflict_detection_key === 'string'
|
||||
? body.conflict_detection_key
|
||||
: null;
|
||||
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
|
||||
const revisionFromConflictKey = (value) => {
|
||||
const match = String(value || '').match(/:(\d+)$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
@@ -688,6 +691,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
let saveTimer = 0;
|
||||
let lastSavedSerialized = '';
|
||||
let hasPendingLocalChanges = false;
|
||||
let suppressNextHostSyncChange = false;
|
||||
let localExternalPollTimer = 0;
|
||||
let localExternalPollInFlight = false;
|
||||
let lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey || '';
|
||||
const normalizeBridgeValue = (value) => {
|
||||
if (value instanceof Map) {
|
||||
const out = {};
|
||||
@@ -719,7 +727,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
currentEditorText(),
|
||||
);
|
||||
const serialized = JSON.stringify(tiptapDocument);
|
||||
if (suppressNextHostSyncChange && serialized === lastSavedSerialized) {
|
||||
suppressNextHostSyncChange = false;
|
||||
hasPendingLocalChanges = false;
|
||||
setStatus('saved');
|
||||
return;
|
||||
}
|
||||
if (serialized === lastSavedSerialized) {
|
||||
hasPendingLocalChanges = false;
|
||||
setStatus('saved');
|
||||
return;
|
||||
}
|
||||
@@ -751,7 +766,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (Number.isInteger(saved.revision)) editorMeta.revision = saved.revision;
|
||||
if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key;
|
||||
if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey;
|
||||
if (editorMeta.conflictDetectionKey) lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey;
|
||||
lastSavedSerialized = serialized;
|
||||
hasPendingLocalChanges = false;
|
||||
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
|
||||
window.__mnoteRecordPageHistorySnapshot('save', {
|
||||
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
|
||||
@@ -767,7 +784,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const queueSave = (event) => {
|
||||
const payload = normalizeEnvelopePayload(event);
|
||||
if (!payload) return;
|
||||
if (suppressNextHostSyncChange) {
|
||||
const tiptapDocument = toTiptapDocument(
|
||||
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
|
||||
currentEditorText(),
|
||||
);
|
||||
if (JSON.stringify(tiptapDocument) === lastSavedSerialized) {
|
||||
suppressNextHostSyncChange = false;
|
||||
hasPendingLocalChanges = false;
|
||||
setStatus('saved');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (saveTimer) window.clearTimeout(saveTimer);
|
||||
hasPendingLocalChanges = true;
|
||||
setStatus('dirty');
|
||||
saveTimer = window.setTimeout(() => {
|
||||
saveTimer = 0;
|
||||
@@ -785,6 +815,79 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
root.addEventListener(CHANGE_EVENT, queueSave);
|
||||
root.addEventListener(SAVE_EVENT, queueSave);
|
||||
|
||||
const pageAggregateUrl = () => {
|
||||
const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin);
|
||||
url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder');
|
||||
if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri);
|
||||
return url;
|
||||
};
|
||||
|
||||
const dispatchReplaceContent = (nextAggregate) => {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
const nextTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
editorMeta.revision = nextRevision;
|
||||
editorMeta.conflictDetectionKey = nextConflictKey;
|
||||
lastExternalConflictDetectionKey = nextConflictKey || '';
|
||||
lastSavedSerialized = JSON.stringify(nextTiptapDocument);
|
||||
hasPendingLocalChanges = false;
|
||||
suppressNextHostSyncChange = true;
|
||||
clearEmbeddedLocalDraft();
|
||||
root.dispatchEvent(new CustomEvent(COMMAND_EVENT, {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
protocol: BRIDGE_PROTOCOL,
|
||||
runtime: 'mnote-leptos-tiptap-spike',
|
||||
version: '1.1.0',
|
||||
source: 'mnote-web-local-folder-watch',
|
||||
event: COMMAND_EVENT,
|
||||
payload: {
|
||||
command: 'replaceContent',
|
||||
documentId: bootstrap.documentId,
|
||||
workspaceId: bootstrap.workspaceId,
|
||||
title: nextAggregate?.head?.title || mountOptions.title,
|
||||
content: nextTiptapDocument,
|
||||
revision: editorMeta.revision,
|
||||
conflictDetectionKey: editorMeta.conflictDetectionKey,
|
||||
readOnly: Boolean(nextPermissions.readOnly),
|
||||
},
|
||||
},
|
||||
}));
|
||||
setStatus('synced-external-change');
|
||||
};
|
||||
|
||||
const pollLocalMarkdownExternalChange = async () => {
|
||||
if (bootstrap.sourceKind !== 'local_folder' || !bootstrap.rootUri || document.hidden) return;
|
||||
if (localExternalPollInFlight) return;
|
||||
localExternalPollInFlight = true;
|
||||
try {
|
||||
const response = await fetch(pageAggregateUrl().toString(), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const nextAggregate = payload?.result;
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextAggregate?.body || {});
|
||||
if (!nextConflictKey || !lastExternalConflictDetectionKey) {
|
||||
lastExternalConflictDetectionKey = nextConflictKey || lastExternalConflictDetectionKey;
|
||||
return;
|
||||
}
|
||||
if (nextConflictKey === lastExternalConflictDetectionKey) return;
|
||||
if (hasPendingLocalChanges || saveTimer) {
|
||||
setStatus('external-change-conflict', '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突');
|
||||
return;
|
||||
}
|
||||
dispatchReplaceContent(nextAggregate);
|
||||
} catch (error) {
|
||||
console.warn('mnote local folder 外部更新检测失败', error);
|
||||
} finally {
|
||||
localExternalPollInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setStatus('loading-assets');
|
||||
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
|
||||
@@ -800,11 +903,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
await runtime.default(wasmUrl);
|
||||
clearEmbeddedLocalDraft();
|
||||
const mountId = runtime.mount(root, mountOptions);
|
||||
lastSavedSerialized = JSON.stringify(mountOptions.content);
|
||||
root.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
||||
if (typeof window.__mnoteApplyPageOptionsToShell === 'function') {
|
||||
window.__mnoteApplyPageOptionsToShell();
|
||||
}
|
||||
if (bootstrap.sourceKind === 'local_folder' && bootstrap.rootUri) {
|
||||
void pollLocalMarkdownExternalChange();
|
||||
localExternalPollTimer = window.setInterval(() => {
|
||||
void pollLocalMarkdownExternalChange();
|
||||
}, 1200);
|
||||
}
|
||||
setStatus('ready');
|
||||
};
|
||||
|
||||
@@ -1530,6 +1640,10 @@ mod tests {
|
||||
assert!(html.contains("asset.png"));
|
||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
||||
assert!(html.contains("pollLocalMarkdownExternalChange"));
|
||||
assert!(html.contains("/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}"));
|
||||
assert!(html.contains("command: 'replaceContent'"));
|
||||
assert!(html.contains("external-change-conflict"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user