synd-term 0.3.2

terminal feed viewer
Documentation
use chrono::DateTime;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use synd_feed::types::{Category, FeedType, FeedUrl, Requirement};
use tracing::warn;

use crate::{
    client::synd_api::{
        mutation,
        query::{self},
    },
    ui,
};

mod time;
pub use time::{Time, TimeExt};

mod page_info;
pub use page_info::PageInfo;

mod requirement_ext;
pub use requirement_ext::RequirementExt;

pub(crate) mod github;

#[derive(Debug, Clone)]
#[cfg_attr(test, derive(fake::Dummy))]
pub struct Link {
    pub href: String,
    pub rel: Option<String>,
    pub media_type: Option<String>,
    pub title: Option<String>,
}

impl From<query::subscription::Link> for Link {
    fn from(v: query::subscription::Link) -> Self {
        Self {
            href: v.href,
            rel: v.rel,
            media_type: v.media_type,
            title: v.title,
        }
    }
}

impl From<mutation::subscribe_feed::Link> for Link {
    fn from(v: mutation::subscribe_feed::Link) -> Self {
        Self {
            href: v.href,
            rel: v.rel,
            media_type: v.media_type,
            title: v.title,
        }
    }
}

#[derive(Debug, Clone)]
#[cfg_attr(test, derive(fake::Dummy))]
pub struct EntryMeta {
    pub title: Option<String>,
    pub published: Option<Time>,
    pub updated: Option<Time>,
    pub summary: Option<String>,
}

impl From<query::subscription::EntryMeta> for EntryMeta {
    fn from(e: query::subscription::EntryMeta) -> Self {
        Self {
            title: e.title,
            published: e.published.map(parse_time),
            updated: e.updated.map(parse_time),
            summary: e.summary,
        }
    }
}

impl From<mutation::subscribe_feed::EntryMeta> for EntryMeta {
    fn from(e: mutation::subscribe_feed::EntryMeta) -> Self {
        Self {
            title: e.title,
            published: e.published.map(parse_time),
            updated: e.updated.map(parse_time),
            summary: e.summary,
        }
    }
}

impl EntryMeta {
    pub fn summary_text(&self, width: usize) -> Option<String> {
        self.summary.as_deref().and_then(|summary| {
            match html2text::from_read(summary.as_bytes(), width) {
                Ok(text) => Some(text),
                Err(err) => {
                    warn!("convert summary html to text: {err}");
                    None
                }
            }
        })
    }
}

#[derive(Debug, Clone)]
#[cfg_attr(test, derive(fake::Dummy))]
pub struct Feed {
    pub feed_type: Option<FeedType>,
    pub title: Option<String>,
    pub url: FeedUrl,
    pub updated: Option<Time>,
    pub links: Vec<Link>,
    pub website_url: Option<String>,
    pub description: Option<String>,
    pub generator: Option<String>,
    pub entries: Vec<EntryMeta>,
    pub authors: Vec<String>,
    requirement: Option<Requirement>,
    category: Option<Category<'static>>,
}

impl Feed {
    pub fn requirement(&self) -> Requirement {
        self.requirement.unwrap_or(ui::DEFAULT_REQUIREMNET)
    }

    pub fn category(&self) -> &Category<'static> {
        self.category.as_ref().unwrap_or(ui::default_category())
    }

    #[must_use]
    pub fn with_url(self, url: FeedUrl) -> Self {
        Self { url, ..self }
    }

    #[must_use]
    pub fn with_requirement(self, requirement: Requirement) -> Self {
        Self {
            requirement: Some(requirement),
            ..self
        }
    }

    #[must_use]
    pub fn with_category(self, category: Category<'static>) -> Self {
        Self {
            category: Some(category),
            ..self
        }
    }
}

impl From<query::subscription::Feed> for Feed {
    fn from(f: query::subscription::Feed) -> Self {
        Self {
            feed_type: match f.type_ {
                query::subscription::FeedType::ATOM => Some(FeedType::Atom),
                query::subscription::FeedType::RSS1 => Some(FeedType::RSS1),
                query::subscription::FeedType::RSS2 => Some(FeedType::RSS2),
                query::subscription::FeedType::RSS0 => Some(FeedType::RSS0),
                query::subscription::FeedType::JSON => Some(FeedType::JSON),
                query::subscription::FeedType::Other(_) => None,
            },
            title: f.title,
            url: f.url,
            updated: f.updated.map(parse_time),
            links: f.links.nodes.into_iter().map(From::from).collect(),
            website_url: f.website_url,
            description: f.description,
            generator: f.generator,
            entries: f.entries.nodes.into_iter().map(From::from).collect(),
            authors: f.authors.nodes,
            requirement: f.requirement.and_then(|r| match r {
                query::subscription::Requirement::MUST => Some(Requirement::Must),
                query::subscription::Requirement::SHOULD => Some(Requirement::Should),
                query::subscription::Requirement::MAY => Some(Requirement::May),
                query::subscription::Requirement::Other(_) => None,
            }),
            category: f.category,
        }
    }
}

