diff --git a/crates/synd_term/gql/query.gql b/crates/synd_term/gql/query.gql index 0830060d..e0a29849 100644 --- a/crates/synd_term/gql/query.gql +++ b/crates/synd_term/gql/query.gql @@ -82,3 +82,19 @@ fragment PageInfo on PageInfo { hasNextPage endCursor } + +query ExportSubscription($after: String, $first: Int!) { + output: subscription { + feeds(after: $after, first: $first) { + pageInfo { + hasNextPage + endCursor + } + nodes { + title + url + type + } + } + } +} diff --git a/crates/synd_term/src/cli/check.rs b/crates/synd_term/src/cli/check.rs index 8e5b8970..4ffd5448 100644 --- a/crates/synd_term/src/cli/check.rs +++ b/crates/synd_term/src/cli/check.rs @@ -16,18 +16,14 @@ pub enum CheckFormat { /// Check application conditions #[derive(Args, Debug)] pub struct CheckCommand { - /// synd_api endpoint - #[arg(long, default_value = config::api::ENDPOINT, env = config::env::ENDPOINT)] - pub endpoint: Url, - #[arg(value_enum, long, default_value_t = CheckFormat::Human)] pub format: CheckFormat, } impl CheckCommand { #[allow(clippy::unused_self)] - pub async fn run(self) -> i32 { - if let Err(err) = self.check().await { + pub async fn run(self, endpoint: Url) -> i32 { + if let Err(err) = self.check(endpoint).await { tracing::error!("{err:?}"); 1 } else { @@ -35,8 +31,8 @@ impl CheckCommand { } } - async fn check(self) -> anyhow::Result<()> { - let Self { endpoint, format } = self; + async fn check(self, endpoint: Url) -> anyhow::Result<()> { + let Self { format } = self; let client = Client::new(endpoint, Duration::from_secs(10))?; let api_health = client diff --git a/crates/synd_term/src/cli/export.rs b/crates/synd_term/src/cli/export.rs new file mode 100644 index 00000000..de0fa5e1 --- /dev/null +++ b/crates/synd_term/src/cli/export.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +use anyhow::anyhow; +use clap::Args; +use url::Url; + +use crate::{auth, client::Client}; + +/// Export subscribed feeds +#[derive(Args, Debug)] +pub struct ExportCommand {} + +impl ExportCommand { + #[allow(clippy::unused_self)] + pub async fn run(self, endpoint: Url) -> i32 { + if let Err(err) = self.export(endpoint).await { + tracing::error!("{err:?}"); + 1 + } else { + 0 + } + } + + async fn export(self, endpoint: Url) -> anyhow::Result<()> { + let mut client = Client::new(endpoint, Duration::from_secs(10))?; + + let credentials = auth::credential_from_cache() + .ok_or_else(|| anyhow!("You are not authenticated, try login in first"))?; + client.set_credential(credentials); + + let mut after = None; + let mut exported_feeds = Vec::new(); + + loop { + let response = client.export_subscription(after.take(), 1).await?; + exported_feeds.extend(response.feeds); + + if !response.page_info.has_next_page { + break; + } + after = response.page_info.end_cursor; + } + + let output = serde_json::json! {{ + "feeds": exported_feeds, + }}; + + serde_json::to_writer_pretty(std::io::stdout(), &output)?; + + Ok(()) + } +} diff --git a/crates/synd_term/src/cli/mod.rs b/crates/synd_term/src/cli/mod.rs index a5ce2300..7295d284 100644 --- a/crates/synd_term/src/cli/mod.rs +++ b/crates/synd_term/src/cli/mod.rs @@ -8,6 +8,7 @@ use crate::config; mod check; mod clear; +mod export; #[derive(Copy, Clone, PartialEq, Eq, Debug, clap::ValueEnum)] pub enum Palette { @@ -67,7 +68,7 @@ impl From for tailwind::Palette { #[command(version, propagate_version = true, name = "synd")] pub struct Args { /// synd_api endpoint - #[arg(long, default_value = config::api::ENDPOINT, env = config::env::ENDPOINT)] + #[arg(long, global = true, default_value = config::api::ENDPOINT, env = config::env::ENDPOINT)] pub endpoint: Url, /// Log file path #[arg(long, default_value = config::log_path().into_os_string(), env = config::env::LOG_PATH)] @@ -86,6 +87,7 @@ pub struct Args { pub enum Command { Clear(clear::ClearCommand), Check(check::CheckCommand), + Export(export::ExportCommand), } pub fn parse() -> Args { diff --git a/crates/synd_term/src/client/mod.rs b/crates/synd_term/src/client/mod.rs index 75ea788f..f14f6dbd 100644 --- a/crates/synd_term/src/client/mod.rs +++ b/crates/synd_term/src/client/mod.rs @@ -9,7 +9,7 @@ use thiserror::Error; use tracing::{error, Span}; use url::Url; -use crate::{auth::Credential, config, types}; +use crate::{auth::Credential, client::payload::ExportSubscriptionPayload, config, types}; use self::query::subscription::SubscriptionOutput; @@ -142,6 +142,19 @@ impl Client { Ok(response.output.into()) } + #[tracing::instrument(skip(self))] + pub async fn export_subscription( + &self, + after: Option, + first: i64, + ) -> anyhow::Result { + let var = query::export_subscription::Variables { after, first }; + let request = query::ExportSubscription::build_query(var); + let response: query::export_subscription::ResponseData = self.request(&request).await?; + + Ok(response.output.into()) + } + #[tracing::instrument(skip_all, err(Display))] async fn request(&self, body: &Body) -> anyhow::Result where diff --git a/crates/synd_term/src/client/payload.rs b/crates/synd_term/src/client/payload.rs index 94ed581c..54f65e31 100644 --- a/crates/synd_term/src/client/payload.rs +++ b/crates/synd_term/src/client/payload.rs @@ -14,3 +14,17 @@ impl From for FetchEntriesPayload { Self { entries, page_info } } } + +pub struct ExportSubscriptionPayload { + pub feeds: Vec, + pub page_info: types::PageInfo, +} + +impl From for ExportSubscriptionPayload { + fn from(v: query::export_subscription::ExportSubscriptionOutput) -> Self { + Self { + feeds: v.feeds.nodes.into_iter().map(Into::into).collect(), + page_info: v.feeds.page_info.into(), + } + } +} diff --git a/crates/synd_term/src/client/query.rs b/crates/synd_term/src/client/query.rs index b31d27c0..46c271d7 100644 --- a/crates/synd_term/src/client/query.rs +++ b/crates/synd_term/src/client/query.rs @@ -4,7 +4,7 @@ pub mod subscription { #![allow(dead_code)] use std::result::Result; pub const OPERATION_NAME: &str = "Subscription"; - pub const QUERY : & str = "query Subscription($after: String, $first: Int) {\n output: subscription {\n feeds(after: $after, first: $first) {\n nodes {\n ...Feed\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Feed on Feed {\n id\n type\n title\n url\n updated\n websiteUrl\n description\n generator\n entries(first: 10) {\n nodes {\n ...EntryMeta\n }\n }\n links {\n nodes {\n ...Link\n }\n }\n authors {\n nodes\n }\n}\n\nfragment EntryMeta on Entry {\n title,\n published,\n updated,\n summary,\n}\n\nfragment Link on Link {\n href\n rel\n mediaType\n title \n}\n\nquery Entries($after: String, $first: Int!) {\n output: subscription {\n entries(after: $after, first: $first) {\n nodes {\n ...Entry\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Entry on Entry {\n title\n published\n updated\n summary\n websiteUrl\n feed {\n ...FeedMeta\n }\n}\n\nfragment FeedMeta on FeedMeta {\n title\n url\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n}\n" ; + pub const QUERY : & str = "query Subscription($after: String, $first: Int) {\n output: subscription {\n feeds(after: $after, first: $first) {\n nodes {\n ...Feed\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Feed on Feed {\n id\n type\n title\n url\n updated\n websiteUrl\n description\n generator\n entries(first: 10) {\n nodes {\n ...EntryMeta\n }\n }\n links {\n nodes {\n ...Link\n }\n }\n authors {\n nodes\n }\n}\n\nfragment EntryMeta on Entry {\n title,\n published,\n updated,\n summary,\n}\n\nfragment Link on Link {\n href\n rel\n mediaType\n title \n}\n\nquery Entries($after: String, $first: Int!) {\n output: subscription {\n entries(after: $after, first: $first) {\n nodes {\n ...Entry\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Entry on Entry {\n title\n published\n updated\n summary\n websiteUrl\n feed {\n ...FeedMeta\n }\n}\n\nfragment FeedMeta on FeedMeta {\n title\n url\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n}\n\nquery ExportSubscription($after: String, $first: Int!) {\n output: subscription {\n feeds(after: $after, first: $first) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n title\n url\n type\n }\n }\n }\n}\n" ; use super::*; use serde::{Deserialize, Serialize}; #[allow(dead_code)] @@ -141,7 +141,7 @@ pub mod entries { #![allow(dead_code)] use std::result::Result; pub const OPERATION_NAME: &str = "Entries"; - pub const QUERY : & str = "query Subscription($after: String, $first: Int) {\n output: subscription {\n feeds(after: $after, first: $first) {\n nodes {\n ...Feed\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Feed on Feed {\n id\n type\n title\n url\n updated\n websiteUrl\n description\n generator\n entries(first: 10) {\n nodes {\n ...EntryMeta\n }\n }\n links {\n nodes {\n ...Link\n }\n }\n authors {\n nodes\n }\n}\n\nfragment EntryMeta on Entry {\n title,\n published,\n updated,\n summary,\n}\n\nfragment Link on Link {\n href\n rel\n mediaType\n title \n}\n\nquery Entries($after: String, $first: Int!) {\n output: subscription {\n entries(after: $after, first: $first) {\n nodes {\n ...Entry\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Entry on Entry {\n title\n published\n updated\n summary\n websiteUrl\n feed {\n ...FeedMeta\n }\n}\n\nfragment FeedMeta on FeedMeta {\n title\n url\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n}\n" ; + pub const QUERY : & str = "query Subscription($after: String, $first: Int) {\n output: subscription {\n feeds(after: $after, first: $first) {\n nodes {\n ...Feed\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Feed on Feed {\n id\n type\n title\n url\n updated\n websiteUrl\n description\n generator\n entries(first: 10) {\n nodes {\n ...EntryMeta\n }\n }\n links {\n nodes {\n ...Link\n }\n }\n authors {\n nodes\n }\n}\n\nfragment EntryMeta on Entry {\n title,\n published,\n updated,\n summary,\n}\n\nfragment Link on Link {\n href\n rel\n mediaType\n title \n}\n\nquery Entries($after: String, $first: Int!) {\n output: subscription {\n entries(after: $after, first: $first) {\n nodes {\n ...Entry\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Entry on Entry {\n title\n published\n updated\n summary\n websiteUrl\n feed {\n ...FeedMeta\n }\n}\n\nfragment FeedMeta on FeedMeta {\n title\n url\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n}\n\nquery ExportSubscription($after: String, $first: Int!) {\n output: subscription {\n feeds(after: $after, first: $first) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n title\n url\n type\n }\n }\n }\n}\n" ; use super::*; use serde::{Deserialize, Serialize}; #[allow(dead_code)] @@ -210,3 +210,99 @@ impl graphql_client::GraphQLQuery for Entries { } } } +pub struct ExportSubscription; +pub mod export_subscription { + #![allow(dead_code)] + use std::result::Result; + pub const OPERATION_NAME: &str = "ExportSubscription"; + pub const QUERY : & str = "query Subscription($after: String, $first: Int) {\n output: subscription {\n feeds(after: $after, first: $first) {\n nodes {\n ...Feed\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Feed on Feed {\n id\n type\n title\n url\n updated\n websiteUrl\n description\n generator\n entries(first: 10) {\n nodes {\n ...EntryMeta\n }\n }\n links {\n nodes {\n ...Link\n }\n }\n authors {\n nodes\n }\n}\n\nfragment EntryMeta on Entry {\n title,\n published,\n updated,\n summary,\n}\n\nfragment Link on Link {\n href\n rel\n mediaType\n title \n}\n\nquery Entries($after: String, $first: Int!) {\n output: subscription {\n entries(after: $after, first: $first) {\n nodes {\n ...Entry\n }\n pageInfo {\n ...PageInfo\n }\n }\n }\n}\n\nfragment Entry on Entry {\n title\n published\n updated\n summary\n websiteUrl\n feed {\n ...FeedMeta\n }\n}\n\nfragment FeedMeta on FeedMeta {\n title\n url\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n}\n\nquery ExportSubscription($after: String, $first: Int!) {\n output: subscription {\n feeds(after: $after, first: $first) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n title\n url\n type\n }\n }\n }\n}\n" ; + use super::*; + use serde::{Deserialize, Serialize}; + #[allow(dead_code)] + type Boolean = bool; + #[allow(dead_code)] + type Float = f64; + #[allow(dead_code)] + type Int = i64; + #[allow(dead_code)] + type ID = String; + #[derive(Debug)] + pub enum FeedType { + ATOM, + RSS1, + RSS2, + RSS0, + JSON, + Other(String), + } + impl ::serde::Serialize for FeedType { + fn serialize(&self, ser: S) -> Result { + ser.serialize_str(match *self { + FeedType::ATOM => "ATOM", + FeedType::RSS1 => "RSS1", + FeedType::RSS2 => "RSS2", + FeedType::RSS0 => "RSS0", + FeedType::JSON => "JSON", + FeedType::Other(ref s) => &s, + }) + } + } + impl<'de> ::serde::Deserialize<'de> for FeedType { + fn deserialize>(deserializer: D) -> Result { + let s: String = ::serde::Deserialize::deserialize(deserializer)?; + match s.as_str() { + "ATOM" => Ok(FeedType::ATOM), + "RSS1" => Ok(FeedType::RSS1), + "RSS2" => Ok(FeedType::RSS2), + "RSS0" => Ok(FeedType::RSS0), + "JSON" => Ok(FeedType::JSON), + _ => Ok(FeedType::Other(s)), + } + } + } + #[derive(Serialize, Debug)] + pub struct Variables { + pub after: Option, + pub first: Int, + } + impl Variables {} + #[derive(Deserialize, Debug)] + pub struct ResponseData { + pub output: ExportSubscriptionOutput, + } + #[derive(Deserialize, Debug)] + pub struct ExportSubscriptionOutput { + pub feeds: ExportSubscriptionOutputFeeds, + } + #[derive(Deserialize, Debug)] + pub struct ExportSubscriptionOutputFeeds { + #[serde(rename = "pageInfo")] + pub page_info: ExportSubscriptionOutputFeedsPageInfo, + pub nodes: Vec, + } + #[derive(Deserialize, Debug)] + pub struct ExportSubscriptionOutputFeedsPageInfo { + #[serde(rename = "hasNextPage")] + pub has_next_page: Boolean, + #[serde(rename = "endCursor")] + pub end_cursor: Option, + } + #[derive(Deserialize, Debug)] + pub struct ExportSubscriptionOutputFeedsNodes { + pub title: Option, + pub url: String, + #[serde(rename = "type")] + pub type_: FeedType, + } +} +impl graphql_client::GraphQLQuery for ExportSubscription { + type Variables = export_subscription::Variables; + type ResponseData = export_subscription::ResponseData; + fn build_query(variables: Self::Variables) -> ::graphql_client::QueryBody { + graphql_client::QueryBody { + variables, + query: export_subscription::QUERY, + operation_name: export_subscription::OPERATION_NAME, + } + } +} diff --git a/crates/synd_term/src/main.rs b/crates/synd_term/src/main.rs index cfaf8920..3c7acbca 100644 --- a/crates/synd_term/src/main.rs +++ b/crates/synd_term/src/main.rs @@ -73,7 +73,8 @@ async fn main() { if let Some(command) = command { let exit_code = match command { cli::Command::Clear(clear) => clear.run(), - cli::Command::Check(check) => check.run().await, + cli::Command::Check(check) => check.run(endpoint).await, + cli::Command::Export(export) => export.run(endpoint).await, }; std::process::exit(exit_code); diff --git a/crates/synd_term/src/types/mod.rs b/crates/synd_term/src/types/mod.rs index 11b3684a..572e07c8 100644 --- a/crates/synd_term/src/types/mod.rs +++ b/crates/synd_term/src/types/mod.rs @@ -1,7 +1,11 @@ use chrono::DateTime; +use serde::Serialize; use synd_feed::types::FeedType; -use crate::client::{mutation, query}; +use crate::client::{ + mutation, + query::{self, export_subscription}, +}; mod time; pub use time::{Time, TimeExt}; @@ -172,6 +176,24 @@ impl From for Entry { } } +#[derive(Serialize)] +pub struct ExportedFeed { + pub title: Option, + pub url: String, + // does not convert to utilize generated serde::Serialize impl + pub r#type: export_subscription::FeedType, +} + +impl From for ExportedFeed { + fn from(v: query::export_subscription::ExportSubscriptionOutputFeedsNodes) -> Self { + Self { + title: v.title, + url: v.url, + r#type: v.type_, + } + } +} + fn parse_time(t: impl AsRef) -> Time { DateTime::parse_from_rfc3339(t.as_ref()) .expect("invalid rfc3339 time") diff --git a/crates/synd_term/src/types/page_info.rs b/crates/synd_term/src/types/page_info.rs index 66aa9f34..eed69e11 100644 --- a/crates/synd_term/src/types/page_info.rs +++ b/crates/synd_term/src/types/page_info.rs @@ -14,3 +14,12 @@ impl From for PageInfo { } } } + +impl From for PageInfo { + fn from(v: query::export_subscription::ExportSubscriptionOutputFeedsPageInfo) -> Self { + Self { + has_next_page: v.has_next_page, + end_cursor: v.end_cursor, + } + } +}