use anyhow::{bail, Context, Result};
use colored::Colorize;
use std::time::Duration;
use crate::utils::{
get_api_version, normalize_page_id, DEFAULT_RETRY_DELAY_SECS, MAX_RETRIES, NOTION_API_BASE,
};
#[derive(Debug, Clone, Default)]
pub struct RichTextSegment {
pub text: String,
pub link: Option<String>,
pub bold: bool,
pub italic: bool,
pub code: bool,
}
impl RichTextSegment {
pub fn plain(text: &str) -> Self {
Self {
text: text.to_string(),
..Default::default()
}
}
pub fn link(text: &str, url: &str) -> Self {
Self {
text: text.to_string(),
link: Some(url.to_string()),
..Default::default()
}
}
#[allow(dead_code)]
pub fn code_inline(text: &str) -> Self {
Self {
text: text.to_string(),
code: true,
..Default::default()
}
}
#[allow(dead_code)]
pub fn bold(text: &str) -> Self {
Self {
text: text.to_string(),
bold: true,
..Default::default()
}
}
}
pub struct NotionClient {
api_key: String,
api_version: String,
client: reqwest::blocking::Client,
}
impl NotionClient {
pub fn new(api_key: String, timeout_secs: u64) -> Result<Self> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.context("Failed to create HTTP client")?;
Ok(Self {
api_key,
api_version: get_api_version(),
client,
})
}
fn execute_with_retry(
&self,
request_builder: impl Fn() -> reqwest::blocking::RequestBuilder,
) -> Result<reqwest::blocking::Response> {
let mut retries = 0;
loop {
let response = request_builder()
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Notion-Version", &self.api_version)
.send()
.context("Failed to send request")?;
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
if retries >= MAX_RETRIES {
bail!("Rate limit exceeded after {} retries", MAX_RETRIES);
}
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_RETRY_DELAY_SECS);
eprintln!(
"{} Rate limited. Waiting {} seconds before retry ({}/{})...",
"⚠".yellow(),
retry_after,
retries + 1,
MAX_RETRIES
);
std::thread::sleep(Duration::from_secs(retry_after));
retries += 1;
continue;
}
return response
.error_for_status()
.context("Notion API returned an error");
}
}
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<serde_json::Value>> {
let url = format!("{}/search", NOTION_API_BASE);
let mut all_results = Vec::new();
let mut start_cursor: Option<String> = None;
loop {
let mut body = serde_json::json!({
"query": query,
"page_size": 100.min(limit - all_results.len())
});
if let Some(cursor) = &start_cursor {
body["start_cursor"] = serde_json::json!(cursor);
}
let body_clone = body.clone();
let url_clone = url.clone();
let response = self.execute_with_retry(|| {
self.client
.post(&url_clone)
.header("Content-Type", "application/json")
.json(&body_clone)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
if let Some(results) = result.get("results").and_then(|r| r.as_array()) {
all_results.extend(results.clone());
}
let has_more = result
.get("has_more")
.and_then(|h| h.as_bool())
.unwrap_or(false);
if !has_more || all_results.len() >= limit {
break;
}
start_cursor = result
.get("next_cursor")
.and_then(|c| c.as_str())
.map(String::from);
if start_cursor.is_none() {
break;
}
}
Ok(all_results)
}
pub fn get_page(&self, page_id: &str) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/pages/{}", NOTION_API_BASE, page_id);
let response = self.execute_with_retry(|| self.client.get(&url))?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn get_blocks(&self, page_id: &str) -> Result<Vec<serde_json::Value>> {
let page_id = normalize_page_id(page_id)?;
let base_url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let mut all_blocks = Vec::new();
let mut start_cursor: Option<String> = None;
loop {
let request_url = if let Some(cursor) = &start_cursor {
format!("{}?start_cursor={}", base_url, cursor)
} else {
base_url.clone()
};
let response = self.execute_with_retry(|| self.client.get(&request_url))?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
if let Some(results) = result.get("results").and_then(|r| r.as_array()) {
all_blocks.extend(results.clone());
}
let has_more = result
.get("has_more")
.and_then(|h| h.as_bool())
.unwrap_or(false);
if !has_more {
break;
}
start_cursor = result
.get("next_cursor")
.and_then(|c| c.as_str())
.map(String::from);
if start_cursor.is_none() {
break;
}
}
Ok(all_blocks)
}
pub fn create_page(
&self,
parent_id: &str,
title: &str,
content: Option<&str>,
) -> Result<serde_json::Value> {
let parent_id = normalize_page_id(parent_id)?;
let url = format!("{}/pages", NOTION_API_BASE);
let mut children = vec![];
if let Some(text) = content {
children.push(serde_json::json!({
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{
"type": "text",
"text": { "content": text }
}]
}
}));
}
let body = serde_json::json!({
"parent": { "page_id": parent_id },
"properties": {
"title": {
"title": [{
"text": { "content": title }
}]
}
},
"children": children
});
let response = self.execute_with_retry(|| {
self.client
.post(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn append_blocks(&self, page_id: &str, content: &str) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let body = serde_json::json!({
"children": [{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{
"type": "text",
"text": { "content": content }
}]
}
}]
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn update_page(
&self,
page_id: &str,
title: Option<&str>,
icon: Option<&str>,
) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/pages/{}", NOTION_API_BASE, page_id);
let mut body = serde_json::json!({});
if let Some(new_title) = title {
body["properties"] = serde_json::json!({
"title": {
"title": [{
"text": { "content": new_title }
}]
}
});
}
if let Some(emoji) = icon {
body["icon"] = serde_json::json!({
"type": "emoji",
"emoji": emoji
});
}
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn delete_page(&self, page_id: &str) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/pages/{}", NOTION_API_BASE, page_id);
let body = serde_json::json!({
"archived": true
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn append_code_block(
&self,
page_id: &str,
code: &str,
language: &str,
) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let body = serde_json::json!({
"children": [{
"object": "block",
"type": "code",
"code": {
"rich_text": [{
"type": "text",
"text": { "content": code }
}],
"language": language
}
}]
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn append_bookmark(
&self,
page_id: &str,
url_str: &str,
caption: Option<&str>,
) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let bookmark_block = if let Some(cap) = caption {
serde_json::json!({
"object": "block",
"type": "bookmark",
"bookmark": {
"url": url_str,
"caption": [{
"type": "text",
"text": { "content": cap }
}]
}
})
} else {
serde_json::json!({
"object": "block",
"type": "bookmark",
"bookmark": {
"url": url_str
}
})
};
let body = serde_json::json!({
"children": [bookmark_block]
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn delete_block(&self, block_id: &str) -> Result<()> {
let block_id = normalize_page_id(block_id)?;
let url = format!("{}/blocks/{}", NOTION_API_BASE, block_id);
self.execute_with_retry(|| self.client.delete(&url))?;
Ok(())
}
pub fn append_heading(
&self,
page_id: &str,
text: &str,
level: u8,
) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let block_type = match level {
1 => "heading_1",
2 => "heading_2",
_ => "heading_3",
};
let body = serde_json::json!({
"children": [{
"object": "block",
"type": block_type,
(block_type): {
"rich_text": [{
"type": "text",
"text": { "content": text }
}]
}
}]
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn append_rich_text(
&self,
page_id: &str,
segments: &[RichTextSegment],
) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let rich_text: Vec<serde_json::Value> = segments
.iter()
.map(|seg| {
let mut text_obj = serde_json::json!({
"content": seg.text
});
if let Some(ref link) = seg.link {
text_obj["link"] = serde_json::json!({ "url": link });
}
let mut annotations = serde_json::json!({});
if seg.bold {
annotations["bold"] = serde_json::json!(true);
}
if seg.italic {
annotations["italic"] = serde_json::json!(true);
}
if seg.code {
annotations["code"] = serde_json::json!(true);
}
serde_json::json!({
"type": "text",
"text": text_obj,
"annotations": annotations
})
})
.collect();
let body = serde_json::json!({
"children": [{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": rich_text
}
}]
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn append_divider(&self, page_id: &str) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let body = serde_json::json!({
"children": [{
"object": "block",
"type": "divider",
"divider": {}
}]
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn append_bulleted_list(
&self,
page_id: &str,
items: &[String],
) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
let children: Vec<serde_json::Value> = items
.iter()
.map(|item| {
serde_json::json!({
"object": "block",
"type": "bulleted_list_item",
"bulleted_list_item": {
"rich_text": [{
"type": "text",
"text": { "content": item }
}]
}
})
})
.collect();
let body = serde_json::json!({
"children": children
});
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
Ok(result)
}
pub fn query_database(
&self,
database_id: &str,
filter: Option<&str>,
sort: Option<&str>,
direction: &str,
limit: usize,
) -> Result<Vec<serde_json::Value>> {
if limit == 0 {
return Ok(Vec::new());
}
let database_id = normalize_page_id(database_id)?;
let url = format!("{}/databases/{}/query", NOTION_API_BASE, database_id);
let mut all_results = Vec::new();
let mut start_cursor: Option<String> = None;
loop {
let remaining = limit.saturating_sub(all_results.len());
let page_size = remaining.clamp(1, 100);
let mut body = serde_json::json!({
"page_size": page_size
});
if let Some(cursor) = &start_cursor {
body["start_cursor"] = serde_json::json!(cursor);
}
if let Some(filter_str) = filter {
if let Some((prop_part, value)) = filter_str.split_once('=') {
let (prop, filter_type) = if let Some((p, t)) = prop_part.split_once(':') {
(p.trim(), t.trim())
} else {
(prop_part.trim(), "rich_text")
};
let filter_value = match filter_type {
"title" => serde_json::json!({
"property": prop,
"title": { "contains": value.trim() }
}),
"select" => serde_json::json!({
"property": prop,
"select": { "equals": value.trim() }
}),
"checkbox" => serde_json::json!({
"property": prop,
"checkbox": { "equals": value.trim().to_lowercase() == "true" }
}),
"number" => {
let num: f64 = value.trim().parse().unwrap_or(0.0);
serde_json::json!({
"property": prop,
"number": { "equals": num }
})
}
_ => serde_json::json!({
"property": prop,
"rich_text": { "contains": value.trim() }
}),
};
body["filter"] = filter_value;
}
}
if let Some(sort_prop) = sort {
body["sorts"] = serde_json::json!([{
"property": sort_prop,
"direction": if direction == "asc" { "ascending" } else { "descending" }
}]);
}
let body_clone = body.clone();
let url_clone = url.clone();
let response = self.execute_with_retry(|| {
self.client
.post(&url_clone)
.header("Content-Type", "application/json")
.json(&body_clone)
})?;
let result: serde_json::Value = response.json().context("Failed to parse response")?;
if let Some(results) = result.get("results").and_then(|r| r.as_array()) {
all_results.extend(results.clone());
}
let has_more = result
.get("has_more")
.and_then(|h| h.as_bool())
.unwrap_or(false);
if !has_more || all_results.len() >= limit {
break;
}
start_cursor = result
.get("next_cursor")
.and_then(|c| c.as_str())
.map(String::from);
if start_cursor.is_none() {
break;
}
}
Ok(all_results)
}
pub fn move_page(
&self,
page_id: &str,
new_parent_id: &str,
delete_original: bool,
) -> Result<serde_json::Value> {
let page_id = normalize_page_id(page_id)?;
let new_parent_id = normalize_page_id(new_parent_id)?;
eprintln!("{} Reading original page...", "→".blue());
let page = self.get_page(&page_id)?;
let title = page
.get("properties")
.and_then(|p| p.as_object())
.and_then(|props| {
props
.values()
.find(|v| v.get("type").and_then(|t| t.as_str()) == Some("title"))
})
.and_then(|t| t.get("title"))
.and_then(|t| t.as_array())
.and_then(|arr| arr.first())
.and_then(|t| t.get("plain_text"))
.and_then(|t| t.as_str())
.unwrap_or("Untitled");
eprintln!("{} Fetching blocks...", "→".blue());
let blocks = self.get_blocks(&page_id)?;
eprintln!("{} Creating new page under new parent...", "→".blue());
let new_page = self.create_page(&new_parent_id, title, None)?;
let new_page_id = new_page
.get("id")
.and_then(|id| id.as_str())
.context("Failed to get new page ID")?;
if !blocks.is_empty() {
eprintln!("{} Copying {} blocks...", "→".blue(), blocks.len());
self.copy_blocks_to_page(new_page_id, &blocks)?;
}
if delete_original {
eprintln!("{} Archiving original page...", "→".blue());
self.delete_page(&page_id)?;
}
Ok(new_page)
}
fn copy_blocks_to_page(&self, page_id: &str, blocks: &[serde_json::Value]) -> Result<()> {
let url = format!("{}/blocks/{}/children", NOTION_API_BASE, page_id);
for chunk in blocks.chunks(100) {
let converted: Vec<(serde_json::Value, Option<String>)> = chunk
.iter()
.filter_map(|block| {
let converted = self.convert_block_for_copy(block)?;
let original_id = if block.get("has_children") == Some(&serde_json::json!(true))
{
block.get("id").and_then(|id| id.as_str()).map(String::from)
} else {
None
};
Some((converted, original_id))
})
.collect();
if converted.is_empty() {
continue;
}
let children: Vec<serde_json::Value> =
converted.iter().map(|(b, _)| b.clone()).collect();
let body = serde_json::json!({ "children": children });
let response = self.execute_with_retry(|| {
self.client
.patch(&url)
.header("Content-Type", "application/json")
.json(&body)
})?;
let created: serde_json::Value = response.json().context("Failed to parse response")?;
if let Some(results) = created.get("results").and_then(|r| r.as_array()) {
for (i, (_, original_id)) in converted.iter().enumerate() {
if let Some(orig_id) = original_id {
if let Some(new_block) = results.get(i) {
if let Some(new_id) = new_block.get("id").and_then(|id| id.as_str()) {
let child_blocks = self.get_blocks(orig_id)?;
if !child_blocks.is_empty() {
self.copy_blocks_to_page(new_id, &child_blocks)?;
}
}
}
}
}
}
}
Ok(())
}
fn convert_block_for_copy(&self, block: &serde_json::Value) -> Option<serde_json::Value> {
let block_type = block.get("type")?.as_str()?;
let content = block.get(block_type)?;
let mut new_block = serde_json::json!({
"object": "block",
"type": block_type,
});
new_block[block_type] = content.clone();
if let Some(obj) = new_block.get_mut(block_type) {
if let Some(map) = obj.as_object_mut() {
map.remove("id");
map.remove("created_time");
map.remove("last_edited_time");
map.remove("created_by");
map.remove("last_edited_by");
map.remove("has_children");
map.remove("archived");
map.remove("in_trash");
}
}
Some(new_block)
}
}