From 2af1ddf755925fca037070a9541957103c7059c4 Mon Sep 17 00:00:00 2001 From: Raajhesh Kannaa Chidambaram Date: Tue, 10 Mar 2026 15:05:07 -0400 Subject: [PATCH] feat(docs): add --content-format markdown to +write helper Parse markdown input and convert to Google Docs API batchUpdate requests with proper formatting (headings, bold, italic, inline code, code blocks, links, ordered/unordered lists). Uses pulldown-cmark for markdown parsing. Generates a single insertText request followed by updateParagraphStyle/updateTextStyle/ createParagraphBullets requests with correct 1-based UTF-16 character indices. Fixes #380 --- Cargo.lock | 35 ++ Cargo.toml | 1 + src/helpers/docs.rs | 67 ++- src/helpers/docs_markdown.rs | 773 +++++++++++++++++++++++++++++++++++ src/helpers/mod.rs | 1 + 5 files changed, 867 insertions(+), 10 deletions(-) create mode 100644 src/helpers/docs_markdown.rs diff --git a/Cargo.lock b/Cargo.lock index 362f8e89..e605cd6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -793,6 +793,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -861,6 +870,7 @@ dependencies = [ "hostname", "keyring", "percent-encoding", + "pulldown-cmark", "rand 0.8.5", "ratatui", "reqwest", @@ -1728,6 +1738,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quinn" version = "0.11.9" @@ -2767,6 +2796,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 5376c529..77a9a25a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ async-trait = "0.1.89" serde_yaml = "0.9.34" percent-encoding = "2.3.2" zeroize = { version = "1.8.2", features = ["derive"] } +pulldown-cmark = "0.12" # The profile that 'cargo dist' will build with diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index e847dfbf..18db9308 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use super::docs_markdown; use super::Helper; use crate::auth; use crate::error::GwsError; @@ -42,18 +43,27 @@ impl Helper for DocsHelper { .arg( Arg::new("text") .long("text") - .help("Text to append (plain text)") + .help("Text to append") .required(true) .value_name("TEXT"), ) + .arg( + Arg::new("content-format") + .long("content-format") + .help("Content format: 'plaintext' or 'markdown'") + .value_name("FORMAT") + .value_parser(["plaintext", "markdown"]) + .default_value("plaintext"), + ) .after_help( "\ EXAMPLES: gws docs +write --document DOC_ID --text 'Hello, world!' + gws docs +write --document DOC_ID --content-format markdown --text '# Title\n\nSome **bold** text.' TIPS: Text is inserted at the end of the document body. - For rich formatting, use the raw batchUpdate API instead.", + Use --content-format markdown to convert markdown to rich formatting.", ), ); cmd @@ -121,6 +131,10 @@ fn build_write_request( ) -> Result<(String, String, Vec), GwsError> { let document_id = matches.get_one::("document").unwrap(); let text = matches.get_one::("text").unwrap(); + let content_format = matches + .get_one::("content-format") + .map(|s| s.as_str()) + .unwrap_or("plaintext"); let documents_res = doc .resources @@ -134,18 +148,22 @@ fn build_write_request( "documentId": document_id }); - let body = json!({ - "requests": [ - { + let requests = match content_format { + "markdown" => docs_markdown::markdown_to_batch_requests(text), + _ => { + // Default: plain text insertion + vec![json!({ "insertText": { "text": text, "endOfSegmentLocation": { - "segmentId": "" // Empty means body + "segmentId": "" } } - } - ] - }); + })] + } + }; + + let body = json!({ "requests": requests }); let scopes: Vec = batch_update_method .scopes @@ -187,7 +205,12 @@ mod tests { fn make_matches_write(args: &[&str]) -> ArgMatches { let cmd = Command::new("test") .arg(Arg::new("document").long("document")) - .arg(Arg::new("text").long("text")); + .arg(Arg::new("text").long("text")) + .arg( + Arg::new("content-format") + .long("content-format") + .default_value("plaintext"), + ); cmd.try_get_matches_from(args).unwrap() } @@ -202,4 +225,28 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } + + #[test] + fn test_build_write_request_markdown() { + let doc = make_mock_doc(); + let matches = make_matches_write(&[ + "test", + "--document", + "456", + "--text", + "# Hello\n\nSome **bold** text.", + "--content-format", + "markdown", + ]); + let (params, body, scopes) = build_write_request(&matches, &doc).unwrap(); + + assert!(params.contains("456")); + // Should contain insertText and updateParagraphStyle (for heading) and updateTextStyle (for bold) + assert!(body.contains("insertText")); + assert!(body.contains("updateParagraphStyle")); + assert!(body.contains("HEADING_1")); + assert!(body.contains("updateTextStyle")); + assert!(body.contains("\"bold\":true") || body.contains("\"bold\": true")); + assert_eq!(scopes[0], "https://scope"); + } } diff --git a/src/helpers/docs_markdown.rs b/src/helpers/docs_markdown.rs new file mode 100644 index 00000000..bf46eb73 --- /dev/null +++ b/src/helpers/docs_markdown.rs @@ -0,0 +1,773 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Markdown-to-Google-Docs batchUpdate conversion. +//! +//! Parses markdown using pulldown-cmark and produces a list of Google Docs API +//! request objects (insertText, updateParagraphStyle, updateTextStyle, +//! createParagraphBullets) suitable for a single `documents.batchUpdate` call. +//! +//! Strategy: +//! 1. Walk the markdown AST, collecting text and formatting metadata. +//! 2. Concatenate all text into one string while tracking 1-based character +//! indices for every formatted range. +//! 3. Emit one `insertText` request (endOfSegmentLocation). +//! 4. Emit N formatting requests using the pre-computed ranges. + +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Convert a markdown string into a vector of Google Docs batchUpdate request +/// objects. The first element is always an `insertText`; the rest are styling +/// requests. +pub fn markdown_to_batch_requests(markdown: &str) -> Vec { + let (full_text, format_ranges) = collect_text_and_ranges(markdown); + + if full_text.is_empty() { + return vec![]; + } + + let mut requests: Vec = Vec::new(); + + // 1. Single insertText with the full concatenated text. + requests.push(json!({ + "insertText": { + "text": full_text, + "endOfSegmentLocation": { + "segmentId": "" + } + } + })); + + // 2. Formatting requests. Because all text is inserted in one shot, the + // 1-based indices we tracked are stable. + for range in &format_ranges { + match &range.kind { + FormatKind::Heading(level) => { + let named_style = heading_level_to_named_style(*level); + requests.push(json!({ + "updateParagraphStyle": { + "paragraphStyle": { + "namedStyleType": named_style + }, + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "fields": "namedStyleType" + } + })); + } + FormatKind::Bold => { + requests.push(json!({ + "updateTextStyle": { + "textStyle": { + "bold": true + }, + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "fields": "bold" + } + })); + } + FormatKind::Italic => { + requests.push(json!({ + "updateTextStyle": { + "textStyle": { + "italic": true + }, + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "fields": "italic" + } + })); + } + FormatKind::InlineCode => { + requests.push(json!({ + "updateTextStyle": { + "textStyle": { + "weightedFontFamily": { + "fontFamily": "Courier New" + } + }, + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "fields": "weightedFontFamily" + } + })); + } + FormatKind::CodeBlock => { + requests.push(json!({ + "updateTextStyle": { + "textStyle": { + "weightedFontFamily": { + "fontFamily": "Courier New" + } + }, + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "fields": "weightedFontFamily" + } + })); + } + FormatKind::Link(url) => { + requests.push(json!({ + "updateTextStyle": { + "textStyle": { + "link": { + "url": url + } + }, + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "fields": "link" + } + })); + } + FormatKind::UnorderedList => { + requests.push(json!({ + "createParagraphBullets": { + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE" + } + })); + } + FormatKind::OrderedList => { + requests.push(json!({ + "createParagraphBullets": { + "range": { + "startIndex": range.start, + "endIndex": range.end + }, + "bulletPreset": "NUMBERED_DECIMAL_ALPHA_ROMAN" + } + })); + } + } + } + + requests +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum FormatKind { + Heading(u8), + Bold, + Italic, + InlineCode, + CodeBlock, + Link(String), + UnorderedList, + OrderedList, +} + +#[derive(Debug, Clone)] +struct FormatRange { + start: usize, // 1-based + end: usize, // 1-based, exclusive + kind: FormatKind, +} + +// --------------------------------------------------------------------------- +// Markdown walking +// --------------------------------------------------------------------------- + +/// Walk the markdown AST. Returns (full_text, format_ranges). +/// +/// `full_text` is the concatenated plain text (with newlines for paragraph +/// boundaries). `format_ranges` uses 1-based indices compatible with the +/// Google Docs API. +fn collect_text_and_ranges(markdown: &str) -> (String, Vec) { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_STRIKETHROUGH); + let parser = Parser::new_ext(markdown, opts); + + let mut full_text = String::new(); + // Cursor tracks the *next* 1-based index we will write to. + // Google Docs body starts at index 1. + let mut cursor: usize = 1; + + let mut format_ranges: Vec = Vec::new(); + + // Stack of (tag, start_index) for nested inline styles. + let mut style_stack: Vec<(StyleEntry, usize)> = Vec::new(); + + // Track list context + let mut list_stack: Vec = Vec::new(); + + // Track whether we are inside a code block + // code block state tracked via code_block_start + let mut code_block_start: usize = 0; + + // Track heading context + // heading state tracked via heading_start/heading_level + let mut heading_level: u8 = 0; + let mut heading_start: usize = 0; + + // Need to add newlines between paragraphs but not double at start + let mut needs_paragraph_break = false; + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + if needs_paragraph_break { + full_text.push('\n'); + cursor += 1; + } + // heading started (tracked via heading_start) + heading_level = heading_level_to_u8(level); + heading_start = cursor; + } + Tag::Paragraph => { + if needs_paragraph_break { + full_text.push('\n'); + cursor += 1; + } + } + Tag::Emphasis => { + style_stack.push((StyleEntry::Italic, cursor)); + } + Tag::Strong => { + style_stack.push((StyleEntry::Bold, cursor)); + } + Tag::Link { dest_url, .. } => { + style_stack.push((StyleEntry::Link(dest_url.to_string()), cursor)); + } + Tag::CodeBlock(_) => { + if needs_paragraph_break { + full_text.push('\n'); + cursor += 1; + } + // code block started (tracked via code_block_start) + code_block_start = cursor; + } + Tag::List(first_item) => { + if needs_paragraph_break && list_stack.is_empty() { + full_text.push('\n'); + cursor += 1; + } + let ordered = first_item.is_some(); + list_stack.push(ListContext { + ordered, + item_start: 0, + }); + } + Tag::Item => { + // Mark the start for bullet formatting + if let Some(ctx) = list_stack.last_mut() { + // Add newline separator between list items (but not before the first) + if ctx.item_start > 0 { + full_text.push('\n'); + cursor += 1; + } + ctx.item_start = cursor; + } + } + _ => {} + }, + Event::End(tag_end) => match tag_end { + TagEnd::Heading(_) => { + // Append newline after heading text + full_text.push('\n'); + let heading_end = cursor; // end before the newline char for style range + cursor += 1; + format_ranges.push(FormatRange { + start: heading_start, + end: heading_end, + kind: FormatKind::Heading(heading_level), + }); + // heading ended + needs_paragraph_break = true; + } + TagEnd::Paragraph => { + full_text.push('\n'); + cursor += 1; + needs_paragraph_break = true; + } + TagEnd::Emphasis => { + if let Some((StyleEntry::Italic, start)) = style_stack.pop() { + format_ranges.push(FormatRange { + start, + end: cursor, + kind: FormatKind::Italic, + }); + } + } + TagEnd::Strong => { + if let Some((StyleEntry::Bold, start)) = style_stack.pop() { + format_ranges.push(FormatRange { + start, + end: cursor, + kind: FormatKind::Bold, + }); + } + } + TagEnd::Link => { + if let Some((StyleEntry::Link(url), start)) = style_stack.pop() { + format_ranges.push(FormatRange { + start, + end: cursor, + kind: FormatKind::Link(url), + }); + } + } + TagEnd::CodeBlock => { + // The code block text already got appended via Event::Text. + // Don't add trailing newline if text already ends with one. + if !full_text.ends_with('\n') { + full_text.push('\n'); + cursor += 1; + } + format_ranges.push(FormatRange { + start: code_block_start, + end: cursor, + kind: FormatKind::CodeBlock, + }); + // code block ended + needs_paragraph_break = true; + } + TagEnd::Item => { + // Record bullet range for this item + if let Some(ctx) = list_stack.last() { + let kind = if ctx.ordered { + FormatKind::OrderedList + } else { + FormatKind::UnorderedList + }; + format_ranges.push(FormatRange { + start: ctx.item_start, + end: cursor, + kind, + }); + } + } + TagEnd::List(_) => { + list_stack.pop(); + // Add trailing newline after list + full_text.push('\n'); + cursor += 1; + needs_paragraph_break = true; + } + _ => {} + }, + Event::Text(text) => { + let s = text.as_ref(); + full_text.push_str(s); + cursor += s.encode_utf16().count(); + } + Event::Code(code) => { + // Inline code + let s = code.as_ref(); + let start = cursor; + full_text.push_str(s); + cursor += s.encode_utf16().count(); + format_ranges.push(FormatRange { + start, + end: cursor, + kind: FormatKind::InlineCode, + }); + } + Event::SoftBreak => { + full_text.push(' '); + cursor += 1; + } + Event::HardBreak => { + full_text.push('\n'); + cursor += 1; + } + _ => {} + } + } + + (full_text, format_ranges) +} + +#[derive(Debug)] +enum StyleEntry { + Bold, + Italic, + Link(String), +} + +#[derive(Debug)] +struct ListContext { + ordered: bool, + /// 1-based start index of current item (0 means no item started yet). + item_start: usize, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn heading_level_to_u8(level: HeadingLevel) -> u8 { + match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + HeadingLevel::H5 => 5, + HeadingLevel::H6 => 6, + } +} + +fn heading_level_to_named_style(level: u8) -> &'static str { + match level { + 1 => "HEADING_1", + 2 => "HEADING_2", + 3 => "HEADING_3", + 4 => "HEADING_4", + 5 => "HEADING_5", + 6 => "HEADING_6", + _ => "NORMAL_TEXT", + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Helper: extract the full inserted text from the first request. + fn inserted_text(requests: &[Value]) -> &str { + requests[0]["insertText"]["text"].as_str().unwrap() + } + + // Helper: count requests of a given type. + fn count_request_type(requests: &[Value], key: &str) -> usize { + requests.iter().filter(|r| r.get(key).is_some()).count() + } + + #[test] + fn test_plain_text_passthrough() { + let requests = markdown_to_batch_requests("Hello, world!"); + assert_eq!(requests.len(), 1, "plain text should produce only insertText"); + assert_eq!(inserted_text(&requests), "Hello, world!\n"); + } + + #[test] + fn test_empty_input() { + let requests = markdown_to_batch_requests(""); + assert!(requests.is_empty()); + } + + #[test] + fn test_heading_conversion() { + let md = "# Title\n\n## Subtitle\n\n### Section\n"; + let requests = markdown_to_batch_requests(md); + + // 1 insertText + 3 headings + assert_eq!(count_request_type(&requests, "insertText"), 1); + assert_eq!(count_request_type(&requests, "updateParagraphStyle"), 3); + + // Check heading levels + let h1 = &requests[1]["updateParagraphStyle"]; + assert_eq!( + h1["paragraphStyle"]["namedStyleType"].as_str().unwrap(), + "HEADING_1" + ); + let h2 = &requests[2]["updateParagraphStyle"]; + assert_eq!( + h2["paragraphStyle"]["namedStyleType"].as_str().unwrap(), + "HEADING_2" + ); + let h3 = &requests[3]["updateParagraphStyle"]; + assert_eq!( + h3["paragraphStyle"]["namedStyleType"].as_str().unwrap(), + "HEADING_3" + ); + } + + #[test] + fn test_bold_italic_conversion() { + let md = "This is **bold** and *italic* text."; + let requests = markdown_to_batch_requests(md); + + // 1 insertText + 1 bold + 1 italic + assert_eq!(count_request_type(&requests, "insertText"), 1); + assert_eq!(count_request_type(&requests, "updateTextStyle"), 2); + + let text = inserted_text(&requests); + assert!(text.contains("bold")); + assert!(text.contains("italic")); + // No markdown syntax in output + assert!(!text.contains("**")); + assert!(!text.contains("*italic*")); + + // Bold range + let bold_req = requests + .iter() + .find(|r| { + r.get("updateTextStyle") + .and_then(|s| s["textStyle"].get("bold")) + .is_some() + }) + .unwrap(); + assert_eq!( + bold_req["updateTextStyle"]["textStyle"]["bold"] + .as_bool() + .unwrap(), + true + ); + + // Italic range + let italic_req = requests + .iter() + .find(|r| { + r.get("updateTextStyle") + .and_then(|s| s["textStyle"].get("italic")) + .is_some() + }) + .unwrap(); + assert_eq!( + italic_req["updateTextStyle"]["textStyle"]["italic"] + .as_bool() + .unwrap(), + true + ); + } + + #[test] + fn test_inline_code() { + let md = "Use `println!` to print."; + let requests = markdown_to_batch_requests(md); + + let text = inserted_text(&requests); + assert!(text.contains("println!")); + assert!(!text.contains('`')); + + let code_req = requests + .iter() + .find(|r| { + r.get("updateTextStyle") + .and_then(|s| s["textStyle"].get("weightedFontFamily")) + .is_some() + }) + .unwrap(); + assert_eq!( + code_req["updateTextStyle"]["textStyle"]["weightedFontFamily"]["fontFamily"] + .as_str() + .unwrap(), + "Courier New" + ); + } + + #[test] + fn test_index_tracking_correctness() { + // "Hello **world**\n" -> text = "Hello world\n" + // "Hello " = indices 1..7 (6 chars), "world" = 7..12 (5 chars), "\n" = 12..13 + let md = "Hello **world**"; + let requests = markdown_to_batch_requests(md); + + let text = inserted_text(&requests); + assert_eq!(text, "Hello world\n"); + + let bold_req = requests + .iter() + .find(|r| r.get("updateTextStyle").is_some()) + .unwrap(); + let range = &bold_req["updateTextStyle"]["range"]; + // "world" starts at 1-based index 7, ends at 12 + assert_eq!(range["startIndex"].as_u64().unwrap(), 7); + assert_eq!(range["endIndex"].as_u64().unwrap(), 12); + } + + #[test] + fn test_mixed_formatting() { + let md = "# My Doc\n\nSome **bold** and *italic* text.\n\n- item one\n- item two\n"; + let requests = markdown_to_batch_requests(md); + + let text = inserted_text(&requests); + // Should contain all plain text + assert!(text.contains("My Doc")); + assert!(text.contains("bold")); + assert!(text.contains("italic")); + assert!(text.contains("item one")); + assert!(text.contains("item two")); + + // Should have heading, bold, italic, and bullets + assert!(count_request_type(&requests, "updateParagraphStyle") >= 1); + assert!(count_request_type(&requests, "updateTextStyle") >= 2); + assert!(count_request_type(&requests, "createParagraphBullets") >= 2); + } + + #[test] + fn test_unordered_list() { + let md = "- alpha\n- beta\n"; + let requests = markdown_to_batch_requests(md); + + let bullet_count = count_request_type(&requests, "createParagraphBullets"); + assert_eq!(bullet_count, 2); + + let bullet_req = requests + .iter() + .find(|r| r.get("createParagraphBullets").is_some()) + .unwrap(); + assert_eq!( + bullet_req["createParagraphBullets"]["bulletPreset"] + .as_str() + .unwrap(), + "BULLET_DISC_CIRCLE_SQUARE" + ); + } + + #[test] + fn test_ordered_list() { + let md = "1. first\n2. second\n"; + let requests = markdown_to_batch_requests(md); + + let bullet_count = count_request_type(&requests, "createParagraphBullets"); + assert_eq!(bullet_count, 2); + + let bullet_req = requests + .iter() + .find(|r| r.get("createParagraphBullets").is_some()) + .unwrap(); + assert_eq!( + bullet_req["createParagraphBullets"]["bulletPreset"] + .as_str() + .unwrap(), + "NUMBERED_DECIMAL_ALPHA_ROMAN" + ); + } + + #[test] + fn test_code_block() { + let md = "```\nfn main() {}\n```\n"; + let requests = markdown_to_batch_requests(md); + + let text = inserted_text(&requests); + assert!(text.contains("fn main() {}")); + + // Code block gets monospace styling + let code_req = requests + .iter() + .find(|r| { + r.get("updateTextStyle") + .and_then(|s| s["textStyle"].get("weightedFontFamily")) + .is_some() + }) + .unwrap(); + assert_eq!( + code_req["updateTextStyle"]["textStyle"]["weightedFontFamily"]["fontFamily"] + .as_str() + .unwrap(), + "Courier New" + ); + } + + #[test] + fn test_link() { + let md = "Visit [Google](https://google.com) today."; + let requests = markdown_to_batch_requests(md); + + let text = inserted_text(&requests); + assert!(text.contains("Google")); + assert!(!text.contains("https://google.com")); + + let link_req = requests + .iter() + .find(|r| { + r.get("updateTextStyle") + .and_then(|s| s["textStyle"].get("link")) + .is_some() + }) + .unwrap(); + assert_eq!( + link_req["updateTextStyle"]["textStyle"]["link"]["url"] + .as_str() + .unwrap(), + "https://google.com" + ); + } + + #[test] + fn test_heading_indices_sequential() { + let md = "# A\n\n## B\n"; + let requests = markdown_to_batch_requests(md); + + let text = inserted_text(&requests); + // "A\n" + "\n" (paragraph break) + "B\n" = "A\n\nB\n" (len 5) + assert_eq!(text, "A\n\nB\n"); + + let h1 = &requests[1]["updateParagraphStyle"]["range"]; + // "A" is at index 1..2 + assert_eq!(h1["startIndex"].as_u64().unwrap(), 1); + assert_eq!(h1["endIndex"].as_u64().unwrap(), 2); + + let h2 = &requests[2]["updateParagraphStyle"]["range"]; + // "B" is at index 4..5 (after "A\n\n") + assert_eq!(h2["startIndex"].as_u64().unwrap(), 4); + assert_eq!(h2["endIndex"].as_u64().unwrap(), 5); + } + + #[test] + fn test_all_requests_have_valid_ranges() { + let md = "# Title\n\nHello **bold** *italic* `code`\n\n- item\n"; + let requests = markdown_to_batch_requests(md); + let text = inserted_text(&requests); + let text_len = text.len(); + + for req in &requests[1..] { + // Find the range in whichever request type + let range = if let Some(v) = req.get("updateParagraphStyle") { + v.get("range") + } else if let Some(v) = req.get("updateTextStyle") { + v.get("range") + } else if let Some(v) = req.get("createParagraphBullets") { + v.get("range") + } else { + None + }; + + if let Some(range) = range { + let start = range["startIndex"].as_u64().unwrap() as usize; + let end = range["endIndex"].as_u64().unwrap() as usize; + assert!(start >= 1, "start index must be >= 1, got {}", start); + assert!( + end <= text_len + 1, + "end index {} exceeds text length + 1 ({})", + end, + text_len + 1 + ); + assert!(start < end, "start {} must be < end {}", start, end); + } + } + } +} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 378f1ea5..c975ba8c 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -19,6 +19,7 @@ use std::pin::Pin; pub mod calendar; pub mod chat; pub mod docs; +pub mod docs_markdown; pub mod drive; pub mod events; pub mod gmail;