Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
29 kB · 737 lines
Rust
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738//! Atomic native Note / reader projection bridge. Native documents are never//! replaced when attaching an existing Note; unsupported nodes fail explicitly.use crate::{ error::ApiError, notes::NoteRecord, reader::{self, Material}, state::AppState,};use axum::{ Json, extract::{Path, State},};use serde_json::{Value, json};use sqlx::{Postgres, Transaction};use uuid::Uuid;
fn unsupported(kind: &str) -> ApiError { ApiError::BadRequest(format!( "Native document contains unsupported reader content: {kind}. The original Note was preserved." ))}
fn children(node: &Value) -> Result<&[Value], ApiError> { match node.get("content") { None => Ok(&[]), Some(Value::Array(nodes)) => Ok(nodes), _ => Err(unsupported("malformed content")), }}
fn escape_text(text: &str) -> String { let mut out = String::new(); for c in text.chars() { if "\\`*_{}[]<>()#+!|~$".contains(c) { out.push('\\'); } out.push(c); } out}
fn text_content(node: &Value) -> Result<String, ApiError> { let mut result = String::new(); for child in children(node)? { if child["type"] != "text" { return Err(unsupported("codeBlock child")); } result.push_str(child["text"].as_str().ok_or_else(|| unsupported("text"))?); } Ok(result)}
fn fence(text: &str, minimum: usize) -> String { let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); "`".repeat(minimum.max(longest + 1))}
fn native_url(url: &str, material: Uuid) -> Result<String, ApiError> { let prefix = format!("/api/reader/files/{material}/"); let url = url.strip_prefix(&prefix).unwrap_or(url); if crate::reader_pdf::safe_relative_path(url) || url.starts_with('#') || url.starts_with("/api/assets/") || url.starts_with("/api/reader/files/") { return Ok(url .replace(' ', "%20") .replace('(', "%28") .replace(')', "%29")); } if reqwest::Url::parse(url).is_ok_and(|u| matches!(u.scheme(), "http" | "https" | "mailto")) { return Ok(url .replace(' ', "%20") .replace('(', "%28") .replace(')', "%29")); } Err(unsupported("unsafe or malformed URL"))}
fn inline(node: &Value, material: Uuid) -> Result<String, ApiError> { let kind = node["type"].as_str().unwrap_or(""); let mut out = match kind { "text" => escape_text(node["text"].as_str().ok_or_else(|| unsupported("text"))?), "hardBreak" => " \n".into(), "inlineLatex" => format!( "${}$", node["attrs"]["formula"] .as_str() .ok_or_else(|| unsupported("inlineLatex formula"))? ), _ => return Err(unsupported(kind)), }; if let Some(marks) = node.get("marks") { for mark in marks.as_array().ok_or_else(|| unsupported("marks"))? { match mark["type"].as_str().unwrap_or("") { "bold" => out = format!("**{out}**"), "italic" => out = format!("*{out}*"), "strike" => out = format!("~~{out}~~"), "code" => { let raw = node["text"] .as_str() .ok_or_else(|| unsupported("code mark"))?; let delimiter = fence(raw, 1); out = format!("{delimiter} {raw} {delimiter}"); } "link" => { out = format!( "[{out}]({})", native_url( mark["attrs"]["href"] .as_str() .ok_or_else(|| unsupported("link href"))?, material )? ) } // These marks remain in the authoritative native document; // the reader projection keeps their text without decoration. "annotationAnchor" => {} "underline" => out = format!("<u>{out}</u>"), kind => return Err(unsupported(kind)), } } } Ok(out)}
fn inlines(node: &Value, material: Uuid) -> Result<String, ApiError> { children(node)? .iter() .map(|n| inline(n, material)) .collect()}
fn blocks(node: &Value, material: Uuid) -> Result<String, ApiError> { Ok(children(node)? .iter() .map(|n| block(n, material)) .collect::<Result<Vec<_>, _>>()? .join("\n\n"))}
fn block(node: &Value, material: Uuid) -> Result<String, ApiError> { let kind = node["type"].as_str().unwrap_or(""); Ok(match kind { "doc" => blocks(node, material)?, "paragraph" => inlines(node, material)?, "heading" => { let level = node["attrs"]["level"] .as_u64() .filter(|n| (1..=6).contains(n)) .ok_or_else(|| unsupported("heading level"))?; format!( "{} {}", "#".repeat(level as usize), inlines(node, material)? ) } "blockquote" => blocks(node, material)? .lines() .map(|l| format!("> {l}")) .collect::<Vec<_>>() .join("\n"), "bulletList" | "orderedList" | "taskList" => { let start = node["attrs"]["start"].as_u64().unwrap_or(1); let mut items = Vec::new(); for (index, item) in children(node)?.iter().enumerate() { let item_kind = item["type"].as_str().unwrap_or(""); if !matches!(item_kind, "listItem" | "taskItem") { return Err(unsupported(item_kind)); } let prefix = if kind == "orderedList" { format!("{}. ", start + index as u64) } else if kind == "taskList" { format!( "- [{}] ", if item["attrs"]["checked"].as_bool().unwrap_or(false) { "x" } else { " " } ) } else { "- ".into() }; let body = blocks(item, material)?; let indent = " ".repeat(prefix.len()); let mut lines = body.lines(); let mut formatted = format!("{prefix}{}", lines.next().unwrap_or("")); for line in lines { formatted.push_str(&format!("\n{indent}{line}")); } items.push(formatted); } items.join("\n") } "codeBlock" => { let body = text_content(node)?; let language = node["attrs"]["language"].as_str().unwrap_or("text"); if language.contains(['\n', '\r', '`']) { return Err(unsupported("code language")); } let delimiter = fence(&body, 3); format!( "{delimiter}{language}\n{}{delimiter}", if body.ends_with('\n') { body } else { format!("{body}\n") } ) } "blockLatex" => format!( "$$\n{}\n$$", node["attrs"]["formula"] .as_str() .ok_or_else(|| unsupported("blockLatex formula"))? ), "horizontalRule" => "---".into(), "image" => { let src = native_url( node["attrs"]["src"] .as_str() .ok_or_else(|| unsupported("image src"))?, material, )?; let alt = escape_text(node["attrs"]["alt"].as_str().unwrap_or("")); let title = node["attrs"]["title"] .as_str() .filter(|s| !s.is_empty()) .map(|s| format!(" \"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))) .unwrap_or_default(); format!("") } "table" => { let mut rows = Vec::new(); for row in children(node)? { if row["type"] != "tableRow" { return Err(unsupported("table row")); } let mut cells = Vec::new(); for cell in children(row)? { if !matches!(cell["type"].as_str(), Some("tableCell" | "tableHeader")) { return Err(unsupported("table cell")); } if ["colspan", "rowspan"] .iter() .any(|key| cell["attrs"][key].as_u64().is_some_and(|n| n > 1)) { return Err(unsupported("merged table cell")); } if children(cell)?.iter().any(|n| n["type"] != "paragraph") { return Err(unsupported("non-paragraph table cell")); } cells.push( children(cell)? .iter() .map(|n| inlines(n, material)) .collect::<Result<Vec<_>, _>>()? .join("<br>") .replace(" \n", "<br>") .replace('\n', "<br>"), ); } rows.push(cells); } if rows.is_empty() { return Ok(String::new()); } let columns = rows[0].len(); if columns == 0 || rows.iter().any(|r| r.len() != columns) { return Err(unsupported("nonrectangular table")); } let mut rendered = Vec::new(); for (i, row) in rows.into_iter().enumerate() { rendered.push(format!("| {} |", row.join(" | "))); if i == 0 { rendered.push(format!("| {} |", vec!["---"; columns].join(" | "))); } } rendered.join("\n") } _ => return Err(unsupported(kind)), })}
pub(crate) fn document_markdown(document: &Value, material: Uuid) -> Result<String, ApiError> { let doc = if document["type"] == "doc" { document } else { &document["content"] }; if doc["type"] != "doc" { return Err(unsupported("document root")); } let body = block(doc, material)?; reader::bounded_text(&body, "content", 4_000_000, false)?; Ok(body)}
pub(crate) fn native_document(body: &str, material: Uuid) -> Value { json!({"schema_version":1,"content":crate::reader_learning::markdown_document(body, material)})}
// Reuse unchanged native nodes when a reader rewrite touches another part of// the article. This keeps annotation marks and editor-only attributes on the// surviving content instead of recreating the complete document tree.fn preserve_native_nodes(previous: &Value, next: &mut Value, material: Uuid) { if previous["type"] != next["type"] { return; } let kind = previous["type"].as_str().unwrap_or(""); let render = |node: &Value| { if matches!(kind, "text" | "inlineLatex" | "hardBreak") { inline(node, material) } else { block(node, material) } }; if kind != "doc" && render(previous) .ok() .is_some_and(|old| render(next).ok() == Some(old)) { *next = previous.clone(); return; } let (Some(old_children), Some(new_children)) = ( previous["content"].as_array(), next["content"].as_array_mut(), ) else { return; }; let mut used = std::collections::HashSet::new(); for (index, child) in new_children.iter_mut().enumerate() { let matching = old_children .iter() .enumerate() .find(|(i, old)| { !used.contains(i) && old["type"] == child["type"] && block(old, material) .ok() .is_some_and(|text| block(child, material).ok() == Some(text)) }) .map(|(i, _)| i) .or_else(|| (index < old_children.len() && !used.contains(&index)).then_some(index)); if let Some(i) = matching { used.insert(i); preserve_native_nodes(&old_children[i], child, material); } }}
pub(crate) fn excerpt(document: &Value) -> String { fn collect(node: &Value, output: &mut String) { if let Some(text) = node["text"].as_str() { output.push_str(text); output.push(' '); } if let Some(formula) = node["attrs"]["formula"].as_str() { output.push_str(formula); output.push(' '); } if let Some(nodes) = node["content"].as_array() { for child in nodes { collect(child, output); } } else if node["content"].is_object() { collect(&node["content"], output); } } let mut text = String::new(); collect(document, &mut text); text.split_whitespace() .collect::<Vec<_>>() .join(" ") .chars() .take(300) .collect()}
async fn native_revision( tx: &mut Transaction<'_, Postgres>, note: &NoteRecord, reason: &str,) -> Result<(), ApiError> { sqlx::query("insert into note_revisions(note_id,parent_revision_id,revision_number,title,excerpt,document,reason) values($1,(select id from note_revisions where note_id=$1 order by revision_number desc limit 1),$2,$3,$4,$5,$6) on conflict(note_id,revision_number) do nothing") .bind(note.id).bind(note.version).bind(¬e.title).bind(¬e.excerpt).bind(¬e.document).bind(reason).execute(&mut **tx).await?; Ok(())}
pub(crate) async fn sync_material_note_tx( tx: &mut Transaction<'_, Postgres>, material: &Material,) -> Result<Material, ApiError> { if material.kind != "article" { return Ok(material.clone()); } let existing = if let Some(id) = material.note_id { Some( sqlx::query_as::<_, NoteRecord>("select * from notes where id=$1 for update") .bind(id) .fetch_one(&mut **tx) .await?, ) } else { None }; let document = native_document(&material.body, material.id); let note = if let Some(note) = existing { if material.note_id.is_some() && note.version != material.version - 1 && note.version != material.version { return Err(ApiError::Conflict( "Linked Note changed; reload before saving.".into(), )); } // A metadata-only reader edit must not discard native annotations, // node attributes, or document provenance. let old_body: Option<String> = sqlx::query_scalar( "select body from reader_material_revisions where material_id=$1 and version=$2", ) .bind(material.id) .bind(material.version - 1) .fetch_optional(&mut **tx) .await?; let mut document = if old_body.as_deref() == Some(&material.body) { note.document.clone() } else { let mut preserved = note.document.clone(); let old_root = if preserved["type"] == "doc" { &preserved } else { &preserved["content"] }; let mut new_root = document["content"].clone(); preserve_native_nodes(old_root, &mut new_root, material.id); if preserved.is_object() && preserved["type"] != "doc" { preserved["content"] = new_root; preserved } else { new_root } }; if !document.is_object() { document = native_document(&material.body, material.id); } let derived_excerpt = if old_body.as_deref() == Some(&material.body) { note.excerpt.clone() } else { excerpt(&document) }; sqlx::query_as::<_, NoteRecord>("update notes set title=$2,excerpt=$3,document=$4,version=$5,updated_at=now() where id=$1 returning *") .bind(note.id).bind(&material.title).bind(derived_excerpt).bind(document).bind(material.version).fetch_one(&mut **tx).await? } else { let id = Uuid::now_v7(); let base = format!("reader-article-{}", material.id); let slug_exists: bool = sqlx::query_scalar("select exists(select 1 from notes where slug=$1)") .bind(&base) .fetch_one(&mut **tx) .await?; let slug = if slug_exists { format!("{base}-{id}") } else { base }; sqlx::query_as::<_, NoteRecord>("insert into notes(id,slug,title,kind,excerpt,document,version,created_at,updated_at) values($1,$2,$3,'post',$4,$5,$6,$7,$8) returning *") .bind(id).bind(slug).bind(&material.title).bind(excerpt(&document)).bind(document).bind(material.version).bind(material.created_at).bind(material.updated_at).fetch_one(&mut **tx).await? }; let history: Vec<Value> = sqlx::query_scalar("select to_jsonb(r) from reader_material_revisions r where material_id=$1 and version<$2 order by version") .bind(material.id).bind(note.version).fetch_all(&mut **tx).await?; for revision in history { let document = native_document(revision["body"].as_str().unwrap_or_default(), material.id); sqlx::query("insert into note_revisions(note_id,parent_revision_id,revision_number,title,excerpt,document,reason,created_at) values($1,(select id from note_revisions where note_id=$1 and revision_number<$2 order by revision_number desc limit 1),$2,$3,$4,$5,$6,$7) on conflict(note_id,revision_number) do nothing") .bind(note.id).bind(revision["version"].as_i64().unwrap_or(1)).bind(revision["title"].as_str().unwrap_or_default()).bind(excerpt(&document)).bind(document).bind(revision["reason"].as_str().unwrap_or("reader history")).bind(revision["created_at"].as_str().and_then(|s|chrono::DateTime::parse_from_rfc3339(s).ok()).map(|t|t.with_timezone(&chrono::Utc)).unwrap_or(material.created_at)).execute(&mut **tx).await?; } native_revision(tx, ¬e, "reader article update").await?; Ok(sqlx::query_as::<_, Material>( "update reader_materials set note_id=$2 where id=$1 returning *", ) .bind(material.id) .bind(note.id) .fetch_one(&mut **tx) .await?)}
pub(crate) async fn sync_note_material_tx( tx: &mut Transaction<'_, Postgres>, note: &NoteRecord, previous_document: &Value, previous: &Material, reason: &str,) -> Result<(), ApiError> { let body = if note.document == *previous_document { previous.body.clone() } else { document_markdown(¬e.document, previous.id)? }; reader::snapshot_revision(tx, previous, reason).await?; let affected = sqlx::query("update reader_materials set title=$3,body=$4,version=$5,updated_at=now() where id=$1 and version=$2") .bind(previous.id).bind(note.version - 1).bind(¬e.title).bind(&body).bind(note.version).execute(&mut **tx).await?.rows_affected(); if affected != 1 { return Err(ApiError::Conflict( "Linked article changed; reload before saving.".into(), )); } if body != previous.body { reader::index_article_tx(tx, previous.id, &body).await?; } sync_lesson_titles_tx(tx, previous.id, ¬e.title).await?; Ok(())}
pub(crate) async fn sync_lesson_titles_tx( tx: &mut Transaction<'_, Postgres>, material: Uuid, title: &str,) -> Result<(), ApiError> { sqlx::query("update learning_lessons set title=$2,version=version+1,updated_at=now() where document->>'reader_material_id'=$1 and title<>$2") .bind(material.to_string()).bind(title).execute(&mut **tx).await?; Ok(())}
pub(crate) async fn backfill_tx(tx: &mut Transaction<'_, Postgres>) -> Result<(), ApiError> { let materials = sqlx::query_as::<_, Material>("select * from reader_materials where kind='article' and note_id is null order by id for update").fetch_all(&mut **tx).await?; for material in materials { sync_material_note_tx(tx, &material).await?; } Ok(())}
pub(crate) async fn reconcile_import_tx( tx: &mut Transaction<'_, Postgres>, ids: &[Uuid],) -> Result<(), ApiError> { backfill_tx(tx).await?; let materials = sqlx::query_as::<_, Material>( "select * from reader_materials where kind='article' and id=any($1) order by id for update", ) .bind(ids) .fetch_all(&mut **tx) .await?; for material in materials { let note = sqlx::query_as::<_, NoteRecord>("select * from notes where id=$1 for update") .bind(material.note_id) .fetch_one(&mut **tx) .await?; let body = document_markdown(¬e.document, material.id)?; let equivalent = document_markdown(&native_document(&material.body, material.id), material.id)? == body; if !equivalent || note.title != material.title || note.version != material.version { sqlx::query("update reader_materials set title=$2,body=$3,version=$4 where id=$1") .bind(material.id) .bind(¬e.title) .bind(if equivalent { &material.body } else { &body }) .bind(note.version) .execute(&mut **tx) .await?; if !equivalent { reader::index_article_tx(tx, material.id, &body).await?; } } sync_lesson_titles_tx(tx, material.id, ¬e.title).await?; } Ok(())}
pub(crate) async fn backfill(state: &AppState) -> Result<(), ApiError> { let mut tx = state.platform_db.begin().await?; backfill_tx(&mut tx).await?; tx.commit().await?; Ok(())}
async fn attach_note_tx( tx: &mut Transaction<'_, Postgres>, id: Uuid,) -> Result<Material, ApiError> { // Serialize attachment with Note saves, then make repeat calls idempotent. let note = sqlx::query_as::<_, NoteRecord>("select * from notes where id=$1 for update") .bind(id) .fetch_one(&mut **tx) .await?; if let Some(material) = sqlx::query_as::<_, Material>("select * from reader_materials where note_id=$1") .bind(id) .fetch_optional(&mut **tx) .await? { return Ok(material); } let material_id = Uuid::now_v7(); let body = document_markdown(¬e.document, material_id)?; let material = sqlx::query_as::<_, Material>("insert into reader_materials(id,kind,note_id,title,body,version) values($1,'article',$2,$3,$4,$5) returning *") .bind(material_id).bind(note.id).bind(¬e.title).bind(&body).bind(note.version).fetch_one(&mut **tx).await?; reader::index_article_tx(tx, material.id, &body).await?; Ok(material)}
pub(crate) async fn from_note( State(state): State<AppState>, Path(id): Path<Uuid>, Json(_input): Json<Value>,) -> Result<Json<Material>, ApiError> { let mut tx = state.platform_db.begin().await?; let material = attach_note_tx(&mut tx, id).await?; tx.commit().await?; Ok(Json(material))}
pub(crate) async fn from_learning( State(state): State<AppState>, Path(id): Path<Uuid>, Json(_input): Json<Value>,) -> Result<Json<Material>, ApiError> { let mut tx = state.platform_db.begin().await?; let lesson: Value = sqlx::query_scalar("select to_jsonb(l) from learning_lessons l where id=$1 for update") .bind(id) .fetch_one(&mut *tx) .await?; if let Some(mid) = lesson["document"]["reader_material_id"] .as_str() .and_then(|s| Uuid::parse_str(s).ok()) { let material = sqlx::query_as::<_, Material>("select * from reader_materials where id=$1") .bind(mid) .fetch_one(&mut *tx) .await?; return Ok(Json(material)); } let note_id = Uuid::now_v7(); // Validate before creating either entity. The Note retains the original // native JSON, including annotations, attributes, and provenance. document_markdown(&lesson["document"], note_id)?; let note = sqlx::query_as::<_, NoteRecord>("insert into notes(id,slug,title,kind,excerpt,document) values($1,$2,$3,'post',$4,$5) returning *") .bind(note_id).bind(format!("learning-note-{note_id}")).bind(lesson["title"].as_str().unwrap_or("Lesson")).bind(excerpt(&lesson["document"])).bind(&lesson["document"]).fetch_one(&mut *tx).await?; native_revision(&mut tx, ¬e, "opened native lesson in Notes").await?; let material = attach_note_tx(&mut tx, note_id).await?; sqlx::query("insert into agent_lesson_revisions(lesson_id,version,title,document) values($1,$2,$3,$4) on conflict(lesson_id,version) do nothing") .bind(id).bind(lesson["version"].as_i64().unwrap_or(1)).bind(lesson["title"].as_str().unwrap_or_default()).bind(&lesson["document"]).execute(&mut *tx).await?; let mut document = lesson["document"].clone(); document["reader_material_id"] = json!(material.id); sqlx::query( "update learning_lessons set document=$2,version=version+1,updated_at=now() where id=$1", ) .bind(id) .bind(document) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json(material))}
pub(crate) async fn ensure_deletable_tx( tx: &mut Transaction<'_, Postgres>, id: Uuid,) -> Result<(), ApiError> { let linked: bool = sqlx::query_scalar( "select exists(select 1 from learning_lessons where document->>'reader_material_id'=$1)", ) .bind(id.to_string()) .fetch_one(&mut **tx) .await?; if linked { return Err(ApiError::Conflict("This article is used by a learning lesson. Remove the lesson link before deleting the article.".into())); } Ok(())}
pub(crate) async fn delete_projection_tx( tx: &mut Transaction<'_, Postgres>, material: &Material,) -> Result<(), ApiError> { ensure_deletable_tx(tx, material.id).await?; crate::reader_chats::cancel_material(material.id); sqlx::query("delete from shelf_items where metadata->>'reader_material_id'=$1") .bind(material.id.to_string()) .execute(&mut **tx) .await?; sqlx::query("delete from reader_materials where id=$1") .bind(material.id) .execute(&mut **tx) .await?; Ok(())}
#[cfg(test)]mod tests { use super::*; #[test] fn codec_preserves_native_math_study_fences_tables_and_assets() { let id = Uuid::new_v4(); let source = "# Native\n\n**Bold** and $x^2$ and **$q$**.\n\n$$\na+b\n$$\n\n```mermaid\ngraph TD; A-->B\n```\n\n```exercise\n{\"id\":\"one\",\"question\":\"Q\"}\n```\n\n```learning\n{\"cards\":[]}\n```\n\n| Left | Right |\n| --- | --- |\n| A | B |\n\n"; let doc = native_document(source, id); let markdown = document_markdown(&doc, id).unwrap(); for fragment in [ "$x^2$", "**$q$**", "a+b", "```mermaid", "graph TD; A-->B", "```exercise", "```learning", "| Left | Right |", "assets/diagram.svg", ] { assert!(markdown.contains(fragment), "{fragment}: {markdown}"); } assert_eq!( doc["content"]["content"] .as_array() .unwrap() .iter() .filter(|n| n["type"] == "table") .count(), 1 ); } #[test] fn attach_rejects_unknown_native_nodes_and_does_not_mutate_document() { let doc = json!({"schema_version":1,"content":{"type":"doc","content":[{"type":"excalidrawDiagram","attrs":{"documentId":"original"}}]}}); let original = doc.clone(); assert!(document_markdown(&doc, Uuid::new_v4()).is_err()); assert_eq!(doc, original); } #[test] fn escaping_and_long_fences_keep_literal_content() { let doc = json!({"content":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"literal * $ [x]"}]},{"type":"codeBlock","attrs":{"language":"text"},"content":[{"type":"text","text":"```\ninside"}]}]}}); let body = document_markdown(&doc, Uuid::new_v4()).unwrap(); assert!(body.contains("literal \\* \\$ \\[x\\]")); assert!(body.contains("````text\n```\ninside\n````")); }}