From 8c8eb42c5a410b0493389cc9346f20d25dd21bed Mon Sep 17 00:00:00 2001 From: Leonora Tindall Date: Fri, 4 Nov 2022 15:00:26 -0500 Subject: [PATCH 1/5] Add post deserialization from Cohost API --- samples/example.project.posts.json | 377 +++++++++++++++++++++++++++++ src/attachment.rs | 10 +- src/post.rs | 369 ++++++++++++++++++++++++---- 3 files changed, 709 insertions(+), 47 deletions(-) create mode 100644 samples/example.project.posts.json diff --git a/samples/example.project.posts.json b/samples/example.project.posts.json new file mode 100644 index 0000000..3c3364c --- /dev/null +++ b/samples/example.project.posts.json @@ -0,0 +1,377 @@ +{ + "nItems": 3, + "nPages": 1, + "items": [ + { + "postId": 185922, + "headline": "Commentary repost of a post from an adult account from a non adult account", + "publishedAt": "2022-11-04T03:29:25.010Z", + "filename": "185922-commentary-repost-of", + "transparentShareOfPostId": null, + "state": 1, + "numComments": 2, + "numSharedComments": 1, + "cws": [], + "tags": [], + "blocks": [ + { + "type": "markdown", + "markdown": { + "content": "and it is marked as adult content" + } + } + ], + "plainTextBody": "and it is marked as adult content", + "postingProject": { + "handle": "example", + "displayName": "Example Page", + "dek": "for use in documentation", + "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", + "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49507, + "privacy": "public", + "pronouns": "", + "url": "https://www.rfc-editor.org/rfc/rfc2606.html", + "flags": [], + "avatarShape": "circle" + }, + "shareTree": [ + { + "postId": 185857, + "headline": "This is an adult post.", + "publishedAt": "2022-11-04T03:20:56.978Z", + "filename": "185857-this-is-an-adult-pos", + "transparentShareOfPostId": null, + "state": 1, + "numComments": 1, + "numSharedComments": 0, + "cws": [ + "a content warning", + "another content warning", + "," + ], + "tags": [ + "example tag", + "another example tag", + "woo spooky adult post" + ], + "blocks": [ + { + "type": "markdown", + "markdown": { + "content": "It's adult because it's on an adult account. It's also got Content Warnings." + } + } + ], + "plainTextBody": "It's adult because it's on an adult account. It's also got Content Warnings.", + "postingProject": { + "handle": "example-adult", + "displayName": "", + "dek": "", + "description": "", + "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49508, + "privacy": "public", + "pronouns": null, + "url": null, + "flags": [], + "avatarShape": "circle" + }, + "shareTree": [], + "relatedProjects": [], + "singlePostPageUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos", + "effectiveAdultContent": false, + "isEditor": false, + "contributorBlockIncomingOrOutgoing": false, + "hasAnyContributorMuted": false, + "postEditUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos/edit", + "isLiked": false, + "canShare": false, + "canPublish": true, + "hasCohostPlus": true, + "pinned": false, + "commentsLocked": false + } + ], + "relatedProjects": [ + { + "handle": "example", + "displayName": "Example Page", + "dek": "for use in documentation", + "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", + "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49507, + "privacy": "public", + "pronouns": "", + "url": "https://www.rfc-editor.org/rfc/rfc2606.html", + "flags": [], + "avatarShape": "circle" + }, + { + "handle": "example-adult", + "displayName": "", + "dek": "", + "description": "", + "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49508, + "privacy": "public", + "pronouns": null, + "url": null, + "flags": [], + "avatarShape": "circle" + } + ], + "singlePostPageUrl": "https://cohost.org/example/post/185922-commentary-repost-of", + "effectiveAdultContent": true, + "isEditor": false, + "contributorBlockIncomingOrOutgoing": false, + "hasAnyContributorMuted": false, + "postEditUrl": "https://cohost.org/example/post/185922-commentary-repost-of/edit", + "isLiked": false, + "canShare": false, + "canPublish": true, + "hasCohostPlus": true, + "pinned": false, + "commentsLocked": false + }, + { + "postId": 185916, + "headline": "Commentary repost of a post from an adult account from a non adult account", + "publishedAt": "2022-11-04T03:28:49.206Z", + "filename": "185916-commentary-repost-of", + "transparentShareOfPostId": null, + "state": 1, + "numComments": 0, + "numSharedComments": 1, + "cws": [], + "tags": [], + "blocks": [ + { + "type": "markdown", + "markdown": { + "content": "and it's not marked as adult content" + } + } + ], + "plainTextBody": "and it's not marked as adult content", + "postingProject": { + "handle": "example", + "displayName": "Example Page", + "dek": "for use in documentation", + "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", + "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49507, + "privacy": "public", + "pronouns": "", + "url": "https://www.rfc-editor.org/rfc/rfc2606.html", + "flags": [], + "avatarShape": "circle" + }, + "shareTree": [ + { + "postId": 185857, + "headline": "This is an adult post.", + "publishedAt": "2022-11-04T03:20:56.978Z", + "filename": "185857-this-is-an-adult-pos", + "transparentShareOfPostId": null, + "state": 1, + "numComments": 1, + "numSharedComments": 0, + "cws": [ + "a content warning", + "another content warning", + "," + ], + "tags": [ + "example tag", + "another example tag", + "woo spooky adult post" + ], + "blocks": [ + { + "type": "markdown", + "markdown": { + "content": "It's adult because it's on an adult account. It's also got Content Warnings." + } + } + ], + "plainTextBody": "It's adult because it's on an adult account. It's also got Content Warnings.", + "postingProject": { + "handle": "example-adult", + "displayName": "", + "dek": "", + "description": "", + "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49508, + "privacy": "public", + "pronouns": null, + "url": null, + "flags": [], + "avatarShape": "circle" + }, + "shareTree": [], + "relatedProjects": [], + "singlePostPageUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos", + "effectiveAdultContent": false, + "isEditor": false, + "contributorBlockIncomingOrOutgoing": false, + "hasAnyContributorMuted": false, + "postEditUrl": "https://cohost.org/example-adult/post/185857-this-is-an-adult-pos/edit", + "isLiked": false, + "canShare": false, + "canPublish": true, + "hasCohostPlus": true, + "pinned": false, + "commentsLocked": false + } + ], + "relatedProjects": [ + { + "handle": "example", + "displayName": "Example Page", + "dek": "for use in documentation", + "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", + "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49507, + "privacy": "public", + "pronouns": "", + "url": "https://www.rfc-editor.org/rfc/rfc2606.html", + "flags": [], + "avatarShape": "circle" + }, + { + "handle": "example-adult", + "displayName": "", + "dek": "", + "description": "", + "avatarURL": "https://cohost.org/rc/default-avatar/49508.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49508.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49508, + "privacy": "public", + "pronouns": null, + "url": null, + "flags": [], + "avatarShape": "circle" + } + ], + "singlePostPageUrl": "https://cohost.org/example/post/185916-commentary-repost-of", + "effectiveAdultContent": false, + "isEditor": false, + "contributorBlockIncomingOrOutgoing": false, + "hasAnyContributorMuted": false, + "postEditUrl": "https://cohost.org/example/post/185916-commentary-repost-of/edit", + "isLiked": false, + "canShare": false, + "canPublish": true, + "hasCohostPlus": true, + "pinned": false, + "commentsLocked": false + }, + { + "postId": 185838, + "headline": "This is a test post.", + "publishedAt": "2022-11-04T03:17:49.605Z", + "filename": "185838-this-is-a-test-post", + "transparentShareOfPostId": null, + "state": 1, + "numComments": 0, + "numSharedComments": 0, + "cws": [], + "tags": [ + "test tag one", + "test tag two", + "a very long tag with some symbols &^^$^(*(&^*& in it" + ], + "blocks": [ + { + "type": "attachment", + "attachment": { + "fileURL": "https://staging.cohostcdn.org/attachment/2b1e7477-ba13-4f7e-9547-f0e2668b92b6/cooltext422710535227689.png", + "previewURL": "https://staging.cohostcdn.org/attachment/2b1e7477-ba13-4f7e-9547-f0e2668b92b6/cooltext422710535227689.png", + "attachmentId": "2b1e7477-ba13-4f7e-9547-f0e2668b92b6", + "altText": "Stylized text with stars reading: \"this block is an image attachment\"" + } + }, + { + "type": "markdown", + "markdown": { + "content": "Here's the body of the test post! This should form the first block." + } + }, + { + "type": "markdown", + "markdown": { + "content": "This is a second paragraph of the test post, which should form the second block and includes _meaningful_*markdown* **formatting**." + } + }, + { + "type": "markdown", + "markdown": { + "content": "This third paragraph, forming the third block, contains Raw HTML ." + } + } + ], + "plainTextBody": "Here's the body of the test post! This should form the first block.\n\nThis is a second paragraph of the test post, which should form the second block and includes _meaningful_*markdown* **formatting**.\n\nThis third paragraph, forming the third block, contains Raw HTML .", + "postingProject": { + "handle": "example", + "displayName": "Example Page", + "dek": "for use in documentation", + "description": "this account was created by @noracodes for use in documentation and testing of the Cohost API. i am happy to hand this account over to @staff or give others access to post here if they want to add cases to the post history or whatever.\r\n\r\nsee also @example-adult and @example-private", + "avatarURL": "https://cohost.org/rc/default-avatar/49507.png", + "avatarPreviewURL": "https://cohost.org/rc/default-avatar/49507.png", + "headerURL": null, + "headerPreviewURL": null, + "projectId": 49507, + "privacy": "public", + "pronouns": "", + "url": "https://www.rfc-editor.org/rfc/rfc2606.html", + "flags": [], + "avatarShape": "circle" + }, + "shareTree": [], + "relatedProjects": [], + "singlePostPageUrl": "https://cohost.org/example/post/185838-this-is-a-test-post", + "effectiveAdultContent": false, + "isEditor": false, + "contributorBlockIncomingOrOutgoing": false, + "hasAnyContributorMuted": false, + "postEditUrl": "https://cohost.org/example/post/185838-this-is-a-test-post/edit", + "isLiked": false, + "canShare": false, + "canPublish": true, + "hasCohostPlus": true, + "pinned": false, + "commentsLocked": false + } + ], + "_links": [ + { + "href": "/api/v1/project/example", + "rel": "project", + "type": "GET" + } + ] +} diff --git a/src/attachment.rs b/src/attachment.rs index 1768197..e120efd 100644 --- a/src/attachment.rs +++ b/src/attachment.rs @@ -37,14 +37,14 @@ pub struct AttachmentId(pub Uuid); /// not, the attachment becomes ["failed"][`Attachment::is_failed`]. #[derive(Debug)] pub struct Attachment { - kind: Inner, + pub(crate) kind: Inner, /// Alt text associated with this attachment. pub alt_text: String, } #[derive(Debug)] -enum Inner { +pub(crate) enum Inner { New { stream: Body, filename: String, @@ -57,9 +57,9 @@ enum Inner { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct Finished { - attachment_id: AttachmentId, - url: String, +pub(crate) struct Finished { + pub(crate) attachment_id: AttachmentId, + pub(crate) url: String, } impl Attachment { diff --git a/src/post.rs b/src/post.rs index 7c29c12..a79d7f5 100644 --- a/src/post.rs +++ b/src/post.rs @@ -1,8 +1,8 @@ -use crate::{Attachment, AttachmentId, Error, Session}; +use crate::{Attachment, Error, Session}; use derive_more::{Display, From, FromStr, Into}; use reqwest::Method; use serde::{Deserialize, Serialize}; -use std::fmt::{self, Debug}; +use std::fmt::Debug; /// A post ID. #[allow(clippy::module_name_repetitions)] @@ -26,6 +26,28 @@ use std::fmt::{self, Debug}; #[serde(transparent)] pub struct PostId(pub u64); +/// A project ID. +#[allow(clippy::module_name_repetitions)] +#[derive( + Clone, + Copy, + Debug, + Default, + Deserialize, + Display, + Eq, + From, + FromStr, + Hash, + Into, + Ord, + PartialEq, + PartialOrd, + Serialize, +)] +#[serde(transparent)] +pub struct ProjectId(pub u64); + /// Describes a post's contents. /// /// When you send a post with [`Session::create_post`] or [`Session::edit_post`], the `Post` must @@ -49,6 +71,52 @@ pub struct Post { /// Marks the post as a draft, preventing it from being seen by other users without the draft /// link. pub draft: bool, + /// Metadata returned by Cohost from posts retrieved from the API. + pub metadata: Option, +} + +/// Metadata returned by the Cohost API for posts retrieved from post pages. +#[derive(Debug)] +pub struct PostMetadata { + /// All identifiers regarding where this post can be found on Cohost. + pub locations: PostLocations, + /// True if the client has permission to share this post. + pub can_share: bool, + /// True if adding new comments is disabled on this post. + pub comments_locked: bool, + /// True if any contributor to the post is muted by the current account. + pub has_any_contributor_muted: bool, + /// True if cohost plus features were available to the poster. + pub has_cohost_plus: bool, + /// True if the current account has liked this post. + pub liked: bool, + /// The number of comments on this post. + pub num_comments: u64, + /// The number of comments on other posts in this post's branch of the + /// share tree. + pub num_shared_comments: u64, + /// True if this post is pinned to its author's profile. + pub pinned: bool, + /// The ID of the project that posted this post. + pub posting_project_id: ProjectId, + /// A list of the IDs of all the projects involved in this post. + pub related_projects: Vec, + /// A list of all the posts in this post's branch of the share tree. + pub share_tree: Vec, +} + +/// All identifying information about where to find a post, from its ID to how to edit it. +#[derive(Debug, Hash, Clone, PartialEq, Eq)] +pub struct PostLocations { + /// The unique numerical ID of the post. + pub id: PostId, + /// The filename of the post, excluding the protocol, domain, and project. + /// Acts as a unique ID with a semi-readable slug. + pub filename: String, + /// The complete URL at which this post can be viewed on Cohost. + pub url: String, + /// The location at which this post can be edited. + pub edit_url: String, } impl Post { @@ -75,7 +143,7 @@ impl Post { let need_upload = self.attachments.iter().any(Attachment::is_new); - let PostResponse { post_id } = session + let de::PostResponse { post_id } = session .client .request(method, path) .json(&self.as_api(need_upload, shared_post)) @@ -107,12 +175,12 @@ impl Post { } #[tracing::instrument] - fn as_api(&self, force_draft: bool, shared_post: Option) -> ApiPost<'_> { + fn as_api(&self, force_draft: bool, shared_post: Option) -> ser::Post<'_> { let mut blocks = self .attachments .iter() - .map(|attachment| ApiBlock::Attachment { - attachment: ApiAttachment { + .map(|attachment| ser::Block::Attachment { + attachment: ser::Attachment { alt_text: &attachment.alt_text, attachment_id: attachment.id().unwrap_or_default(), }, @@ -120,13 +188,13 @@ impl Post { .collect::>(); if !self.markdown.is_empty() { for block in self.markdown.split("\n\n") { - blocks.push(ApiBlock::Markdown { - markdown: ApiMarkdown { content: block }, + blocks.push(ser::Block::Markdown { + markdown: ser::Markdown { content: block }, }); } } - let post = ApiPost { + let post = ser::Post { adult_content: self.adult_content, blocks, cws: &self.content_warnings, @@ -140,48 +208,265 @@ impl Post { } } -#[allow(clippy::module_name_repetitions)] -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ApiPost<'a> { - adult_content: bool, - blocks: Vec>, - cws: &'a [String], - headline: &'a str, - post_state: u64, - #[serde(skip_serializing_if = "Option::is_none")] - share_of_post_id: Option, - tags: &'a [String], +impl From for Post { + fn from(api: de::Post) -> Self { + let locations = PostLocations { + id: api.post_id, + filename: api.filename, + url: api.single_post_page_url, + edit_url: api.post_edit_url, + }; + let metadata = PostMetadata { + locations, + can_share: api.can_share, + comments_locked: api.comments_locked, + has_any_contributor_muted: api.has_any_contributor_muted, + has_cohost_plus: api.has_cohost_plus, + liked: api.is_liked, + num_comments: api.num_comments, + num_shared_comments: api.num_shared_comments, + pinned: api.pinned, + posting_project_id: api.posting_project.project_id, + related_projects: { + let mut related_projects: Vec = api + .related_projects + .into_iter() + .map(|project| project.project_id) + .collect(); + if related_projects.is_empty() { + related_projects.push(api.posting_project.project_id) + }; + related_projects + }, + share_tree: api + .share_tree + .into_iter() + .map(|api_post| api_post.into()) + .collect(), + }; + + let attachments: Vec = api + .blocks + .into_iter() + .filter_map(|block| match block { + de::Block::Attachment { attachment } => { + Some(crate::attachment::Attachment::from(attachment)) + } + _ => None, + }) + .collect(); + + Self { + metadata: Some(metadata), + adult_content: api.effective_adult_content, + headline: api.headline, + markdown: api.plain_text_body, + tags: api.tags, + content_warnings: api.cws, + draft: if api.state == 0 { true } else { false }, + attachments, + } + } } -impl Debug for ApiPost<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", serde_json::to_value(self).map_err(|_| fmt::Error)?) +impl From for Attachment { + fn from(api: de::Attachment) -> Self { + Self { + kind: crate::attachment::Inner::Uploaded(crate::attachment::Finished { + attachment_id: api.attachment_id, + url: api.file_url, + }), + alt_text: api.alt_text, + } } } -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum ApiBlock<'a> { - Attachment { attachment: ApiAttachment<'a> }, - Markdown { markdown: ApiMarkdown<'a> }, +mod ser { + use super::PostId; + use crate::attachment::AttachmentId; + use serde::Serialize; + use std::fmt::{self, Debug}; + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Post<'a> { + pub adult_content: bool, + pub blocks: Vec>, + pub cws: &'a [String], + pub headline: &'a str, + pub post_state: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub share_of_post_id: Option, + pub tags: &'a [String], + } + + impl Debug for Post<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", serde_json::to_value(self).map_err(|_| fmt::Error)?) + } + } + + #[derive(Serialize)] + #[serde(tag = "type", rename_all = "camelCase")] + pub enum Block<'a> { + Attachment { attachment: Attachment<'a> }, + Markdown { markdown: Markdown<'a> }, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Attachment<'a> { + pub alt_text: &'a str, + pub attachment_id: AttachmentId, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Markdown<'a> { + pub content: &'a str, + } } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ApiAttachment<'a> { - alt_text: &'a str, - attachment_id: AttachmentId, +mod de { + use super::{PostId, ProjectId}; + use crate::AttachmentId; + use serde::Deserialize; + + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PostPage { + pub n_items: u64, + pub n_pages: u64, + pub items: Vec, + } + + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Post { + pub blocks: Vec, + pub can_publish: bool, + pub can_share: bool, + pub comments_locked: bool, + pub contributor_block_incoming_or_outgoing: bool, + pub cws: Vec, + pub effective_adult_content: bool, + pub filename: String, + pub has_any_contributor_muted: bool, + pub has_cohost_plus: bool, + pub headline: String, + pub is_editor: bool, + pub is_liked: bool, + pub num_comments: u64, + pub num_shared_comments: u64, + pub pinned: bool, + pub plain_text_body: String, + pub post_edit_url: String, + pub post_id: PostId, + pub posting_project: PostingProject, + pub related_projects: Vec, + pub share_tree: Vec, + pub single_post_page_url: String, + pub state: u64, + pub tags: Vec, + pub transparent_share_of_post_id: Option, + } + + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PostingProject { + pub handle: String, + pub display_name: Option, + pub dek: Option, + pub description: Option, + #[serde(rename = "avatarURL")] + pub avatar_url: String, + #[serde(rename = "avatarPreviewURL")] + pub avatar_preview_url: String, + pub project_id: ProjectId, + pub privacy: String, + pub pronouns: Option, + pub url: Option, + pub avatar_shape: String, + } + + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] + #[serde(tag = "type", rename_all = "camelCase")] + pub enum Block { + Attachment { attachment: Attachment }, + Markdown { markdown: Markdown }, + } + + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Attachment { + pub alt_text: String, + pub attachment_id: AttachmentId, + #[serde(rename = "fileURL")] + pub file_url: String, + #[serde(rename = "previewURL")] + pub preview_url: String, + } + + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Markdown { + pub content: String, + } + + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PostResponse { + pub post_id: PostId, + } } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ApiMarkdown<'a> { - content: &'a str, +#[test] +fn test_parse_project_post_page() -> Result<(), Box> { + let post_page: de::PostPage = + serde_json::from_str(include_str!("../samples/example.project.posts.json"))?; + assert_eq!(post_page.n_items, 3); + assert_eq!(post_page.n_items as usize, post_page.items.len()); + let post = post_page + .items + .iter() + .find(|post| post.post_id.0 == 185838) + .expect("Couldn't find post by ID 185838 as expected; did you change the sample?"); + assert_eq!(post.headline, "This is a test post."); + assert_eq!(post.filename, "185838-this-is-a-test-post"); + assert_eq!(post.state, 1); + assert!(post.transparent_share_of_post_id.is_none()); + assert_eq!(post.num_comments, 0); + assert_eq!(post.num_shared_comments, 0); + assert_eq!(post.tags.len(), 3); + assert_eq!(post.cws.len(), 0); + assert_eq!(post.related_projects.len(), 0); + + let post = post_page + .items + .iter() + .find(|post| post.post_id.0 == 185916) + .expect("Couldn't find post by ID 185916 as expected; did you change the sample?"); + + assert_eq!(post.related_projects.len(), 2); + assert_eq!(post.share_tree.len(), 1); + Ok(()) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PostResponse { - post_id: PostId, +#[test] +fn test_convert_post() -> Result<(), Box> { + let post_page: de::PostPage = + serde_json::from_str(include_str!("../samples/example.project.posts.json"))?; + let post = post_page + .items + .iter() + .find(|post| post.post_id.0 == 185838) + .expect("Couldn't find post by ID 185838 as expected; did you change the sample?"); + let converted_post = Post::from(post.clone()); + let converted_post_metadata = converted_post + .metadata + .expect("No metadata for converted post!"); + assert_eq!(post.post_id, converted_post_metadata.locations.id); + assert!(!converted_post.attachments.is_empty()); + + Ok(()) } From eb419b91526c6a42d9553d3fb17835ade1596a53 Mon Sep 17 00:00:00 2001 From: Leonora Tindall Date: Fri, 4 Nov 2022 15:15:47 -0500 Subject: [PATCH 2/5] Add ability for Client to get posts --- README.md | 2 +- examples/get.rs | 21 +++++++++++++++++++++ src/client.rs | 18 +++++++++++++++++- src/post.rs | 13 ++++++++++--- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 examples/get.rs diff --git a/README.md b/README.md index 5d10216..b94d73f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # eggbug eggbug-rs is a bot library for [cohost.org](https://cohost.org/rc/welcome), providing an -interface to create, edit, and delete posts. +interface to create, read, edit, and delete posts. ```rust use eggbug::{Post, Session}; diff --git a/examples/get.rs b/examples/get.rs new file mode 100644 index 0000000..d8127bf --- /dev/null +++ b/examples/get.rs @@ -0,0 +1,21 @@ +#![deny(elided_lifetimes_in_paths)] +#![warn(clippy::pedantic)] + +use anyhow::Result; +use eggbug::{Attachment, Client, Post}; +use std::path::Path; +use tracing_subscriber::{fmt, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<()> { + dotenv::dotenv().ok(); + fmt().with_env_filter(EnvFilter::from_default_env()).init(); + + let project = std::env::var("COHOST_PROJECT")?; + + let client = Client::new(); + let posts = client.get_posts_page(&project, 0).await?; + println!("{:#?}", posts); + + Ok(()) +} diff --git a/src/client.rs b/src/client.rs index ce2ab8a..9919e38 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,4 @@ -use crate::{Error, Session}; +use crate::{Error, Post, Session}; use reqwest::{Method, RequestBuilder}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; @@ -93,6 +93,22 @@ impl Client { Ok(Session { client: self }) } + /// Get a page of posts from the given project. + /// + /// Pages start at 0. Once you get an empty page, there are no more pages after that to get; they will all be empty. + #[tracing::instrument(skip(self))] + pub async fn get_posts_page(&self, project: &str, page: u64) -> Result, Error> { + let posts_page: crate::post::PostPage = self + .get(&format!("project/{}/posts", project)) + .query(&[("page", page.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(posts_page.into()) + } + #[inline] pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder { tracing::info!(%method, path, "Client::request"); diff --git a/src/post.rs b/src/post.rs index a79d7f5..3268a44 100644 --- a/src/post.rs +++ b/src/post.rs @@ -1,4 +1,5 @@ use crate::{Attachment, Error, Session}; +pub(crate) use de::PostPage; use derive_more::{Display, From, FromStr, Into}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -281,6 +282,12 @@ impl From for Attachment { } } +impl From for Vec { + fn from(page: PostPage) -> Self { + page.items.into_iter().map(|post| post.into()).collect() + } +} + mod ser { use super::PostId; use crate::attachment::AttachmentId; @@ -335,9 +342,9 @@ mod de { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PostPage { - pub n_items: u64, - pub n_pages: u64, - pub items: Vec, + pub(super) n_items: u64, + pub(super) n_pages: u64, + pub(super) items: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] From b6425be886d93290b1c1aaccd8964abbd4c9d1fd Mon Sep 17 00:00:00 2001 From: Leonora Tindall Date: Fri, 4 Nov 2022 15:20:47 -0500 Subject: [PATCH 3/5] Use handles instead of project IDs --- src/post.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/post.rs b/src/post.rs index 3268a44..a63ec7d 100644 --- a/src/post.rs +++ b/src/post.rs @@ -98,10 +98,10 @@ pub struct PostMetadata { pub num_shared_comments: u64, /// True if this post is pinned to its author's profile. pub pinned: bool, - /// The ID of the project that posted this post. - pub posting_project_id: ProjectId, - /// A list of the IDs of all the projects involved in this post. - pub related_projects: Vec, + /// The handle of the project that posted this post. + pub posting_project_id: String, + /// A list of the handles of all the projects involved in this post. + pub related_projects: Vec, /// A list of all the posts in this post's branch of the share tree. pub share_tree: Vec, } @@ -227,18 +227,18 @@ impl From for Post { num_comments: api.num_comments, num_shared_comments: api.num_shared_comments, pinned: api.pinned, - posting_project_id: api.posting_project.project_id, related_projects: { - let mut related_projects: Vec = api + let mut related_projects: Vec = api .related_projects .into_iter() - .map(|project| project.project_id) + .map(|project| project.handle) .collect(); if related_projects.is_empty() { - related_projects.push(api.posting_project.project_id) + related_projects.push(api.posting_project.handle.clone()) }; related_projects }, + posting_project_id: api.posting_project.handle, share_tree: api .share_tree .into_iter() From 3d2da737605391f2e83bc6de7419be7ec1e6f142 Mon Sep 17 00:00:00 2001 From: Leonora Tindall Date: Fri, 4 Nov 2022 15:24:37 -0500 Subject: [PATCH 4/5] Clean up some clippy lints --- examples/get.rs | 3 +-- src/post.rs | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/get.rs b/examples/get.rs index d8127bf..9bd77f2 100644 --- a/examples/get.rs +++ b/examples/get.rs @@ -2,8 +2,7 @@ #![warn(clippy::pedantic)] use anyhow::Result; -use eggbug::{Attachment, Client, Post}; -use std::path::Path; +use eggbug::Client; use tracing_subscriber::{fmt, EnvFilter}; #[tokio::main] diff --git a/src/post.rs b/src/post.rs index a63ec7d..e68e7a1 100644 --- a/src/post.rs +++ b/src/post.rs @@ -78,6 +78,7 @@ pub struct Post { /// Metadata returned by the Cohost API for posts retrieved from post pages. #[derive(Debug)] +#[allow(clippy::struct_excessive_bools, clippy::module_name_repetitions)] pub struct PostMetadata { /// All identifiers regarding where this post can be found on Cohost. pub locations: PostLocations, @@ -108,6 +109,7 @@ pub struct PostMetadata { /// All identifying information about where to find a post, from its ID to how to edit it. #[derive(Debug, Hash, Clone, PartialEq, Eq)] +#[allow(clippy::module_name_repetitions)] pub struct PostLocations { /// The unique numerical ID of the post. pub id: PostId, @@ -234,7 +236,7 @@ impl From for Post { .map(|project| project.handle) .collect(); if related_projects.is_empty() { - related_projects.push(api.posting_project.handle.clone()) + related_projects.push(api.posting_project.handle.clone()); }; related_projects }, @@ -264,7 +266,7 @@ impl From for Post { markdown: api.plain_text_body, tags: api.tags, content_warnings: api.cws, - draft: if api.state == 0 { true } else { false }, + draft: api.state == 0, attachments, } } @@ -349,6 +351,7 @@ mod de { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] + #[allow(clippy::struct_excessive_bools)] pub struct Post { pub blocks: Vec, pub can_publish: bool, From 055920ab97260edc62e3fe682a73a979c7f0e713 Mon Sep 17 00:00:00 2001 From: Leonora Tindall Date: Sat, 5 Nov 2022 20:00:08 -0500 Subject: [PATCH 5/5] Add publication date support with Chrono --- Cargo.toml | 1 + src/post.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index e12f86c..3f4785f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license-file = "LICENSE.md" [dependencies] base64 = "0.13.0" bytes = "1.1.0" +chrono = { version = "0.4.22", default-features = false, features = ["std", "serde"] } derive_more = { version = "0.99.17", default-features = false, features = ["display", "from", "from_str", "into"] } futures = { version = "0.3.21", default-features = false, features = ["alloc"] } hmac = "0.12.1" diff --git a/src/post.rs b/src/post.rs index e68e7a1..8f93af6 100644 --- a/src/post.rs +++ b/src/post.rs @@ -101,6 +101,8 @@ pub struct PostMetadata { pub pinned: bool, /// The handle of the project that posted this post. pub posting_project_id: String, + /// The time at which the post was published. + pub publication_date: chrono::DateTime, /// A list of the handles of all the projects involved in this post. pub related_projects: Vec, /// A list of all the posts in this post's branch of the share tree. @@ -241,6 +243,7 @@ impl From for Post { related_projects }, posting_project_id: api.posting_project.handle, + publication_date: api.published_at, share_tree: api .share_tree .into_iter() @@ -373,6 +376,7 @@ mod de { pub post_edit_url: String, pub post_id: PostId, pub posting_project: PostingProject, + pub published_at: chrono::DateTime, pub related_projects: Vec, pub share_tree: Vec, pub single_post_page_url: String, @@ -477,6 +481,10 @@ fn test_convert_post() -> Result<(), Box> { .expect("No metadata for converted post!"); assert_eq!(post.post_id, converted_post_metadata.locations.id); assert!(!converted_post.attachments.is_empty()); + assert_eq!( + converted_post_metadata.publication_date.timestamp(), + 1667531869 + ); Ok(()) }