diff --git a/src/backend.rs b/src/backend.rs index 41fba494..3e52fc17 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -6,7 +6,7 @@ use once_cell::sync::Lazy; use strum::{Display, EnumIter, IntoEnumIterator}; use self::error_log::create_error_logs_files; -use crate::config::{MangaTuiConfig, build_config_file}; +use crate::config::build_config_file; use crate::logger::ILogger; pub mod cache; diff --git a/src/backend/cache.rs b/src/backend/cache.rs index 42b4bc26..b06dd80f 100644 --- a/src/backend/cache.rs +++ b/src/backend/cache.rs @@ -1,6 +1,5 @@ use std::error::Error; -use std::fmt::{Debug, Display}; -use std::time::Duration; +use std::fmt::Debug; pub mod in_memory; diff --git a/src/backend/cache/in_memory.rs b/src/backend/cache/in_memory.rs index c7a442b8..1060a0f5 100644 --- a/src/backend/cache/in_memory.rs +++ b/src/backend/cache/in_memory.rs @@ -139,13 +139,12 @@ impl Cacher for InMemoryCache { #[cfg(test)] mod tests { use std::error::Error; - use std::thread::sleep; use std::time::{Duration, Instant}; use pretty_assertions::assert_eq; use super::*; - use crate::backend::cache::{self, Entry, InsertEntry}; + use crate::backend::cache::{Entry, InsertEntry}; #[test] fn it_saves_and_retrieves_data() -> Result<(), Box> { diff --git a/src/backend/manga_downloader/pdf_downloader.rs b/src/backend/manga_downloader/pdf_downloader.rs index 3c2e5675..89d2735c 100644 --- a/src/backend/manga_downloader/pdf_downloader.rs +++ b/src/backend/manga_downloader/pdf_downloader.rs @@ -4,8 +4,8 @@ use std::path::Path; use flate2::Compression; use flate2::write::ZlibEncoder; -use image::{DynamicImage, GenericImageView, ImageFormat}; -use lopdf::{Document, Object, Stream, dictionary}; +use image::{GenericImageView, ImageFormat}; +use lopdf::{Document, Stream, dictionary}; use super::MangaDownloader; @@ -28,14 +28,15 @@ impl MangaDownloader for PdfDownloader { let pdf_path = base_directory.join(format!("{}.pdf", self.make_chapter_name(&chapter).display())); + let file = File::create(pdf_path)?; let mut doc = Document::with_version("1.7"); let mut pages = Vec::new(); let page_width = 595.0; - for (index, page) in chapter.pages.iter().enumerate() { + for page in chapter.pages.iter() { let img = image::load_from_memory(&page.bytes)?; let (img_width, img_height) = img.dimensions(); - let mut img_data = Vec::new(); + let mut img_data = Vec::with_capacity(page.bytes.len()); let filter; let color_space = if img.color().has_color() { "DeviceRGB" } else { "DeviceGray" }; @@ -105,7 +106,6 @@ impl MangaDownloader for PdfDownloader { doc.trailer.set("Root", catalog_id); - let mut file = File::create(pdf_path)?; doc.save_to(&mut BufWriter::new(file))?; Ok(()) @@ -115,12 +115,9 @@ impl MangaDownloader for PdfDownloader { #[cfg(test)] mod tests { use std::error::Error; - use std::fs; - use std::path::PathBuf; use fake::Fake; use fake::faker::name::en::Name; - use lopdf::Document; use uuid::Uuid; use super::*; diff --git a/src/backend/manga_provider.rs b/src/backend/manga_provider.rs index e97b88d7..280c97b1 100644 --- a/src/backend/manga_provider.rs +++ b/src/backend/manga_provider.rs @@ -21,6 +21,7 @@ use crate::config::ImageQuality; use crate::global::PREFERRED_LANGUAGE; use crate::view::widgets::StatefulWidgetFrame; +pub mod filters; pub mod mangadex; pub mod weebcentral; @@ -203,6 +204,11 @@ impl Languages { } } + /// Returns an iterator which discards the 'Unknown' variant + pub fn iterate() -> std::iter::Filter bool> { + Self::iter().filter(|lan| *lan != Self::Unkown) + } + pub fn get_preferred_lang() -> &'static Languages { PREFERRED_LANGUAGE.get_or_init(Self::default) } diff --git a/src/backend/manga_provider/filters.rs b/src/backend/manga_provider/filters.rs new file mode 100644 index 00000000..57fa2986 --- /dev/null +++ b/src/backend/manga_provider/filters.rs @@ -0,0 +1,252 @@ +//! This module provides the `FiltersCache` struct, which is responsible for caching and retrieving filter data used in manga search +//! operations. +//! +//! The `FiltersCache` allows you to serialize filter configurations (such as languages, publication status, sort order, tags, +//! authors, and more) into TOML files for persistent storage, and deserialize them back when needed. This is useful for persisting +//! user-selected filters or default filter sets between application runs. +//! +//! The cache is stored in a specified directory and file, and the module provides methods to write filter data to the cache and +//! read it back. The filter data must implement `serde::Serialize` and `serde::de::DeserializeOwned`, making it flexible for +//! various filter types. +//! +//! Example use cases include caching search filters for manga providers like MangaDex, where filters may include fields such as +//! languages, publication status, sort order, tags, magazine demographics, authors, and artists. +use std::error::Error; +use std::fs::{File, create_dir_all}; +use std::io::{Read, Write}; +use std::path::PathBuf; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// A cache handler for serializing and deserializing filter data to and from TOML files. +/// +/// `FiltersCache` is designed to persist filter configurations used in manga search operations, such as those for MangaDex. +/// It stores filter data (implementing `serde::Serialize` and `serde::de::DeserializeOwned`) in a specified directory and file. +/// +/// # Example Usage +/// +/// The struct is typically used to cache filters like the following (see tests for more details): +/// +/// ```rust +/// # use crate::backend::manga_provider::mangadex::filters::api_parameter::{Filters, ContentRating, PublicationStatus, SortBy, Tags, TagData, TagSelection, MagazineDemographic, User, AuthorFilterState}; +/// # use crate::backend::manga_provider::Languages; +/// let filters = Filters { +/// content_rating: vec![ContentRating::Suggestive, ContentRating::Erotic], +/// publication_status: vec![PublicationStatus::Completed, PublicationStatus::Ongoing], +/// sort_by: SortBy::HighestRating, +/// tags: Tags::new(vec![TagData::new("id_tag".to_string(), TagSelection::Included, "fantasy".to_string())]), +/// magazine_demographic: vec![MagazineDemographic::Shoujo, MagazineDemographic::Seinen], +/// authors: User::new(vec![AuthorFilterState::new("user_id".to_string(), "".to_string())]), +/// artists: User::default(), +/// languages: vec![Languages::English, Languages::Spanish], +/// }; +/// ``` +/// +/// You can then write these filters to a cache file and retrieve them later: +/// +/// ```rust +/// # use std::path::Path; +/// # let filters_cache = FiltersCache::new(Path::new("./cache_dir"), "filters.toml"); +/// filters_cache.write_to_cache(&filters).unwrap(); +/// let cached: Option = filters_cache.get_cached_filters(); +/// ``` +/// +/// This enables persistent storage and retrieval of user or default filter sets between application runs. +pub struct FiltersCache { + base_directory: PathBuf, + cache_filename: &'static str, +} + +impl FiltersCache { + pub fn new>(base_directory: T, cache_filename: &'static str) -> Self { + let path: PathBuf = base_directory.into(); + Self { + base_directory: path, + cache_filename, + } + } + + fn save_filters(&self, filters: &I, file: &mut T) -> Result<(), Box> { + let filters_as_toml = toml::to_string(filters)?; + + file.write_all(filters_as_toml.as_bytes())?; + + file.flush()?; + + Ok(()) + } + + #[inline] + fn get_cache_file_path(&self) -> PathBuf { + self.base_directory.join(self.cache_filename) + } + + fn parse_cache(&self, file: &mut T) -> Result> { + let mut contents = String::new(); + + file.read_to_string(&mut contents)?; + + let filters: I = toml::from_str(&contents)?; + + Ok(filters) + } + + fn ensure_cache_directory_exists(&self) -> Result<(), std::io::Error> { + if !self.base_directory.exists() { + create_dir_all(&self.base_directory)? + } + + Ok(()) + } + + /// Reads the cache directory, and returns: + /// Some(filters) if there is already a cache filters file, + /// None if the file doesnt exist + pub fn get_cached_filters(&self) -> Option { + let file_path = self.get_cache_file_path(); + + let maybe_filters = File::open(file_path) + .inspect_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => {}, + _ => { + #[cfg(not(test))] + { + use crate::backend::error_log::{ErrorType, write_to_error_log}; + + write_to_error_log(ErrorType::String(&e.to_string())) + } + }, + }) + .and_then(|mut file| self.parse_cache(&mut file).map_err(|e| std::io::Error::other(e.to_string()))) + .ok(); + + maybe_filters + } + + /// Writes the "Filters" to the cache file which is created if it + /// doesnt exist in toml format + pub fn write_to_cache(&self, filters: &I) -> Result<(), Box> { + let file_path = self.get_cache_file_path(); + + self.ensure_cache_directory_exists()?; + + let mut file = File::create(file_path)?; + + self.save_filters(filters, &mut file)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + use std::fs::create_dir_all; + use std::io::Cursor; + use std::path::Path; + + use pretty_assertions::assert_eq; + + use super::*; + use crate::backend::manga_provider::Languages; + use crate::backend::manga_provider::mangadex::filters::api_parameter::{ + AuthorFilterState, ContentRating, Filters, MagazineDemographic, PublicationStatus, SortBy, TagData, TagSelection, Tags, + User, + }; + + const CACHE_TEST_DIRECTORY_PATH: &str = "./test_results/cache_test/"; + + #[test] + fn it_writes_mangadex_filters_to_cache_file() -> Result<(), Box> { + let filters: Filters = Filters { + content_rating: vec![ContentRating::Suggestive, ContentRating::Erotic], + publication_status: vec![PublicationStatus::Completed, PublicationStatus::Ongoing], + sort_by: SortBy::HighestRating, + tags: Tags::new(vec![TagData::new("id_tag".to_string(), TagSelection::Included, "fantasy".to_string())]), + magazine_demographic: vec![MagazineDemographic::Shoujo, MagazineDemographic::Seinen], + authors: User::new(vec![AuthorFilterState::new("user_id".to_string(), "".to_string())]), + artists: User::default(), + languages: vec![Languages::English, Languages::Spanish], + }; + + let mut test_file = Cursor::new(Vec::new()); + + let filters_cache = FiltersCache::new(Path::new(""), ""); + + filters_cache.save_filters(&filters, &mut test_file)?; + + let contents = String::from_utf8(test_file.into_inner())?; + + let result: Filters = toml::from_str(&contents)?; + + assert_eq!(filters, result); + + Ok(()) + } + + #[test] + fn it_parses_mangadex_filters_from_the_cache_from_file() -> Result<(), Box> { + let filters: Filters = Filters { + content_rating: vec![ContentRating::Suggestive, ContentRating::Erotic], + publication_status: vec![PublicationStatus::Completed, PublicationStatus::Ongoing], + sort_by: SortBy::HighestRating, + tags: Tags::new(vec![TagData::new("id_tag".to_string(), TagSelection::Included, "fantasy".to_string())]), + magazine_demographic: vec![MagazineDemographic::Shoujo, MagazineDemographic::Seinen], + authors: User::new(vec![AuthorFilterState::new("user_id".to_string(), "".to_string())]), + artists: User::default(), + languages: vec![Languages::English, Languages::Spanish], + }; + + let mut test_file = Cursor::new(toml::to_string(&filters)?); + + let filters_cache = FiltersCache::new(Path::new(""), ""); + + let cached = filters_cache.parse_cache(&mut test_file)?; + + assert_eq!(filters, cached); + + Ok(()) + } + + fn delete_cached_file_if_already_exists(path: &Path) { + if path.exists() { + std::fs::remove_file(path).unwrap() + } + } + + #[ignore] + #[test] + fn it_check_if_cache_file_exists_and_returns_none() -> Result<(), Box> { + let filters: Filters = Filters { + content_rating: vec![ContentRating::Suggestive, ContentRating::Erotic], + publication_status: vec![PublicationStatus::Completed, PublicationStatus::Ongoing], + sort_by: SortBy::HighestRating, + tags: Tags::new(vec![TagData::new("id_tag".to_string(), TagSelection::Included, "fantasy".to_string())]), + magazine_demographic: vec![MagazineDemographic::Shoujo, MagazineDemographic::Seinen], + authors: User::new(vec![AuthorFilterState::new("user_id".to_string(), "".to_string())]), + artists: User::default(), + languages: vec![Languages::English, Languages::Spanish], + }; + + create_dir_all(CACHE_TEST_DIRECTORY_PATH)?; + + let file_cache = FiltersCache::new(CACHE_TEST_DIRECTORY_PATH, "mangadex_filters.toml"); + + delete_cached_file_if_already_exists(&file_cache.get_cache_file_path()); + + let first_check: Option = file_cache.get_cached_filters(); + + assert!(first_check.is_none()); + + file_cache.write_to_cache(&filters).expect("failed to create cache file"); + + let second_check: Option = file_cache.get_cached_filters(); + + assert!(second_check.is_some()); + + dbg!(second_check); + + Ok(()) + } +} diff --git a/src/backend/manga_provider/mangadex.rs b/src/backend/manga_provider/mangadex.rs index a18bc42b..85e756e5 100644 --- a/src/backend/manga_provider/mangadex.rs +++ b/src/backend/manga_provider/mangadex.rs @@ -1,6 +1,6 @@ use std::error::Error; -use std::path::Path; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock}; use std::time::Duration as StdDuration; use api_responses::*; @@ -22,6 +22,7 @@ use super::{ }; use crate::backend::cache::{CacheDuration, Cacher, InsertEntry}; use crate::backend::database::ChapterBookmarked; +use crate::backend::manga_provider::filters::FiltersCache; use crate::config::ImageQuality; use crate::global::APP_USER_AGENT; use crate::view::widgets::StatefulWidgetFrame; @@ -34,6 +35,15 @@ pub static API_URL_BASE: &str = "https://api.mangadex.org"; pub static COVER_IMG_URL_BASE: &str = "https://uploads.mangadex.org/covers"; +pub static MANGADEX_CACHE_FILENAME: &str = "filters.toml"; + +pub static MANGADEX_CACHE_BASE_DIRECTORY: LazyLock = LazyLock::new(|| { + let cache_path = directories::ProjectDirs::from("", "", "manga-tui") + .map(|project_dirs| project_dirs.cache_dir().join("mangadex").to_path_buf()) + .unwrap_or_default(); + cache_path +}); + /// Mangadex: `https://mangadex.org` /// This is the first manga provider since the first versions of manga-tui, thats why it is the /// default @@ -1033,6 +1043,13 @@ impl ProviderIdentity for MangadexClient { impl MangaProvider for MangadexClient {} +/// Returns the cached mangadex filters or default if it hasnt been cached yet +pub fn get_cached_filters() -> Filters { + FiltersCache::new(&*MANGADEX_CACHE_BASE_DIRECTORY, MANGADEX_CACHE_FILENAME) + .get_cached_filters() + .unwrap_or_default() +} + #[cfg(test)] mod test { use cache::mock::EmptyCache; diff --git a/src/backend/manga_provider/mangadex/filter_widget.rs b/src/backend/manga_provider/mangadex/filter_widget.rs index b8673972..0f64625d 100644 --- a/src/backend/manga_provider/mangadex/filter_widget.rs +++ b/src/backend/manga_provider/mangadex/filter_widget.rs @@ -204,7 +204,7 @@ impl MangadexFilterWidget { let tags_filtered: Vec> = tags .iter() .filter(|tag| tag.state != TagListItemState::NotSelected) - .map(|tag| set_filter_tags_style(tag)) + .map(|tag| tag.set_filter_tags_style()) .collect(); Paragraph::new(Line::from(tags_filtered)) diff --git a/src/backend/manga_provider/mangadex/filters.rs b/src/backend/manga_provider/mangadex/filters.rs index 15d8ef97..e18b97ad 100644 --- a/src/backend/manga_provider/mangadex/filters.rs +++ b/src/backend/manga_provider/mangadex/filters.rs @@ -1,14 +1,2 @@ -use filter_provider::{TagListItem, TagListItemState}; -use ratatui::style::Stylize; -use ratatui::text::Span; - pub mod api_parameter; pub mod filter_provider; - -pub fn set_filter_tags_style(tag: &TagListItem) -> Span<'_> { - match tag.state { - TagListItemState::Included => format!(" {} ", tag.name).black().on_green(), - TagListItemState::Excluded => format!(" {} ", tag.name).black().on_red(), - TagListItemState::NotSelected => Span::from(tag.name.clone()), - } -} diff --git a/src/backend/manga_provider/mangadex/filters/api_parameter.rs b/src/backend/manga_provider/mangadex/filters/api_parameter.rs index e54ff20a..04a37734 100644 --- a/src/backend/manga_provider/mangadex/filters/api_parameter.rs +++ b/src/backend/manga_provider/mangadex/filters/api_parameter.rs @@ -1,6 +1,8 @@ //! The `Mangadex` represented as the Api parameters use std::fmt::{Debug, Write}; +use std::ops::Deref; +use serde::{Deserialize, Serialize}; use strum::{Display, EnumIter, IntoEnumIterator}; use super::filter_provider::{TagListItem, TagListItemState}; @@ -10,8 +12,9 @@ pub trait IntoParam: Debug { fn into_param(self) -> String; } -#[derive(Display, Clone, Debug)] +#[derive(Display, Clone, Debug, EnumIter, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ContentRating { + #[default] #[strum(to_string = "safe")] Safe, #[strum(to_string = "suggestive")] @@ -34,7 +37,7 @@ impl From<&str> for ContentRating { } } -#[derive(Display, Clone, EnumIter, PartialEq, Eq, Default, Debug)] +#[derive(Display, Clone, EnumIter, PartialEq, Eq, Default, Debug, Serialize, Deserialize)] pub enum SortBy { #[strum(to_string = "Best match")] BestMatch, @@ -65,21 +68,22 @@ pub enum SortBy { YearAscending, } -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum TagSelection { Included, Excluded, } -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct TagData { - id: String, - state: TagSelection, + pub id: String, + pub name: String, + pub state: TagSelection, } impl TagData { - pub fn new(id: String, state: TagSelection) -> Self { - Self { id, state } + pub fn new(id: String, state: TagSelection, name: String) -> Self { + Self { id, state, name } } } @@ -88,20 +92,35 @@ impl From<&TagListItem> for TagData { Self { id: value.id.clone(), state: if value.state == TagListItemState::Included { TagSelection::Included } else { TagSelection::Excluded }, + name: value.name.to_string(), + } + } +} + +impl From<&TagData> for TagListItem { + fn from(value: &TagData) -> Self { + Self { + id: value.id.to_string(), + name: value.name.to_string(), + state: TagListItemState::default(), } } } -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct Tags(Vec); impl Tags { pub fn new(tags: Vec) -> Self { Self(tags) } +} + +impl Deref for Tags { + type Target = Vec; - pub fn is_empty(&self) -> bool { - self.0.is_empty() + fn deref(&self) -> &Self::Target { + &self.0 } } @@ -167,7 +186,7 @@ impl IntoParam for SortBy { } } -#[derive(Display, Clone, EnumIter, PartialEq, Eq, Debug)] +#[derive(Display, Clone, EnumIter, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum MagazineDemographic { Shounen, Shoujo, @@ -197,34 +216,59 @@ impl IntoParam for Vec { } } -#[derive(Default, Clone, Debug)] -pub struct AuthorFilterState(String); +#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct AuthorFilterState { + pub id: String, + pub name: String, +} impl AuthorFilterState { - pub fn new(id_author: String) -> Self { - AuthorFilterState(id_author) + pub fn new(id_author: String, name: String) -> Self { + AuthorFilterState { + id: id_author, + name, + } } } -#[derive(Default, Clone, Debug)] -pub struct ArtistFilterState(String); +#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ArtistFilterState { + pub id: String, + pub name: String, +} impl ArtistFilterState { pub fn new(id_artist: String) -> Self { - ArtistFilterState(id_artist) + ArtistFilterState { + id: id_artist, + name: "".to_string(), + } + } + + pub fn with_name(mut self, name: &str) -> Self { + self.name = name.to_string(); + self } } -#[derive(Default, Clone, Debug)] +#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct User(pub Vec); +impl Deref for User { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl IntoParam for User { fn into_param(self) -> String { if self.0.is_empty() { return String::new(); } self.0.into_iter().fold(String::new(), |mut ids, author| { - let _ = write!(ids, "&authors[]={}", author.0); + let _ = write!(ids, "&authors[]={}", author.id); ids }) } @@ -236,7 +280,7 @@ impl IntoParam for User { return String::new(); } self.0.into_iter().fold(String::new(), |mut ids, artist| { - let _ = write!(ids, "&artists[]={}", artist.0); + let _ = write!(ids, "&artists[]={}", artist.id); ids }) } @@ -269,7 +313,7 @@ impl IntoParam for Vec { } } -#[derive(Clone, Display, EnumIter, Debug)] +#[derive(Clone, Display, EnumIter, Debug, Serialize, Deserialize, PartialEq)] pub enum PublicationStatus { #[strum(to_string = "ongoing")] Ongoing, @@ -300,7 +344,7 @@ impl IntoParam for Vec { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Filters { pub content_rating: Vec, pub publication_status: Vec, @@ -458,8 +502,10 @@ mod test { #[test] fn filter_by_author_works() { - let sample_authors: Vec = - vec![AuthorFilterState::new("id_author1".to_string()), AuthorFilterState::new("id_author2".to_string())]; + let sample_authors: Vec = vec![ + AuthorFilterState::new("id_author1".to_string(), "".to_string()), + AuthorFilterState::new("id_author2".to_string(), "".to_string()), + ]; let filter_artist = User::::new(sample_authors); assert_eq!("&authors[]=id_author1&authors[]=id_author2", filter_artist.into_param()); } @@ -492,10 +538,12 @@ mod test { TagData { id: "id_tag_included".to_string(), state: TagSelection::Included, + name: "".to_string(), }, TagData { id: "id_tag_excluded".to_string(), state: TagSelection::Excluded, + name: "".to_string(), }, ]); @@ -513,9 +561,12 @@ mod test { let mut filters = Filters::default(); - filters.set_tags(vec![TagData::new("id_1".to_string(), TagSelection::Included)]); + filters.set_tags(vec![TagData::new("id_1".to_string(), TagSelection::Included, "".to_string())]); - filters.set_authors(vec![AuthorFilterState::new("id_1".to_string()), AuthorFilterState::new("id_2".to_string())]); + filters.set_authors(vec![ + AuthorFilterState::new("id_1".to_string(), "".to_string()), + AuthorFilterState::new("id_2".to_string(), "".to_string()), + ]); filters.set_languages(vec![Languages::French, Languages::Spanish]); diff --git a/src/backend/manga_provider/mangadex/filters/filter_provider.rs b/src/backend/manga_provider/mangadex/filters/filter_provider.rs index 166a99df..0e9471ce 100644 --- a/src/backend/manga_provider/mangadex/filters/filter_provider.rs +++ b/src/backend/manga_provider/mangadex/filters/filter_provider.rs @@ -3,6 +3,8 @@ use std::marker::PhantomData; use crossterm::event::{KeyCode, KeyEvent}; use manga_tui::SearchTerm; +use ratatui::style::Stylize; +use ratatui::text::Span; use ratatui::widgets::*; use strum::{Display, IntoEnumIterator}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; @@ -11,10 +13,11 @@ use tui_input::backend::crossterm::EventHandler; use super::super::{API_URL_BASE, COVER_IMG_URL_BASE}; use crate::backend::cache::in_memory::InMemoryCache; -use crate::backend::manga_provider::mangadex::MangadexClient; +use crate::backend::manga_provider::filters::FiltersCache; use crate::backend::manga_provider::mangadex::api_responses::authors::AuthorsResponse; use crate::backend::manga_provider::mangadex::api_responses::tags::TagsResponse; use crate::backend::manga_provider::mangadex::filters::api_parameter::*; +use crate::backend::manga_provider::mangadex::{MANGADEX_CACHE_BASE_DIRECTORY, MANGADEX_CACHE_FILENAME, MangadexClient}; use crate::backend::manga_provider::{EventHandler as FiltersEventHandler, FiltersHandler, Languages}; use crate::backend::tui::Events; @@ -53,7 +56,7 @@ pub const FILTERS: [MangaFilters; 8] = [ MangaFilters::Artists, ]; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct FilterListItem { pub is_selected: bool, pub name: String, @@ -65,25 +68,59 @@ impl FilterListItem { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct ContentRatingState; -#[derive(Debug)] + +#[derive(Debug, PartialEq, Eq)] pub struct PublicationStatusState; -#[derive(Debug)] + +#[derive(Debug, PartialEq, Eq)] pub struct SortByState; -#[derive(Debug)] + +#[derive(Debug, PartialEq, Eq)] pub struct MagazineDemographicState; -#[derive(Debug)] + +#[derive(Debug, PartialEq, Eq)] pub struct LanguageState; -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct FilterList { pub items: Vec, pub state: ListState, _state: PhantomData, } +struct FilterListIter<'a> { + items: &'a [FilterListItem], + index: usize, +} + +impl<'a> FilterListIter<'a> { + fn new(filter_list: &'a FilterList) -> Self { + Self { + items: &filter_list.items, + index: 0, + } + } +} + +impl<'a> Iterator for FilterListIter<'a> { + type Item = &'a FilterListItem; + + fn next(&mut self) -> Option { + let next = self.items.get(self.index); + + self.index += 1; + + next + } +} + impl FilterList { + fn iter(&self) -> FilterListIter<'_> { + FilterListIter::new(self) + } + pub fn toggle(&mut self) { if let Some(index) = self.state.selected() { if let Some(content_rating) = self.items.get_mut(index) { @@ -116,24 +153,27 @@ impl FilterList { impl Default for FilterList { fn default() -> Self { Self { - items: vec![ - FilterListItem { - is_selected: true, - name: ContentRating::Safe.to_string(), - }, - FilterListItem { - is_selected: false, - name: ContentRating::Suggestive.to_string(), - }, - FilterListItem { - is_selected: false, - name: ContentRating::Erotic.to_string(), - }, - FilterListItem { - is_selected: false, - name: ContentRating::Pornographic.to_string(), - }, - ], + items: ContentRating::iter() + .map(|rating| FilterListItem { + is_selected: rating == ContentRating::default(), + name: rating.to_string(), + }) + .collect(), + state: ListState::default(), + _state: PhantomData::, + } + } +} + +impl FilterList { + fn from_content_ratings(content_ratings: &[ContentRating]) -> Self { + Self { + items: ContentRating::iter() + .map(|rating| FilterListItem { + is_selected: content_ratings.contains(&rating), + name: rating.to_string(), + }) + .collect(), state: ListState::default(), _state: PhantomData::, } @@ -155,6 +195,21 @@ impl Default for FilterList { } } +impl FilterList { + fn from_sort_by(cached_sort_by: &SortBy) -> Self { + let sort_by_items = SortBy::iter().map(|sort_by_elem| FilterListItem { + is_selected: sort_by_elem == *cached_sort_by, + name: sort_by_elem.to_string(), + }); + + Self { + items: sort_by_items.collect(), + state: ListState::default(), + _state: PhantomData::, + } + } +} + impl Default for FilterList { fn default() -> Self { let items = MagazineDemographic::iter().map(|mag| FilterListItem { @@ -169,6 +224,20 @@ impl Default for FilterList { } } +impl FilterList { + fn from_magazine_demographic(magazine_demographic: &[MagazineDemographic]) -> Self { + let items = MagazineDemographic::iter().map(|mag| FilterListItem { + name: mag.to_string(), + is_selected: magazine_demographic.contains(&mag), + }); + Self { + items: items.collect(), + state: ListState::default(), + _state: PhantomData, + } + } +} + impl Default for FilterList { fn default() -> Self { let items = PublicationStatus::iter().map(|status| FilterListItem { @@ -183,6 +252,20 @@ impl Default for FilterList { } } +impl FilterList { + fn from_publication_status(publication_statuses: &[PublicationStatus]) -> Self { + let items = PublicationStatus::iter().map(|status| FilterListItem { + is_selected: publication_statuses.contains(&status), + name: status.to_string(), + }); + Self { + items: items.collect(), + state: ListState::default(), + _state: PhantomData, + } + } +} + impl FilterList { pub fn toggle_sort_by(&mut self) { for item in self.items.iter_mut() { @@ -212,6 +295,21 @@ impl Default for FilterList { } } +impl FilterList { + fn from_languages(from_languages: &[Languages]) -> Self { + let items = Languages::iterate().map(|lang| FilterListItem { + name: format!("{} {}", lang.as_emoji(), lang.as_human_readable()), + is_selected: from_languages.contains(&lang), + }); + + Self { + items: items.collect(), + state: ListState::default(), + _state: PhantomData, + } + } +} + #[derive(Clone, Debug)] pub struct ListItemId { pub id: String, @@ -245,6 +343,56 @@ impl SendEventOnSuccess for ArtistState { } } +impl FilterListDynamic { + fn from_authors(authors: &User) -> Self { + Self { + items: if authors.is_empty() { + None + } else { + Some( + authors + .iter() + .map(|author| ListItemId { + is_selected: true, + id: author.id.to_string(), + name: author.name.to_string(), + }) + .collect(), + ) + }, + state: ListState::default(), + search_bar: Input::default(), + _is_found: true, + _state: PhantomData, + } + } +} + +impl FilterListDynamic { + fn from_artist(artists: &User) -> Self { + Self { + items: if artists.is_empty() { + None + } else { + Some( + artists + .iter() + .map(|artist| ListItemId { + is_selected: true, + id: artist.id.to_string(), + name: artist.name.to_string(), + }) + .collect(), + ) + }, + state: ListState::default(), + search_bar: Input::default(), + _is_found: true, + _state: PhantomData, + } + } +} + impl SendEventOnSuccess for AuthorState { fn send(data: Option) -> FilterEvents { FilterEvents::LoadAuthors(data) @@ -323,6 +471,15 @@ pub enum TagListItemState { NotSelected, } +impl From for TagListItemState { + fn from(value: TagSelection) -> Self { + match value { + TagSelection::Included => Self::Included, + TagSelection::Excluded => Self::Excluded, + } + } +} + #[derive(Default, Clone, Debug)] pub struct TagListItem { pub id: String, @@ -342,6 +499,14 @@ impl TagListItem { } } + pub fn set_filter_tags_style(&self) -> Span<'_> { + match self.state { + TagListItemState::Included => format!(" {} ", self.name).black().on_green(), + TagListItemState::Excluded => format!(" {} ", self.name).black().on_red(), + TagListItemState::NotSelected => Span::from(self.name.clone()), + } + } + pub fn toggle_exclude(&mut self) { match self.state { TagListItemState::NotSelected | TagListItemState::Included => { @@ -361,15 +526,49 @@ pub struct TagsState { pub filter_input: Input, } +pub struct TagsStateIter<'a> { + tags: Option<&'a [TagListItem]>, + current: usize, +} + +impl<'a> TagsStateIter<'a> { + pub fn new(tags: Option<&'a [TagListItem]>) -> Self { + Self { tags, current: 0 } + } +} + +impl<'a> Iterator for TagsStateIter<'a> { + type Item = &'a TagListItem; + + fn next(&mut self) -> Option { + self.tags.as_ref().and_then(|tags| { + let next = tags.get(self.current); + self.current += 1; + next + }) + } +} + +impl From<&Tags> for TagsState { + fn from(value: &Tags) -> Self { + Self { + tags: if value.is_empty() { None } else { Some(value.iter().map(TagListItem::from).collect()) }, + ..Default::default() + } + } +} + impl TagsState { + pub fn iter(&self) -> TagsStateIter<'_> { + TagsStateIter::new(self.tags.as_deref()) + } + pub fn num_filters_active(&self) -> usize { - match self.tags.as_ref() { - Some(tags) => tags - .iter() + self.tags.as_ref().map_or(0, |tags| { + tags.iter() .filter(|tag| tag.state == TagListItemState::Included || tag.state == TagListItemState::Excluded) - .count(), - None => 0, - } + .count() + }) } pub fn is_filter_empty(&mut self) -> bool { @@ -434,6 +633,7 @@ pub struct MangadexFilterProvider { pub publication_status: FilterList, pub sort_by_state: FilterList, pub magazine_demographic: FilterList, + already_existings_tags: Option, pub tags_state: TagsState, pub author_state: FilterListDynamic, pub artist_state: FilterListDynamic, @@ -444,6 +644,38 @@ pub struct MangadexFilterProvider { rx: UnboundedReceiver, } +impl From for MangadexFilterProvider { + fn from(filters: Filters) -> Self { + let (tx, rx) = mpsc::unbounded_channel::(); + tx.send(FilterEvents::SearchTags).ok(); + + let already_existings_tags = if filters.tags.is_empty() { None } else { Some(filters.tags.clone()) }; + + Self { + is_open: false, + id_filter: 0, + content_rating: FilterList::::from_content_ratings(&filters.content_rating), + sort_by_state: FilterList::::from_sort_by(&filters.sort_by), + publication_status: FilterList::::from_publication_status(&filters.publication_status), + tags_state: TagsState::from(&filters.tags), + magazine_demographic: FilterList::::from_magazine_demographic(&filters.magazine_demographic), + author_state: FilterListDynamic::::from_authors(&filters.authors), + artist_state: FilterListDynamic::::from_artist(&filters.artists), + lang_state: FilterList::::from_languages(filters.languages.as_ref()), + already_existings_tags, + api_client: MangadexClient::new( + API_URL_BASE.parse().unwrap(), + COVER_IMG_URL_BASE.parse().unwrap(), + InMemoryCache::init(2), + ), + is_typing: false, + tx, + rx, + filters, + } + } +} + impl FiltersEventHandler for MangadexFilterProvider { fn handle_events(&mut self, events: Events) { match events { @@ -458,47 +690,44 @@ impl FiltersHandler for MangadexFilterProvider { type InnerState = Filters; fn toggle(&mut self) { + if self.is_open { + self.save_filters_on_close(); + } + self.is_open = !self.is_open; } + #[inline] fn is_typing(&self) -> bool { self.is_typing } + #[inline] fn is_open(&self) -> bool { self.is_open } + #[inline] fn get_state(&self) -> &Self::InnerState { &self.filters } } impl MangadexFilterProvider { - pub fn new() -> Self { - let (tx, rx) = mpsc::unbounded_channel::(); - tx.send(FilterEvents::SearchTags).ok(); - Self { - is_open: false, - id_filter: 0, - filters: Filters::default(), - content_rating: FilterList::::default(), - publication_status: FilterList::::default(), - sort_by_state: FilterList::::default(), - tags_state: TagsState::default(), - magazine_demographic: FilterList::::default(), - author_state: FilterListDynamic::::default(), - artist_state: FilterListDynamic::::default(), - lang_state: FilterList::::default(), - api_client: MangadexClient::new( - API_URL_BASE.parse().unwrap(), - COVER_IMG_URL_BASE.parse().unwrap(), - InMemoryCache::init(2), - ), - is_typing: false, - tx, - rx, - } + fn save_filters_on_close(&self) { + let filters_cache_writer = FiltersCache::new(&*MANGADEX_CACHE_BASE_DIRECTORY, MANGADEX_CACHE_FILENAME); + + filters_cache_writer + .write_to_cache(&self.filters) + .inspect_err(|e| { + #[cfg(not(test))] + { + use crate::backend::error_log::{ErrorType, write_to_error_log}; + + write_to_error_log(ErrorType::String(&e.to_string())); + } + }) + .ok(); } pub fn reset(&mut self) { @@ -777,9 +1006,17 @@ impl MangadexFilterProvider { .data .into_iter() .map(|data| TagListItem { - id: data.id, + id: data.id.to_string(), name: data.attributes.name.en, - state: TagListItemState::default(), + state: self + .already_existings_tags + .as_ref() + .and_then(|tags| { + let found_tag = tags.iter().find(|tag| tag.id == data.id); + + found_tag.map(|existing_tag| TagListItemState::from(existing_tag.state)) + }) + .unwrap_or_default(), }) .collect(); @@ -847,7 +1084,7 @@ impl MangadexFilterProvider { .iter() .filter_map(|item| { if item.is_selected { - return Some(AuthorFilterState::new(item.id.to_string())); + return Some(AuthorFilterState::new(item.id.to_string(), item.name.clone())); } None }) @@ -863,7 +1100,7 @@ impl MangadexFilterProvider { .iter() .filter_map(|item| { if item.is_selected { - return Some(ArtistFilterState::new(item.id.to_string())); + return Some(ArtistFilterState::new(item.id.to_string()).with_name(&item.name)); } None }) @@ -918,6 +1155,8 @@ impl MangadexFilterProvider { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use super::*; use crate::backend::manga_provider::mangadex::authors::Data; use crate::backend::manga_provider::mangadex::tags::TagsData; @@ -1082,7 +1321,7 @@ mod tests { #[test] fn filter_state() { - let mut filter_state = MangadexFilterProvider::new(); + let mut filter_state = MangadexFilterProvider::from(Filters::default()); filter_state.is_open = true; @@ -1151,4 +1390,108 @@ mod tests { assert!(!filter_state.is_open); } + + #[test] + fn filter_provider_is_initialized_from_filters() { + let filters: Filters = Filters { + content_rating: vec![ContentRating::Suggestive, ContentRating::Erotic], + publication_status: vec![PublicationStatus::Completed, PublicationStatus::Ongoing], + sort_by: SortBy::HighestRating, + tags: Tags::new(vec![TagData::new("id_tag".to_string(), TagSelection::Included, "fantasy".to_string())]), + magazine_demographic: vec![MagazineDemographic::Shoujo, MagazineDemographic::Seinen], + authors: User::new(vec![AuthorFilterState::new("author_id".to_string(), "name_author".to_string())]), + artists: User::new(vec![ArtistFilterState::new("artist_id".to_string()).with_name("artist_name")]), + languages: vec![Languages::English, Languages::Spanish], + }; + + let filters_provider = MangadexFilterProvider::from(filters); + + let expected_content_rating: FilterList = FilterList { + items: vec![ + FilterListItem { + is_selected: false, + name: ContentRating::Safe.to_string(), + }, + FilterListItem { + is_selected: true, + name: ContentRating::Suggestive.to_string(), + }, + FilterListItem { + is_selected: true, + name: ContentRating::Erotic.to_string(), + }, + FilterListItem { + is_selected: false, + name: ContentRating::Pornographic.to_string(), + }, + ], + state: ListState::default(), + _state: PhantomData, + }; + + assert_eq!(expected_content_rating, filters_provider.content_rating); + + filters_provider + .sort_by_state + .iter() + .find(|item| item.is_selected && item.name == SortBy::HighestRating.to_string()) + .expect("sort_by state is not the one that should be selected"); + + let num_publication_status_expected = filters_provider + .publication_status + .iter() + .filter_map(|item| { + if item.is_selected + && (item.name == PublicationStatus::Ongoing.to_string() + || item.name == PublicationStatus::Completed.to_string()) + { + Some(item) + } else { + None + } + }) + .count(); + + assert_eq!(num_publication_status_expected, 2); + + let num_languages_expected = filters_provider + .lang_state + .iter() + .filter_map(|lan| lan.is_selected.then_some(lan)) + .count(); + + assert_eq!(num_languages_expected, 2); + + filters_provider + .tags_state + .iter() + .find(|tag| tag.id == "id_tag") + .expect("tag state was not initialized correctly"); + + let num_magazine_demographic_expected = filters_provider + .magazine_demographic + .iter() + .filter_map(|magazine| magazine.is_selected.then_some(magazine)) + .count(); + + assert_eq!(num_magazine_demographic_expected, 2); + + filters_provider + .artist_state + .items + .as_ref() + .unwrap() + .iter() + .find(|artist| artist.id == "artist_id" && artist.name == "artist_name") + .expect("Expected artist was not found"); + + filters_provider + .author_state + .items + .as_ref() + .unwrap() + .iter() + .find(|author| author.id == "author_id" && author.name == "name_author") + .expect("Expected author was not found"); + } } diff --git a/src/backend/manga_provider/weebcentral.rs b/src/backend/manga_provider/weebcentral.rs index 8ea629d7..ad80d861 100644 --- a/src/backend/manga_provider/weebcentral.rs +++ b/src/backend/manga_provider/weebcentral.rs @@ -1,15 +1,12 @@ use std::error::Error; -use std::fmt::Write; -use std::path::Path; use std::sync::Arc; use std::time::Duration; use filter_state::{WeebcentralFilterState, WeebcentralFiltersProvider}; use filter_widget::WeebcentralFilterWidget; use http::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, HOST, REFERER}; -use http::{HeaderMap, HeaderValue, StatusCode, status}; +use http::{HeaderMap, HeaderValue, StatusCode}; use manga_tui::SearchTerm; -use reqwest::cookie::Jar; use reqwest::{Client, Url}; use response::{ ChapterPageData, ChapterPagesLinks, LatestMangas, MangaPageData, PopularMangasWeebCentral, SearchPageMangas, @@ -17,9 +14,9 @@ use response::{ }; use super::{ - Author, Chapter, ChapterFilters, ChapterOrderBy, ChapterPageUrl, DecodeBytesToImage, FeedPageProvider, FetchChapterBookmarked, - Genres, GetChapterPages, GetChaptersResponse, GetMangasResponse, GetRawImage, GoToReadChapter, HomePageMangaProvider, - Languages, LatestChapter, ListOfChapters, Manga, MangaPageProvider, MangaProvider, MangaProviders, Pagination, PopularManga, + Chapter, ChapterFilters, ChapterOrderBy, ChapterPageUrl, DecodeBytesToImage, FeedPageProvider, FetchChapterBookmarked, + GetChapterPages, GetChaptersResponse, GetMangasResponse, GetRawImage, GoToReadChapter, HomePageMangaProvider, Languages, + LatestChapter, ListOfChapters, Manga, MangaPageProvider, MangaProvider, MangaProviders, Pagination, PopularManga, ProviderIdentity, ReaderPageProvider, RecentlyAddedManga, SearchChapterById, SearchMangaById, SearchMangaPanel, SearchPageProvider, }; diff --git a/src/backend/manga_provider/weebcentral/response.rs b/src/backend/manga_provider/weebcentral/response.rs index 97552e08..f3f10fa3 100644 --- a/src/backend/manga_provider/weebcentral/response.rs +++ b/src/backend/manga_provider/weebcentral/response.rs @@ -1,22 +1,16 @@ -use std::collections::HashMap; use std::error::Error; use std::fmt::{Display, Write}; -use std::iter; -use std::num::ParseIntError; use std::path::Path; use chrono::NaiveDate; -use image::GenericImageView; -use regex::Regex; use scraper::selectable::Selectable; -use scraper::{ElementRef, Selector, html}; -use serde::{Deserialize, Serialize}; +use scraper::{ElementRef, html}; use crate::backend::html_parser::scraper::AsSelector; use crate::backend::html_parser::{HtmlElement, ParseHtml}; use crate::backend::manga_provider::{ - Author, Chapter, ChapterPageUrl, ChapterReader, Genres, GetChaptersResponse, GetMangasResponse, Languages, ListOfChapters, - Manga, MangaStatus, PopularManga, Rating, RecentlyAddedManga, SearchManga, SortedChapters, SortedVolumes, Volumes, + Author, ChapterPageUrl, ChapterReader, Genres, GetMangasResponse, Languages, ListOfChapters, Manga, MangaStatus, PopularManga, + Rating, RecentlyAddedManga, SearchManga, SortedChapters, SortedVolumes, Volumes, }; #[derive(Debug)] @@ -798,10 +792,7 @@ impl From for GetMangasResponse { mod tests { use std::error::Error; - use fake::rand::seq::IndexedRandom; use pretty_assertions::assert_eq; - use reqwest::Url; - use scraper::Html; use super::*; use crate::backend::html_parser::{HtmlElement, ParseHtml}; diff --git a/src/backend/secrets/keyring.rs b/src/backend/secrets/keyring.rs index 06a5fcbe..211a866d 100644 --- a/src/backend/secrets/keyring.rs +++ b/src/backend/secrets/keyring.rs @@ -1,9 +1,5 @@ -use std::error::Error; -use std::string; - use clap::crate_name; use keyring::Entry; -use strum::Display; use super::SecretStorage; diff --git a/src/cli.rs b/src/cli.rs index ffa2612a..dd40f9ef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::error::Error; -use std::fmt::Display; use std::future::Future; use std::io::BufRead; use std::process::exit; diff --git a/src/config.rs b/src/config.rs index bc96568f..77b7b2e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,13 +4,11 @@ //! config file creation, updating, and reading. It supports default values, //! table parameters, and ensures the config file is always up-to-date with //! the latest parameters. - use std::error::Error; use std::fmt::Write as FmtWrite; use std::fs::{File, OpenOptions, create_dir_all}; -use std::io::{Cursor, Read, Seek, Write}; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::sync::LazyLock; use manga_tui::exists; @@ -19,10 +17,8 @@ use serde::{Deserialize, Serialize}; use strum::{Display, EnumIter}; use toml::Table; -use crate::backend::AppDirectories; use crate::backend::manga_provider::MangaProviders; use crate::cli::Credentials; -use crate::logger::{DefaultLogger, ILogger}; static CONFIG_FILE_NAME: &str = "config.toml"; @@ -662,11 +658,11 @@ pub fn read_config_file() -> Result> { #[cfg(test)] mod tests { - use std::fmt::{Debug, Write as FmtWrite}; use std::fs; - use std::io::{Cursor, Write}; + use std::io::Cursor; + use std::str::FromStr; - use pretty_assertions::{assert_eq, assert_str_eq}; + use pretty_assertions::assert_eq; use super::*; diff --git a/src/main.rs b/src/main.rs index 8d551136..66207823 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ use self::backend::migration::update_database_with_migrations; use self::backend::tui::run_app; use self::cli::CliArgs; use self::config::MangaTuiConfig; +use crate::backend::manga_provider::mangadex::get_cached_filters; mod backend; mod cli; @@ -149,8 +150,14 @@ async fn main() -> Result<(), Box> { }, } - run_app(ratatui::init(), mangadex_client, anilist_client, MangadexFilterProvider::new(), MangadexFilterWidget::new()) - .await?; + run_app( + ratatui::init(), + mangadex_client, + anilist_client, + MangadexFilterProvider::from(get_cached_filters()), + MangadexFilterWidget::new(), + ) + .await?; }, MangaProviders::Weebcentral => { logger.inform("Using Weeb central as manga provider"); @@ -160,7 +167,7 @@ async fn main() -> Result<(), Box> { WeebcentralProvider::new(WEEBCENTRAL_BASE_URL.parse().unwrap(), cache_provider), anilist_client, WeebcentralFiltersProvider::new(WeebcentralFilterState::default()), - WeebcentralFilterWidget {}, + WeebcentralFilterWidget::new(), ) .await?; }, diff --git a/src/utils.rs b/src/utils.rs index 0271b314..db18d692 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,8 +1,8 @@ use chrono::NaiveDate; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Style, Stylize}; -use ratatui::text::{Line, Span}; +use ratatui::style::{Color, Style}; +use ratatui::text::Line; use ratatui::widgets::{Block, Paragraph, Widget}; use tui_input::Input;