use anyhow::{Context, Result};
use serde_json::{Value, json};
const NOTION_VERSION: &str = "2026-03-11";
const MAX_SPAN_CHARS: usize = 1900;
pub async fn send_page(
token: &str,
parent_page_id: &str,
title: &str,
body: &str,
) -> Result<String> {
let client = reqwest::Client::new();
let payload = json!({
"parent": { "page_id": parent_page_id },
"properties": {
"title": {
"title": [{ "type": "text", "text": { "content": title } }]
}
},
"children": markdown_to_blocks(body),
});
let resp = client
.post("https://api.notion.com/v1/pages")
.header("Authorization", format!("Bearer {token}"))
.header("Notion-Version", NOTION_VERSION)
.json(&payload)
.send()
.await
.context("Erro conectando ao Notion")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Notion API erro {status}: {body}");
}
let result: Value = resp
.json()
.await
.context("Erro parseando resposta do Notion")?;
Ok(result["url"].as_str().unwrap_or("").to_string())
}
fn markdown_to_blocks(text: &str) -> Vec<Value> {
let mut blocks: Vec<Value> = Vec::new();
let mut para_lines: Vec<&str> = Vec::new();
let mut code_fence: Option<String> = None; let mut code_lines: Vec<&str> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if code_fence.is_some() {
if trimmed == "```" || trimmed == "~~~" {
let code_content = code_lines.join("\n");
let lang = code_fence.take().unwrap_or_default();
code_lines.clear();
blocks.push(json!({
"object": "block",
"type": "code",
"code": {
"rich_text": [plain_span(&code_content)],
"language": if lang.is_empty() { "plain text" } else { &lang }
}
}));
} else {
code_lines.push(line);
}
continue;
}
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
flush_into(&mut blocks, &mut para_lines);
let lang = trimmed
.trim_start_matches('`')
.trim_start_matches('~')
.trim()
.to_string();
code_fence = Some(lang);
continue;
}
if trimmed.is_empty() {
if let Some(b) = flush_paragraph(¶_lines) {
blocks.push(b);
}
para_lines.clear();
continue;
}
if let Some(inner) = trimmed.strip_prefix("### ") {
flush_into(&mut blocks, &mut para_lines);
blocks.push(json!({
"object": "block",
"type": "heading_3",
"heading_3": { "rich_text": parse_inline(inner.trim()) }
}));
continue;
}
if let Some(inner) = trimmed.strip_prefix("## ") {
flush_into(&mut blocks, &mut para_lines);
blocks.push(json!({
"object": "block",
"type": "heading_2",
"heading_2": { "rich_text": parse_inline(inner.trim()) }
}));
continue;
}
if let Some(inner) = trimmed.strip_prefix("# ") {
flush_into(&mut blocks, &mut para_lines);
blocks.push(json!({
"object": "block",
"type": "heading_1",
"heading_1": { "rich_text": parse_inline(inner.trim()) }
}));
continue;
}
if let Some(inner) = try_bold_heading(trimmed) {
flush_into(&mut blocks, &mut para_lines);
blocks.push(json!({
"object": "block",
"type": "heading_2",
"heading_2": { "rich_text": [plain_span(inner)] }
}));
continue;
}
if let Some(rest) = try_bullet_line(line) {
flush_into(&mut blocks, &mut para_lines);
blocks.push(json!({
"object": "block",
"type": "bulleted_list_item",
"bulleted_list_item": { "rich_text": parse_inline(rest) }
}));
continue;
}
para_lines.push(trimmed);
}
if let Some(b) = flush_paragraph(¶_lines) {
blocks.push(b);
}
blocks
}
fn flush_into(blocks: &mut Vec<Value>, para_lines: &mut Vec<&str>) {
if let Some(b) = flush_paragraph(para_lines) {
blocks.push(b);
}
para_lines.clear();
}
fn try_bold_heading(line: &str) -> Option<&str> {
let inner = line.strip_prefix("**")?.strip_suffix("**")?;
if inner.contains("**") {
return None;
}
Some(inner)
}
fn try_bullet_line(line: &str) -> Option<&str> {
let stripped = line.trim_start();
for prefix in &["* ", "* ", "* ", "- ", "• ", "•"] {
if let Some(rest) = stripped.strip_prefix(prefix) {
return Some(rest.trim_start());
}
}
None
}
fn flush_paragraph(lines: &[&str]) -> Option<Value> {
if lines.is_empty() {
return None;
}
let content = lines.join(" ");
let rt = parse_inline(content.trim());
if rt.is_empty() {
return None;
}
Some(json!({
"object": "block",
"type": "paragraph",
"paragraph": { "rich_text": rt }
}))
}
fn parse_inline(text: &str) -> Vec<Value> {
let mut spans: Vec<Value> = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
let pos_backtick = remaining.find('`');
let pos_double = remaining.find("**");
let pos_single = find_single_asterisk(remaining);
let earliest = [
pos_backtick.map(|p| (p, 0u8)),
pos_double.map(|p| (p, 1u8)),
pos_single.map(|p| (p, 2u8)),
]
.into_iter()
.flatten()
.min_by_key(|(p, _)| *p);
match earliest {
None => {
spans.extend(span_chunks(remaining, false, false));
break;
}
Some((pos, 0)) => {
if pos > 0 {
spans.extend(span_chunks(&remaining[..pos], false, false));
}
let after = &remaining[pos + 1..];
if let Some(close) = after.find('`') {
let code = &after[..close];
spans.push(json!({
"type": "text",
"text": { "content": code },
"annotations": {
"bold": false, "italic": false, "strikethrough": false,
"underline": false, "code": true, "color": "default"
}
}));
remaining = &after[close + 1..];
} else {
spans.extend(span_chunks(remaining, false, false));
break;
}
}
Some((pos, 1)) => {
if pos > 0 {
spans.extend(span_chunks(&remaining[..pos], false, false));
}
let after = &remaining[pos + 2..];
if let Some(close) = after.find("**") {
spans.extend(span_chunks(&after[..close], true, false));
remaining = &after[close + 2..];
} else {
spans.extend(span_chunks(remaining, false, false));
break;
}
}
Some((pos, _)) => {
if pos > 0 {
spans.extend(span_chunks(&remaining[..pos], false, false));
}
let after = &remaining[pos + 1..];
if let Some(close) = find_single_asterisk(after) {
spans.extend(span_chunks(&after[..close], false, true));
remaining = &after[close + 1..];
} else {
spans.extend(span_chunks(remaining, false, false));
break;
}
}
}
}
spans
}
fn find_single_asterisk(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'*' {
let prev = i > 0 && bytes[i - 1] == b'*';
let next = i + 1 < bytes.len() && bytes[i + 1] == b'*';
if !prev && !next {
return Some(i);
}
}
i += 1;
}
None
}
fn span_chunks(text: &str, bold: bool, italic: bool) -> Vec<Value> {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return vec![];
}
chars
.chunks(MAX_SPAN_CHARS)
.map(|chunk| {
let content: String = chunk.iter().collect();
json!({
"type": "text",
"text": { "content": content },
"annotations": {
"bold": bold,
"italic": italic,
"strikethrough": false,
"underline": false,
"code": false,
"color": "default"
}
})
})
.collect()
}
fn plain_span(text: &str) -> Value {
json!({
"type": "text",
"text": { "content": text },
"annotations": {
"bold": false, "italic": false, "strikethrough": false,
"underline": false, "code": false, "color": "default"
}
})
}