use crate::prelude::*;
use std::{
io::Cursor,
process::Stdio,
sync::Arc,
time::{Duration, Instant},
};
use getset::Getters;
use htmd::HtmlToMarkdown;
use image::ImageReader;
use news_flash::models::{Article, ArticleID, Enclosure, FatArticle, Feed, Tag, Thumbnail};
use ratatui_image::{picker::Picker, protocol::StatefulProtocol};
use tokio::sync::mpsc::UnboundedSender;
#[derive(Getters)]
#[getset(get = "pub(super)")]
pub struct ArticleContentModelData {
#[getset(skip)]
news_flash_utils: Arc<NewsFlashUtils>,
#[getset(skip)]
message_sender: UnboundedSender<Message>,
article: Option<Article>,
feed: Option<Feed>,
tags: Option<Vec<Tag>>,
fat_article: Option<FatArticle>,
enclosures: Option<Vec<Enclosure>>,
markdown_content: Option<String>,
thumbnail_fetch_successful: Option<bool>,
thumbnail_fetch_running: bool,
instant_since_article_selected: Option<Instant>,
duration_since_last_article_change: Option<Duration>,
}
impl ArticleContentModelData {
pub(super) fn new(
news_flash_utils: Arc<NewsFlashUtils>,
message_sender: UnboundedSender<Message>,
) -> Self {
Self {
news_flash_utils,
message_sender,
article: None,
feed: None,
tags: None,
fat_article: None,
markdown_content: None,
thumbnail_fetch_successful: None,
thumbnail_fetch_running: false,
instant_since_article_selected: None,
duration_since_last_article_change: None,
enclosures: None,
}
}
pub(super) async fn on_article_selected(
&mut self,
article_id: &ArticleID,
is_focused: bool,
) -> color_eyre::Result<()> {
if let Some(current_article) = self.article.as_ref()
&& current_article.article_id == *article_id
{
return Ok(());
}
let current_instant = Instant::now();
if let Some(last_article_selected) = self.instant_since_article_selected {
self.duration_since_last_article_change =
Some(current_instant.duration_since(last_article_selected));
}
self.instant_since_article_selected = Some(current_instant);
self.thumbnail_fetch_successful = None;
self.fat_article = None;
self.markdown_content = None;
self.feed = None;
self.tags = None;
let news_flash = self.news_flash_utils.news_flash_lock.read().await;
let article = news_flash.get_article(article_id)?;
self.feed = news_flash
.get_feeds()?
.0
.into_iter()
.find(|feed| feed.feed_id == article.feed_id);
let (tags, taggings) = news_flash.get_tags()?;
let mut tag_for_tag_id = NewsFlashUtils::generate_id_map(&tags, |tag| tag.tag_id.clone());
self.tags = Some(
taggings
.into_iter()
.filter(|tagging| tagging.article_id == article.article_id)
.filter_map(|tagging| tag_for_tag_id.remove(&tagging.tag_id))
.collect::<Vec<Tag>>(),
);
self.enclosures = Some(news_flash.get_enclosures(article_id)?);
self.article = Some(article);
if is_focused {
self.message_sender
.send(Message::Event(Event::FatArticleSelected(
article_id.clone(),
)))?;
}
Ok(())
}
pub(super) fn prepare_thumbnail(
&mut self,
thumbnail: &Thumbnail,
picker: &Picker,
) -> color_eyre::Result<Option<StatefulProtocol>> {
if let Some(article) = self.article.as_ref()
&& article.article_id == thumbnail.article_id
&& let Some(data) = thumbnail.data.as_ref()
{
let cursor = Cursor::new(data);
let image = ImageReader::new(cursor).with_guessed_format()?.decode()?;
Ok(Some(picker.new_resize_protocol(image)))
} else {
Ok(None)
}
}
pub(super) fn scrape_article(&mut self) -> color_eyre::Result<()> {
let Some(article) = self.article.as_ref() else {
return Ok(());
};
if self.fat_article.is_none() {
let article_id = article.article_id.clone();
self.news_flash_utils.fetch_fat_article(article_id);
}
Ok(())
}
pub(super) fn set_fat_article(&mut self, fat_article: FatArticle) {
self.fat_article = Some(fat_article);
self.markdown_content = None; }
pub(super) fn get_or_create_markdown_content(
&mut self,
config: &Config,
) -> color_eyre::Result<()> {
if self.markdown_content.is_none()
&& config.content_preferred_type == ArticleContentType::Markdown
&& let Some(fat_article) = self.fat_article.as_ref()
&& let Some(html) = fat_article.scraped_content.as_deref()
{
let html2markdown = HtmlToMarkdown::builder().build();
self.markdown_content = Some(html2markdown.convert(html)?);
}
Ok(())
}
pub(super) fn update_should_fetch_thumbnail(&mut self, config: &Config) -> bool {
if !config.thumbnail_show || self.thumbnail_fetch_running {
return false;
}
let Some(article) = self.article.as_ref() else {
return false;
};
if article.thumbnail_url.is_none() {
self.thumbnail_fetch_successful = Some(false);
return false;
}
if !self.thumbnail_fetch_successful.unwrap_or(true) {
return false;
}
let current_instant = Instant::now();
let long_enough_current_article = match self.instant_since_article_selected {
Some(article_selected_instant) => {
let duration = current_instant.duration_since(article_selected_instant);
duration >= Duration::from_millis(config.thumbnail_fetch_debounce_millis)
}
None => true,
};
match self.duration_since_last_article_change {
None => true,
Some(duration) => {
duration > Duration::from_millis(config.thumbnail_fetch_debounce_millis)
|| long_enough_current_article
}
}
}
pub(super) fn start_fetch_thumbnail(&mut self) -> color_eyre::Result<()> {
let Some(article) = self.article.as_ref() else {
self.thumbnail_fetch_successful = Some(false);
return Ok(());
};
if article.thumbnail_url.is_none() {
self.thumbnail_fetch_successful = Some(false);
return Ok(());
}
let article_id = article.article_id.clone();
self.news_flash_utils.fetch_thumbnail(article_id);
self.thumbnail_fetch_running = true;
Ok(())
}
pub(super) fn on_thumbnail_fetch_finished(&mut self, thumbnail: Option<&Thumbnail>) {
self.thumbnail_fetch_running = false;
match thumbnail {
Some(_) => {
self.thumbnail_fetch_successful = Some(true);
}
None => {
log::debug!("fetching thumbnail not successful");
self.thumbnail_fetch_successful = Some(false);
}
}
}
pub(super) fn on_thumbnail_fetch_failed(&mut self) {
self.thumbnail_fetch_successful = Some(false);
self.thumbnail_fetch_running = false;
}
pub(super) fn clean_string(string: &str) -> String {
string.replace("\r", "").replace("\n", "")
}
pub(crate) async fn open_enclosure(
&self,
config: &Config,
enclosure: &Enclosure,
) -> color_eyre::Result<String> {
let command = match <EnclosureType>::from(enclosure) {
EnclosureType::Audio => config.audio_enclosure_command.as_ref(),
EnclosureType::Image => config.image_enclosure_command.as_ref(),
EnclosureType::Video => config.video_enclosure_command.as_ref(),
}
.unwrap_or(&config.enclosure_command)
.replace("{url}", enclosure.url.as_ref())
.replace("{type}", <EnclosureType>::from(enclosure).as_ref())
.replace("{mime}", enclosure.mime_type.as_deref().unwrap_or("*/*"));
let args = shell_words::split(&command)?;
let Some((cmd, args)) = args.split_first() else {
return Err(color_eyre::eyre::eyre!(
"invalid enclosure command: no command found"
));
};
let mut command = std::process::Command::new(cmd);
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.args(args)
.spawn()?;
Ok(cmd.to_owned())
}
}