diff --git a/.gitignore b/.gitignore index 47a0031..17a0c6d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ Cargo.lock .api_token todo_config.toml + + + + + +# my own tests; do never merge it with remote +wowa \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index e4cffb8..44faa89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ license = "MIT" [dependencies] serde_json = "1.0" +serde_path_to_error = "0.1.14" thiserror = "1.0" tracing = "0.1" @@ -31,6 +32,15 @@ features = ["full"] version = "1.0" features = ["derive"] +[dependencies.uuid] +version = "1.2.2" +features = [ + "v4", # Lets you generate random UUIDs + "fast-rng", # Use a faster (but still sufficiently random) RNG + "macro-diagnostics", # Enable better diagnostics for compile-time UUIDs + "serde", +] + [dev-dependencies] cargo-husky = "1" wiremock = "0.5.2" diff --git a/src/ids.rs b/src/ids.rs index 611212e..4bfa3ec 100644 --- a/src/ids.rs +++ b/src/ids.rs @@ -1,8 +1,9 @@ use std::fmt::Display; use std::fmt::Error; +use uuid::Uuid; pub trait Identifier: Display { - fn value(&self) -> &str; + fn value(&self) -> String; } /// Meant to be a helpful trait allowing anything that can be /// identified by the type specified in `ById`. @@ -27,16 +28,15 @@ where self } } - -macro_rules! identifer { +macro_rules! uuid_identifer { ($name:ident) => { #[derive(serde::Serialize, serde::Deserialize, Debug, Eq, PartialEq, Hash, Clone)] #[serde(transparent)] - pub struct $name(String); + pub struct $name(Uuid); impl Identifier for $name { - fn value(&self) -> &str { - &self.0 + fn value(&self) -> String { + self.0.to_string() } } @@ -53,17 +53,48 @@ macro_rules! identifer { type Err = Error; fn from_str(s: &str) -> Result { - Ok($name(s.to_string())) + Ok($name(Uuid::parse_str(s).unwrap())) + } + } + }; +} + +macro_rules! string_identifer { + ($name:ident) => { + #[derive(serde::Serialize, serde::Deserialize, Debug, Eq, PartialEq, Hash, Clone)] + #[serde(transparent)] + pub struct $name(String); + + impl Identifier for $name { + fn value(&self) -> String { + self.0.clone() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl std::str::FromStr for $name { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok($name(String::from_str(s).unwrap())) } } }; } -identifer!(DatabaseId); -identifer!(PageId); -identifer!(BlockId); -identifer!(UserId); -identifer!(PropertyId); +// According to Notion API Reference id's are UUIDv4 (https://developers.notion.com/reference/intro#json-conventions) +// can be represented with or without dashes. +// Using uuid crate. +uuid_identifer!(DatabaseId); +uuid_identifer!(PageId); +uuid_identifer!(BlockId); +string_identifer!(UserId); +string_identifer!(PropertyId); impl From for BlockId { fn from(page_id: PageId) -> Self { diff --git a/src/lib.rs b/src/lib.rs index dca0158..cf62940 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,8 @@ use crate::models::error::ErrorResponse; use crate::models::search::{DatabaseQuery, SearchRequest}; use crate::models::{Block, Database, ListResponse, Object, Page}; use ids::{AsIdentifier, PageId}; -use models::PageCreateRequest; +use models::paging::Paging; +use models::{PageCreateRequest, PageUpdateRequest}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::{header, Client, ClientBuilder, RequestBuilder}; use tracing::Instrument; @@ -13,7 +14,7 @@ pub mod models; pub use chrono; -const NOTION_API_VERSION: &str = "2022-02-22"; +const NOTION_API_VERSION: &str = "2022-06-28"; /// An wrapper Error type for all errors produced by the [`NotionApi`](NotionApi) client. #[derive(Debug, thiserror::Error)] @@ -95,12 +96,17 @@ impl NotionApi { .await .map_err(|source| Error::ResponseIoError { source })?; - tracing::debug!("JSON Response: {}", json); + // tracing::trace!("JSON Response: {}", json); #[cfg(test)] { dbg!(serde_json::from_str::(&json) .map_err(|source| Error::JsonParseError { source })?); } + let deserializer = &mut serde_json::Deserializer::from_str(&json); + let result: Result = serde_path_to_error::deserialize(deserializer); + if let Err(e) = result { + panic!("{} . path: {}", e, e.path()); + } let result = serde_json::from_str(&json).map_err(|source| Error::JsonParseError { source })?; @@ -198,6 +204,27 @@ impl NotionApi { } } + pub async fn update_page>( + &self, + page_id: &PageId, + page_update: T, + ) -> Result { + let result = self + .make_json_request( + self.client + .patch(format!( + "https://api.notion.com/v1/pages/{}", + page_id.as_id() + )) + .json(&page_update.into()), + ) + .await?; + match result { + Object::Page { page } => Ok(page), + response => Err(Error::UnexpectedResponse { response }), + } + } + /// Query a database and return the matching pages. pub async fn query_database( &self, @@ -227,10 +254,26 @@ impl NotionApi { pub async fn get_block_children>( &self, block_id: T, + paging: Paging, ) -> Result, Error> { + let query = { + [ + paging.start_cursor.map(|s| format!("start_cursor={}", s)), + paging.page_size.map(|p| format!("page_size={}", p)), + ] + .iter() + .filter_map(|i| i.clone()) + .collect::>() + .join("&") + }; + let query = if query.is_empty() { + query + } else { + format!("?{query}") + }; let result = self .make_json_request(self.client.get(&format!( - "https://api.notion.com/v1/blocks/{block_id}/children", + "https://api.notion.com/v1/blocks/{block_id}/children{query}", block_id = block_id.as_id() ))) .await?; diff --git a/src/models/mod.rs b/src/models/mod.rs index 3c9d12b..a2d3b68 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -186,6 +186,11 @@ pub struct PageCreateRequest { pub properties: Properties, } +#[derive(Serialize, Debug, Eq, PartialEq)] +pub struct PageUpdateRequest { + pub properties: Properties, +} + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct Page { pub id: PageId, @@ -395,12 +400,12 @@ pub struct TableOfContents { #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct ColumnListFields { - pub children: Vec, + pub children: Option>, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct ColumnFields { - pub children: Vec, + pub children: Option>, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] @@ -411,7 +416,7 @@ pub struct LinkPreviewFields { #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct TemplateFields { pub rich_text: Vec, - pub children: Vec, + pub children: Option>, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] @@ -430,7 +435,7 @@ pub struct SyncedFromObject { #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct SyncedBlockFields { pub synced_from: Option, - pub children: Vec, + pub children: Option>, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] @@ -438,12 +443,12 @@ pub struct TableFields { pub table_width: u64, pub has_column_header: bool, pub has_row_header: bool, - pub children: Vec, + pub children: Option>, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct TableRowFields { - pub cells: Vec, + pub cells: Vec>, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] @@ -615,6 +620,49 @@ pub enum Block { Unknown, } +impl Block { + pub fn has_children(&self) -> bool { + use Block::*; + match self { + Paragraph { common, .. } + | Heading1 { common, .. } + | Heading2 { common, .. } + | Heading3 { common, .. } + | Callout { common, .. } + | Quote { common, .. } + | BulletedListItem { common, .. } + | NumberedListItem { common, .. } + | ToDo { common, .. } + | Toggle { common, .. } + | Code { common, .. } + | ChildPage { common, .. } + | ChildDatabase { common, .. } + | Embed { common, .. } + | Image { common, .. } + | Video { common, .. } + | File { common, .. } + | Pdf { common, .. } + | Bookmark { common, .. } + | Equation { common, .. } + | Divider { common, .. } + | TableOfContents { common, .. } + | Breadcrumb { common, .. } + | ColumnList { common, .. } + | Column { common, .. } + | LinkPreview { common, .. } + | Template { common, .. } + | LinkToPage { common, .. } + | SyncedBlock { common, .. } + | Table { common, .. } + | TableRow { common, .. } + | Unsupported { common, .. } => common.has_children, + Unknown => { + panic!("Trying to reference identifier for unknown block!") + } + } + } +} + impl AsIdentifier for Block { fn as_id(&self) -> &BlockId { use Block::*; diff --git a/src/models/paging.rs b/src/models/paging.rs index c38ae11..9cf29ae 100644 --- a/src/models/paging.rs +++ b/src/models/paging.rs @@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize}; #[serde(transparent)] pub struct PagingCursor(String); +impl std::fmt::Display for PagingCursor { + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + f.write_str(&self.0) + } +} + #[derive(Serialize, Debug, Eq, PartialEq, Default, Clone)] pub struct Paging { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/models/properties.rs b/src/models/properties.rs index fcead03..5798409 100644 --- a/src/models/properties.rs +++ b/src/models/properties.rs @@ -1,7 +1,8 @@ + use crate::models::text::RichText; use crate::models::users::User; -use super::{DateTime, Number, Utc}; +use super::{DateTime, FileObject, Number, Utc}; use crate::ids::{DatabaseId, PageId, PropertyId}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; @@ -234,6 +235,28 @@ pub enum DateOrDateTime { DateTime(DateTime), } +impl DateOrDateTime { + pub fn as_date(&self) -> Option<&NaiveDate> { + match self { + DateOrDateTime::Date(date) => Some(date), + _ => None, + } + } + pub fn as_date_time(&self) -> Option> { + match self { + DateOrDateTime::Date(date) => Some( + date.clone() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_local_timezone(Utc) + .unwrap(), + ), + DateOrDateTime::DateTime(date_time) => Some(*date_time), + } + } +} + +/// #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct DateValue { pub start: DateOrDateTime, @@ -261,26 +284,47 @@ pub struct RelationValue { pub id: PageId, } +/// Notion API reference documentation is somewhat misleading +/// Rollup property value is defined on "Property values" +/// and "Page property values" #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum RollupValue { - Number { number: Option }, - Date { date: Option> }, - Array { array: Vec }, + /// According to + /// One another option is: String + /// String { string: Option } + Number { + number: Option, + function: RollupFunction, + }, + + /// + /// "Date rollup property values contain a date property value within the date property." + Date { + date: Option, + function: RollupFunction, + }, + Array { + array: Vec, + function: RollupFunction, + }, } +/// FileReference, a subject of File property value, augments FileObject with name String +/// #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct FileReference { pub name: String, - pub url: String, - pub mime_type: String, + #[serde(flatten)] + pub file_object: FileObject, } +/// #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] #[serde(tag = "type")] #[serde(rename_all = "snake_case")] pub enum PropertyValue { - // + /// Title { id: PropertyId, title: Vec, @@ -291,64 +335,75 @@ pub enum PropertyValue { id: PropertyId, rich_text: Vec, }, - /// + + /// Number { id: PropertyId, number: Option, }, - /// + + /// Select { id: PropertyId, select: Option, }, + /// Status { id: PropertyId, status: Option, }, - /// + + /// MultiSelect { id: PropertyId, - multi_select: Option>, + multi_select: Vec, }, - /// + /// Date { id: PropertyId, date: Option, }, - /// + + /// Formula { id: PropertyId, formula: FormulaResultValue, }, - /// + + /// /// It is actually an array of relations Relation { id: PropertyId, - relation: Option>, + has_more: bool, // added according to reference, but not sure if this not cause conflicts + relation: Vec, }, + /// - Rollup { - id: PropertyId, - rollup: Option, - }, - /// + Rollup { id: PropertyId, rollup: RollupValue }, + + /// People { id: PropertyId, people: Vec }, - /// + + /// Files { id: PropertyId, - files: Option>, + files: Vec, }, - /// + + /// Checkbox { id: PropertyId, checkbox: bool }, + /// Url { id: PropertyId, url: Option }, - /// + + /// Email { id: PropertyId, email: Option, }, - /// + + /// PhoneNumber { id: PropertyId, phone_number: String, @@ -372,6 +427,11 @@ pub enum PropertyValue { }, } + +/// TODO This is completely wrong. +/// rename to RollupArrayValueProperties +/// implement according to https://developers.notion.com/reference/page-property-values#rollup +/// /// #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] #[serde(tag = "type")] @@ -382,6 +442,13 @@ pub enum RollupPropertyValue { Text { rich_text: Vec, }, + + /// according to notion-sdk-js code + #[serde(rename = "rich_text")] + Title { + rich_text: Vec, + }, + /// Number { number: Option, @@ -403,10 +470,10 @@ pub enum RollupPropertyValue { Formula { formula: FormulaResultValue, }, - /// /// It is actually an array of relations + /// Relation { - relation: Option>, + relation: Vec, }, /// Rollup { diff --git a/src/models/properties/tests.rs b/src/models/properties/tests.rs index 86bead8..de659f5 100644 --- a/src/models/properties/tests.rs +++ b/src/models/properties/tests.rs @@ -1,3 +1,5 @@ +use crate::models::properties::{RollupFunction, RelationValue}; + use super::{DateOrDateTime, PropertyValue, RollupPropertyValue, RollupValue}; use chrono::NaiveDate; @@ -25,6 +27,16 @@ fn parse_null_select_property() { fn parse_select_property() { let _property: PropertyValue = serde_json::from_str(include_str!("tests/select_property.json")).unwrap(); + assert!(matches!(_property, + PropertyValue::Select { select: Some(..), .. })); +} + +#[test] +fn parse_select_empty_property() { + let _property: PropertyValue = + serde_json::from_str(include_str!("tests/select_empty_property.json")).unwrap(); + assert!(matches!(_property, + PropertyValue::Select { select: None, .. })); } #[test] @@ -33,6 +45,25 @@ fn parse_text_property_with_link() { serde_json::from_str(include_str!("tests/text_with_link.json")).unwrap(); } +#[test] +fn parse_relation_property() { + let property: PropertyValue = + serde_json::from_str(include_str!("tests/relation_property.json")).unwrap(); + + assert!(matches!( + property, + PropertyValue::Relation { .. } + )); + + if let PropertyValue::Relation { + relation, + .. + } = &property + { + assert!(matches!(relation[0], RelationValue{ .. })) + } +} + #[test] fn parse_rollup_property() { let property: PropertyValue = @@ -41,16 +72,31 @@ fn parse_rollup_property() { assert!(matches!( property, PropertyValue::Rollup { - rollup: Some(RollupValue::Array { .. }), + rollup: RollupValue::Array { .. }, .. } )); if let PropertyValue::Rollup { - rollup: Some(RollupValue::Array { array }), + rollup: RollupValue::Array { array , function: RollupFunction::ShowOriginal }, .. } = property { assert!(matches!(array[0], RollupPropertyValue::Text { .. })) } } + + +#[test] +fn parse_rollup_number_property() { + let property: PropertyValue = + serde_json::from_str(include_str!("tests/rollup_number_property.json")).unwrap(); + + assert!(matches!( + property, + PropertyValue::Rollup { + rollup: RollupValue::Number { .. }, + .. + } + )); +} diff --git a/src/models/properties/tests/relation_property.json b/src/models/properties/tests/relation_property.json new file mode 100644 index 0000000..c240d57 --- /dev/null +++ b/src/models/properties/tests/relation_property.json @@ -0,0 +1,10 @@ +{ + "id": "dummy_id", + "type": "relation", + "relation": [ + { + "id": "9f4b3f27-f6f1-4d83-bce5-06d131e354d3" + } + ], + "has_more": false +} \ No newline at end of file diff --git a/src/models/properties/tests/rollup_number_property.json b/src/models/properties/tests/rollup_number_property.json new file mode 100644 index 0000000..afa395d --- /dev/null +++ b/src/models/properties/tests/rollup_number_property.json @@ -0,0 +1,9 @@ +{ + "id": "WD%3Cs", + "type": "rollup", + "rollup": { + "type": "number", + "number": 42, + "function": "sum" + } + } \ No newline at end of file diff --git a/src/models/properties/tests/select_empty_property.json b/src/models/properties/tests/select_empty_property.json new file mode 100644 index 0000000..178a4ea --- /dev/null +++ b/src/models/properties/tests/select_empty_property.json @@ -0,0 +1,5 @@ +{ + "id": "D~vu", + "type": "select", + "select": null +} \ No newline at end of file diff --git a/src/models/search.rs b/src/models/search.rs index c9e7e91..3c796dc 100644 --- a/src/models/search.rs +++ b/src/models/search.rs @@ -259,6 +259,8 @@ pub enum PropertyCondition { Formula(FormulaCondition), } +pub type TimestampFilterProperty = DatabaseSortTimestamp; + #[derive(Serialize, Debug, Eq, PartialEq, Clone)] #[serde(untagged)] pub enum FilterCondition { @@ -267,12 +269,34 @@ pub enum FilterCondition { #[serde(flatten)] condition: PropertyCondition, }, + Timestamp { + #[serde(flatten)] + condition: PropertyTimestampCondition, + }, /// Returns pages when **all** of the filters inside the provided vector match. And { and: Vec }, /// Returns pages when **any** of the filters inside the provided vector match. Or { or: Vec }, } +impl FilterCondition { + pub fn created_time(date_condition: DateCondition) -> FilterCondition { + FilterCondition::Timestamp { + condition: PropertyTimestampCondition::CreatedTime { + created_time: date_condition, + }, + } + } +} + +#[derive(Serialize, Debug, Eq, PartialEq, Clone)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "timestamp")] +pub enum PropertyTimestampCondition { + CreatedTime { created_time: DateCondition }, + LastEditedTime { last_edited_time: DateCondition }, +} + #[derive(Serialize, Debug, Eq, PartialEq, Hash, Copy, Clone)] #[serde(rename_all = "snake_case")] pub enum DatabaseSortTimestamp { @@ -281,16 +305,40 @@ pub enum DatabaseSortTimestamp { } #[derive(Serialize, Debug, Eq, PartialEq, Clone)] -pub struct DatabaseSort { - // Todo: Should property and timestamp be mutually exclusive? (i.e a flattened enum?) - // the documentation is not clear: - // https://developers.notion.com/reference/post-database-query#post-database-query-sort - #[serde(skip_serializing_if = "Option::is_none")] - pub property: Option, - /// The name of the timestamp to sort against. - #[serde(skip_serializing_if = "Option::is_none")] - pub timestamp: Option, - pub direction: SortDirection, +#[serde(untagged)] +pub enum DatabaseSort { + Property { + property: String, + direction: SortDirection, + }, + Timestamp { + timestamp: DatabaseSortTimestamp, + direction: SortDirection, + }, +} + +impl DatabaseSort { + pub fn by_prop( + property: String, + direction: SortDirection, + ) -> DatabaseSort { + DatabaseSort::Property { + property, + direction, + } + } + pub fn by_created_time(direction: SortDirection) -> DatabaseSort { + DatabaseSort::Timestamp { + timestamp: DatabaseSortTimestamp::CreatedTime, + direction, + } + } + pub fn by_last_edited_time(direction: SortDirection) -> DatabaseSort { + DatabaseSort::Timestamp { + timestamp: DatabaseSortTimestamp::LastEditedTime, + direction, + } + } } #[derive(Serialize, Debug, Eq, PartialEq, Default, Clone)] @@ -387,7 +435,7 @@ mod tests { #[test] fn text_property_equals() -> Result<(), Box> { - let json = serde_json::to_value(&FilterCondition::Property { + let json = serde_json::to_value(FilterCondition::Property { property: "Name".to_string(), condition: RichText(TextCondition::Equals("Test".to_string())), })?; @@ -401,7 +449,7 @@ mod tests { #[test] fn text_property_contains() -> Result<(), Box> { - let json = serde_json::to_value(&FilterCondition::Property { + let json = serde_json::to_value(FilterCondition::Property { property: "Name".to_string(), condition: RichText(TextCondition::Contains("Test".to_string())), })?; @@ -415,7 +463,7 @@ mod tests { #[test] fn text_property_is_empty() -> Result<(), Box> { - let json = serde_json::to_value(&FilterCondition::Property { + let json = serde_json::to_value(FilterCondition::Property { property: "Name".to_string(), condition: RichText(TextCondition::IsEmpty), })?; @@ -429,7 +477,7 @@ mod tests { #[test] fn text_property_is_not_empty() -> Result<(), Box> { - let json = serde_json::to_value(&FilterCondition::Property { + let json = serde_json::to_value(FilterCondition::Property { property: "Name".to_string(), condition: RichText(TextCondition::IsNotEmpty), })?; @@ -443,7 +491,7 @@ mod tests { #[test] fn compound_query_and() -> Result<(), Box> { - let json = serde_json::to_value(&FilterCondition::And { + let json = serde_json::to_value(FilterCondition::And { and: vec![ FilterCondition::Property { property: "Seen".to_string(), @@ -470,7 +518,7 @@ mod tests { #[test] fn compound_query_or() -> Result<(), Box> { - let json = serde_json::to_value(&FilterCondition::Or { + let json = serde_json::to_value(FilterCondition::Or { or: vec![ FilterCondition::Property { property: "Description".to_string(), diff --git a/src/models/text.rs b/src/models/text.rs index 841e7d7..4106290 100644 --- a/src/models/text.rs +++ b/src/models/text.rs @@ -1,8 +1,10 @@ use crate::models::properties::DateValue; use crate::models::users::User; -use crate::{Database, Page}; +use crate::Database; use serde::{Deserialize, Serialize}; +use super::properties::RelationValue; + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "snake_case")] pub enum TextColor { @@ -70,8 +72,11 @@ pub enum MentionObject { user: User, }, // TODO: need to add tests + // Page mention does not contain full Page structure, but just id + // https://developers.notion.com/reference/rich-text#page-mention-type-object + // 2023-02-07 Page { - page: Page, + page: RelationValue, }, // TODO: need to add tests Database { diff --git a/src/models/users.rs b/src/models/users.rs index b7702d0..18d66a7 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -13,9 +13,10 @@ pub struct Person { pub email: String, } +/// TODO not finished implementation of bot option +/// #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Bot { - pub email: String, } #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]