impl From<mutation::subscribe_feed::Feed> for Feed {
    fn from(f: mutation::subscribe_feed::Feed) -> Self {
        Self {
            feed_type: match f.type_ {
                mutation::subscribe_feed::FeedType::ATOM => Some(FeedType::Atom),
                mutation::subscribe_feed::FeedType::RSS1 => Some(FeedType::RSS1),
                mutation::subscribe_feed::FeedType::RSS2 => Some(FeedType::RSS2),
                mutation::subscribe_feed::FeedType::RSS0 => Some(FeedType::RSS0),
                mutation::subscribe_feed::FeedType::JSON => Some(FeedType::JSON),
                mutation::subscribe_feed::FeedType::Other(_) => None,
            },
            title: f.title,
            url: f.url,
            updated: f.updated.map(parse_time),
            links: f.links.nodes.into_iter().map(From::from).collect(),
            website_url: f.website_url,
            description: f.description,
            generator: f.generator,
            entries: f.entries.nodes.into_iter().map(From::from).collect(),
            authors: f.authors.nodes,
            requirement: f.requirement.and_then(|r| match r {
                mutation::subscribe_feed::Requirement::MUST => Some(Requirement::Must),
                mutation::subscribe_feed::Requirement::SHOULD => Some(Requirement::Should),
                mutation::subscribe_feed::Requirement::MAY => Some(Requirement::May),
                mutation::subscribe_feed::Requirement::Other(_) => None,
            }),
            category: f.category,
        }
    }
}

#[derive(Debug, Clone)]
pub struct Entry {
    pub title: Option<String>,
    pub published: Option<Time>,
    pub updated: Option<Time>,
    pub website_url: Option<String>,
    pub summary: Option<String>,
    pub feed_title: Option<String>,
    pub feed_url: FeedUrl,
    requirement: Option<Requirement>,
    category: Option<Category<'static>>,
}

impl Entry {
    pub fn summary_text(&self, width: usize) -> Option<String> {
        self.summary.as_deref().map(|summary| {
            html2text::config::plain()
                .string_from_read(summary.as_bytes(), width)
                .unwrap_or_default()
        })
    }

    pub fn requirement(&self) -> Requirement {
        self.requirement.unwrap_or(ui::DEFAULT_REQUIREMNET)
    }

    pub fn category(&self) -> &Category<'static> {
        self.category
            .as_ref()
            .unwrap_or_else(|| ui::default_category())
    }
}

impl From<query::entries::Entry> for Entry {
    fn from(v: query::entries::Entry) -> Self {
        Self {
            title: v.title,
            published: v.published.map(parse_time),
            updated: v.updated.map(parse_time),
            website_url: v.website_url,
            feed_title: v.feed.title,
            feed_url: v.feed.url,
            summary: v.summary,
            requirement: match v.feed.requirement {
                Some(query::entries::Requirement::MUST) => Some(Requirement::Must),
                Some(query::entries::Requirement::SHOULD) => Some(Requirement::Should),
                Some(query::entries::Requirement::MAY) => Some(Requirement::May),
                _ => None,
            },
            category: v.feed.category,
        }
    }
}

#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ExportedFeed {
    pub title: Option<String>,
    pub url: FeedUrl,
    pub requirement: Option<Requirement>,
    pub category: Option<Category<'static>>,
}

impl From<query::export_subscription::ExportSubscriptionOutputFeedsNodes> for ExportedFeed {
    fn from(v: query::export_subscription::ExportSubscriptionOutputFeedsNodes) -> Self {
        Self {
            title: v.title,
            url: v.url,
            requirement: v.requirement.and_then(|r| match r {
                query::export_subscription::Requirement::MUST => Some(Requirement::Must),
                query::export_subscription::Requirement::SHOULD => Some(Requirement::Should),
                query::export_subscription::Requirement::MAY => Some(Requirement::May),
                query::export_subscription::Requirement::Other(_) => None,
            }),
            category: v.category,
        }
    }
}

impl From<ExportedFeed> for mutation::subscribe_feed::SubscribeFeedInput {
    fn from(feed: ExportedFeed) -> Self {
        Self {
            url: feed.url,
            requirement: feed.requirement.map(|r| match r {
                Requirement::Must => mutation::subscribe_feed::Requirement::MUST,
                Requirement::Should => mutation::subscribe_feed::Requirement::SHOULD,
                Requirement::May => mutation::subscribe_feed::Requirement::MAY,
            }),
            category: feed.category,
        }
    }
}

fn parse_time(t: impl AsRef<str>) -> Time {
    DateTime::parse_from_rfc3339(t.as_ref())
        .expect("invalid rfc3339 time")
        .with_timezone(&chrono::Utc)
}