use crate::error::{DrivenError, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotionConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub api_token: String,
pub default_database: Option<String>,
}
fn default_true() -> bool {
true
}
impl Default for NotionConfig {
fn default() -> Self {
Self {
enabled: true,
api_token: String::new(),
default_database: None,
}
}
}
impl NotionConfig {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| DrivenError::Io(e))?;
Self::parse_sr(&content)
}
fn parse_sr(_content: &str) -> Result<Self> {
Ok(Self::default())
}
pub fn resolve_env_vars(&mut self) {
if self.api_token.is_empty() || self.api_token.starts_with('$') {
self.api_token = std::env::var("NOTION_API_TOKEN")
.or_else(|_| std::env::var("NOTION_TOKEN"))
.unwrap_or_default();
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotionPage {
pub id: String,
pub title: String,
pub parent: NotionParent,
pub url: String,
pub created_time: String,
pub last_edited_time: String,
pub icon: Option<NotionIcon>,
pub cover: Option<String>,
pub archived: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum NotionParent {
#[serde(rename = "database_id")]
Database { database_id: String },
#[serde(rename = "page_id")]
Page { page_id: String },
#[serde(rename = "workspace")]
Workspace,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum NotionIcon {
#[serde(rename = "emoji")]
Emoji { emoji: String },
#[serde(rename = "external")]
External { external: NotionExternalFile },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotionExternalFile {
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotionDatabase {
pub id: String,
pub title: String,
pub url: String,
pub properties: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotionBlock {
pub id: String,
pub block_type: String,
pub has_children: bool,
pub content: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichText {
pub content: String,
pub annotations: Option<TextAnnotations>,
pub href: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextAnnotations {
pub bold: bool,
pub italic: bool,
pub strikethrough: bool,
pub underline: bool,
pub code: bool,
pub color: String,
}
pub struct NotionClient {
config: NotionConfig,
base_url: String,
}
impl NotionClient {
const API_BASE: &'static str = "https://api.notion.com/v1";
const API_VERSION: &'static str = "2022-06-28";
pub fn new(config: &NotionConfig) -> Result<Self> {
let mut config = config.clone();
config.resolve_env_vars();
Ok(Self {
config,
base_url: Self::API_BASE.to_string(),
})
}
pub fn is_configured(&self) -> bool {
!self.config.api_token.is_empty()
}
pub async fn get_page(&self, page_id: &str) -> Result<NotionPage> {
let url = format!("{}/pages/{}", self.base_url, page_id);
let response = self.api_get(&url).await?;
self.parse_page_response(response)
}
pub async fn create_page(
&self,
title: &str,
parent_id: &str,
content: Vec<NotionBlock>,
) -> Result<NotionPage> {
let url = format!("{}/pages", self.base_url);
let body = json!({
"parent": { "database_id": parent_id },
"properties": {
"title": {
"title": [{
"text": { "content": title }
}]
}
},
"children": content.iter().map(|b| &b.content).collect::<Vec<_>>()
});
let response = self.api_post(&url, body).await?;
self.parse_page_response(response)
}
pub async fn update_page(&self, page_id: &str, properties: serde_json::Value) -> Result<NotionPage> {
let url = format!("{}/pages/{}", self.base_url, page_id);
let body = json!({ "properties": properties });
let response = self.api_patch(&url, body).await?;
self.parse_page_response(response)
}
pub async fn archive_page(&self, page_id: &str) -> Result<()> {
let url = format!("{}/pages/{}", self.base_url, page_id);
let body = json!({ "archived": true });
self.api_patch(&url, body).await?;
Ok(())
}
pub async fn get_database(&self, database_id: &str) -> Result<NotionDatabase> {
let url = format!("{}/databases/{}", self.base_url, database_id);
let response = self.api_get(&url).await?;
Ok(NotionDatabase {
id: response["id"].as_str().unwrap_or_default().to_string(),
title: response["title"][0]["plain_text"]
.as_str()
.unwrap_or_default()
.to_string(),
url: response["url"].as_str().unwrap_or_default().to_string(),
properties: response["properties"].clone(),
})
}
pub async fn query_database(
&self,
database_id: &str,
filter: Option<serde_json::Value>,
sorts: Option<Vec<serde_json::Value>>,
) -> Result<Vec<NotionPage>> {
let url = format!("{}/databases/{}/query", self.base_url, database_id);
let mut body = json!({});
if let Some(f) = filter {
body["filter"] = f;
}
if let Some(s) = sorts {
body["sorts"] = json!(s);
}
let response = self.api_post(&url, body).await?;
let results = response["results"]
.as_array()
.ok_or_else(|| DrivenError::Parse("Invalid response".into()))?;
results
.iter()
.map(|r| self.parse_page_response(r.clone()))
.collect()
}
pub async fn get_blocks(&self, page_id: &str) -> Result<Vec<NotionBlock>> {
let url = format!("{}/blocks/{}/children", self.base_url, page_id);
let response = self.api_get(&url).await?;
let results = response["results"]
.as_array()
.ok_or_else(|| DrivenError::Parse("Invalid response".into()))?;
Ok(results
.iter()
.map(|b| NotionBlock {
id: b["id"].as_str().unwrap_or_default().to_string(),
block_type: b["type"].as_str().unwrap_or_default().to_string(),
has_children: b["has_children"].as_bool().unwrap_or(false),
content: b.clone(),
})
.collect())
}
pub async fn append_blocks(&self, page_id: &str, blocks: Vec<serde_json::Value>) -> Result<()> {
let url = format!("{}/blocks/{}/children", self.base_url, page_id);
let body = json!({ "children": blocks });
self.api_patch(&url, body).await?;
Ok(())
}
pub async fn delete_block(&self, block_id: &str) -> Result<()> {
let url = format!("{}/blocks/{}", self.base_url, block_id);
self.api_delete(&url).await
}
pub async fn search(&self, query: &str, filter: Option<&str>) -> Result<Vec<serde_json::Value>> {
let url = format!("{}/search", self.base_url);
let mut body = json!({ "query": query });
if let Some(f) = filter {
body["filter"] = json!({ "value": f, "property": "object" });
}
let response = self.api_post(&url, body).await?;
Ok(response["results"]
.as_array()
.cloned()
.unwrap_or_default())
}
pub fn paragraph_block(text: &str) -> serde_json::Value {
json!({
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{
"type": "text",
"text": { "content": text }
}]
}
})
}
pub fn heading_block(text: &str, level: u8) -> serde_json::Value {
let heading_type = match level {
1 => "heading_1",
2 => "heading_2",
_ => "heading_3",
};
json!({
"object": "block",
"type": heading_type,
heading_type: {
"rich_text": [{
"type": "text",
"text": { "content": text }
}]
}
})
}
pub fn bullet_block(text: &str) -> serde_json::Value {
json!({
"object": "block",
"type": "bulleted_list_item",
"bulleted_list_item": {
"rich_text": [{
"type": "text",
"text": { "content": text }
}]
}
})
}
pub fn todo_block(text: &str, checked: bool) -> serde_json::Value {
json!({
"object": "block",
"type": "to_do",
"to_do": {
"rich_text": [{
"type": "text",
"text": { "content": text }
}],
"checked": checked
}
})
}
async fn api_get(&self, url: &str) -> Result<serde_json::Value> {
let client = reqwest::Client::new();
let response = client
.get(url)
.header("Authorization", format!("Bearer {}", self.config.api_token))
.header("Notion-Version", Self::API_VERSION)
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
let error = response.text().await.unwrap_or_default();
return Err(DrivenError::Api(format!("Notion API error: {}", error)));
}
response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))
}
async fn api_post(&self, url: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let client = reqwest::Client::new();
let response = client
.post(url)
.header("Authorization", format!("Bearer {}", self.config.api_token))
.header("Notion-Version", Self::API_VERSION)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
let error = response.text().await.unwrap_or_default();
return Err(DrivenError::Api(format!("Notion API error: {}", error)));
}
response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))
}
async fn api_patch(&self, url: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let client = reqwest::Client::new();
let response = client
.patch(url)
.header("Authorization", format!("Bearer {}", self.config.api_token))
.header("Notion-Version", Self::API_VERSION)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
let error = response.text().await.unwrap_or_default();
return Err(DrivenError::Api(format!("Notion API error: {}", error)));
}
response
.json()
.await
.map_err(|e| DrivenError::Parse(e.to_string()))
}
async fn api_delete(&self, url: &str) -> Result<()> {
let client = reqwest::Client::new();
let response = client
.delete(url)
.header("Authorization", format!("Bearer {}", self.config.api_token))
.header("Notion-Version", Self::API_VERSION)
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
if !response.status().is_success() {
let error = response.text().await.unwrap_or_default();
return Err(DrivenError::Api(format!("Notion API error: {}", error)));
}
Ok(())
}
fn parse_page_response(&self, response: serde_json::Value) -> Result<NotionPage> {
let title = response["properties"]["title"]["title"][0]["plain_text"]
.as_str()
.or_else(|| response["properties"]["Name"]["title"][0]["plain_text"].as_str())
.unwrap_or_default()
.to_string();
Ok(NotionPage {
id: response["id"].as_str().unwrap_or_default().to_string(),
title,
parent: serde_json::from_value(response["parent"].clone())
.unwrap_or(NotionParent::Workspace),
url: response["url"].as_str().unwrap_or_default().to_string(),
created_time: response["created_time"]
.as_str()
.unwrap_or_default()
.to_string(),
last_edited_time: response["last_edited_time"]
.as_str()
.unwrap_or_default()
.to_string(),
icon: serde_json::from_value(response["icon"].clone()).ok(),
cover: response["cover"]["external"]["url"]
.as_str()
.map(String::from),
archived: response["archived"].as_bool().unwrap_or(false),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = NotionConfig::default();
assert!(config.enabled);
}
#[test]
fn test_paragraph_block() {
let block = NotionClient::paragraph_block("Hello, world!");
assert_eq!(block["type"], "paragraph");
}
#[test]
fn test_todo_block() {
let block = NotionClient::todo_block("Task 1", false);
assert_eq!(block["type"], "to_do");
assert_eq!(block["to_do"]["checked"], false);
}
}