Refactor structure
This commit is contained in:
79
src/app.rs
79
src/app.rs
@@ -1,79 +0,0 @@
|
||||
use crate::config::types::ApplicationConfig;
|
||||
use crate::event::{AppEvent, EventHandler};
|
||||
use crate::widgets::views::{AppView, MainView};
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::{Event};
|
||||
use ratatui::{DefaultTerminal, Frame};
|
||||
use std::time::Duration;
|
||||
use color_eyre::eyre::eyre;
|
||||
|
||||
pub(crate) struct App {
|
||||
events: EventHandler,
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
view: Option<AppView>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub async fn create() -> Result<Self> {
|
||||
let config = ApplicationConfig::get_config()?;
|
||||
let state = AppState {
|
||||
view: Some(AppView::Main(MainView::new()?)),
|
||||
};
|
||||
let app = Self {
|
||||
events: EventHandler::new(Duration::from_millis(config.basic_config.tick_rate)),
|
||||
state,
|
||||
};
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
pub async fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||
loop {
|
||||
let event = self.events.next().await?;
|
||||
self.update(event)?;
|
||||
let Some(current_view) = self.state.view.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let view = current_view.get_view();
|
||||
if !view.is_running() { break Ok(()) }
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, event: AppEvent) -> Result<()> {
|
||||
if let AppEvent::Raw(cross_event) = event {
|
||||
self.handle_event(&cross_event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, key: &Event) -> Result<()> {
|
||||
let Some(current_view) = self.state.view.as_mut() else {
|
||||
return Err(eyre!("there is no view"));
|
||||
};
|
||||
let view = current_view.get_view();
|
||||
view.handle_input(key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let Some(current_view) = self.state.view.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let view = current_view.get_view();
|
||||
if let Some(pos) = view.screen_cursor() {
|
||||
frame.set_cursor_position(pos);
|
||||
}
|
||||
match current_view {
|
||||
AppView::Main(main_view) => {
|
||||
frame.render_stateful_widget(
|
||||
MainView::new().unwrap(),
|
||||
frame.area(),
|
||||
&mut main_view.state,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::{Args, Command, Parser, Subcommand};
|
||||
use color_eyre::eyre::eyre;
|
||||
use crate::config::types::ApplicationConfig;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub(super) struct FolderAddCommand {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub(super) enum FolderSubCommand {
|
||||
Add(FolderAddCommand),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub(super) struct FolderCommand {
|
||||
#[command(subcommand)]
|
||||
pub(super) subcommand: FolderSubCommand,
|
||||
}
|
||||
|
||||
impl Subcommand for FolderCommand {
|
||||
fn augment_subcommands(cmd: Command) -> Command {
|
||||
cmd.subcommand(FolderAddCommand::augment_args(Command::new("add")))
|
||||
.subcommand_required(true)
|
||||
}
|
||||
|
||||
fn augment_subcommands_for_update(cmd: Command) -> Command {
|
||||
cmd.subcommand(FolderAddCommand::augment_args(Command::new("add")))
|
||||
.subcommand_required(true)
|
||||
}
|
||||
|
||||
fn has_subcommand(name: &str) -> bool {
|
||||
matches!(name, "add")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl FolderSubCommand {
|
||||
pub async fn handle(&self) -> color_eyre::Result<()> {
|
||||
match self {
|
||||
FolderSubCommand::Add(cmd) => cmd.handle().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl FolderAddCommand {
|
||||
pub async fn handle(&self) -> color_eyre::Result<()> {
|
||||
let mut config = ApplicationConfig::get_config()?;
|
||||
let path = PathBuf::from(&self.path);
|
||||
let abs_path = path.canonicalize()?;
|
||||
if !abs_path.is_dir() {
|
||||
return Err(eyre!("{:?} is not a directory", abs_path));
|
||||
}
|
||||
config
|
||||
.path_config
|
||||
.dlsite_paths
|
||||
.push(abs_path.to_str().unwrap().to_string());
|
||||
config.save()?;
|
||||
println!("Added {:?} to path config", abs_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
mod folder;
|
||||
mod sync;
|
||||
|
||||
use crate::{app, helpers};
|
||||
use clap::{command, Parser};
|
||||
use color_eyre::Result;
|
||||
use ratatui::crossterm;
|
||||
use crate::cli::folder::FolderCommand;
|
||||
use crate::cli::sync::DLSiteCommand;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
enum CliSubCommand {
|
||||
Folder(FolderCommand),
|
||||
#[command(name = "dlsite")]
|
||||
DLSite(DLSiteCommand),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
pub(crate) struct Cli {
|
||||
#[command(subcommand)]
|
||||
subcommand: Option<CliSubCommand>,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub async fn run(&self) -> Result<()> {
|
||||
helpers::initialize_folders().await?;
|
||||
if self.subcommand.is_none() {
|
||||
return self.start_tui().await;
|
||||
}
|
||||
if let Some(sub_command) = &self.subcommand {
|
||||
return sub_command.handle().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_tui(&self) -> Result<()> {
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
let app = app::App::create().await?;
|
||||
let result = app.run(&mut terminal).await;
|
||||
ratatui::restore();
|
||||
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl CliSubCommand {
|
||||
pub async fn handle(&self) -> Result<()> {
|
||||
match self {
|
||||
CliSubCommand::Folder(cmd) => cmd.subcommand.handle().await,
|
||||
CliSubCommand::DLSite(cmd) => cmd.subcommand.handle().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
194
src/cli/sync.rs
194
src/cli/sync.rs
@@ -1,194 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use clap::{Parser};
|
||||
use color_eyre::eyre::{Result};
|
||||
use crossterm::style::{style, Stylize};
|
||||
use futures::StreamExt;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use itertools::Itertools;
|
||||
use tokio::time::Instant;
|
||||
use crate::models::{DLSiteCategory, DLSiteGenre, DLSiteManiax, DLSiteTranslation};
|
||||
use crate::config::types::ApplicationConfig;
|
||||
use crate::constants::{DB_CF_OPTIONS, DB_OPTIONS};
|
||||
use crate::crawler::{dlsite, DLSiteCrawler};
|
||||
use crate::helpers;
|
||||
use crate::helpers::db::RocksDB;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub(super) struct DLSiteCommand {
|
||||
#[command(subcommand)]
|
||||
pub(super) subcommand: DLSiteSubCommand,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub(super) enum DLSiteSubCommand {
|
||||
#[command(name = "sync")]
|
||||
Sync(DLSiteSyncCommand)
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub(super) struct DLSiteSyncCommand {
|
||||
#[clap(long, short, action)]
|
||||
missing: bool,
|
||||
#[clap(long = "genre", default_value = "false")]
|
||||
do_sync_genre: bool,
|
||||
#[clap(long = "work", default_value = "true")]
|
||||
do_sync_work: bool
|
||||
}
|
||||
|
||||
impl DLSiteSubCommand {
|
||||
pub async fn handle(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::Sync(cmd) => cmd.handle().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DLSiteSyncCommand {
|
||||
pub async fn handle(&self) -> Result<()> {
|
||||
let now = Instant::now();
|
||||
let app_conf = ApplicationConfig::get_config()?;
|
||||
let mut db = RocksDB::default();
|
||||
let crawler = DLSiteCrawler::new()?;
|
||||
if self.do_sync_genre {
|
||||
let genre_now = Instant::now();
|
||||
Self::sync_genres(&mut db, &app_conf, &crawler).await?;
|
||||
println!(
|
||||
"{} {} Done in {:.2?}",
|
||||
style("Genres").cyan(),
|
||||
style("Syncing").green(),
|
||||
genre_now.elapsed()
|
||||
);
|
||||
}
|
||||
if self.do_sync_work {
|
||||
let work_now = Instant::now();
|
||||
self.sync_works(&app_conf, &mut db, &crawler).await?;
|
||||
println!(
|
||||
"{} {} Done in {:.2?}",
|
||||
style("Works").cyan(),
|
||||
style("Syncing").green(),
|
||||
work_now.elapsed()
|
||||
);
|
||||
}
|
||||
println!("{} Done in {:.2?}", style("Syncing").green(), now.elapsed());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_genres(db: &mut RocksDB, app_conf: &ApplicationConfig, crawler: &DLSiteCrawler) -> Result<()> {
|
||||
let requested_categories = crawler.get_all_genres(&app_conf.basic_config.locale).await?;
|
||||
let categories: Vec<DLSiteCategory> = requested_categories.iter()
|
||||
.map(|g| g.clone().try_into())
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
db.set_values(&categories)?;
|
||||
let genres = requested_categories.into_iter()
|
||||
.flat_map(|v| v.values)
|
||||
.collect_vec();
|
||||
let existing_genres = db.get_all_values::<DLSiteGenre>()?;
|
||||
let mut modified_genres: Vec<DLSiteGenre> = Vec::new();
|
||||
for genre in genres {
|
||||
let id = genre.value.parse::<u16>()?;
|
||||
let existing_genre =
|
||||
existing_genres.iter().find(|v| v.id == id);
|
||||
if let Some(existing_genre) = existing_genre {
|
||||
let name = DLSiteTranslation::try_from(genre.name)?;
|
||||
if existing_genre.name.contains(&name) {
|
||||
modified_genres.push(existing_genre.clone());
|
||||
continue;
|
||||
}
|
||||
let mut modified_genre = existing_genre.clone();
|
||||
modified_genre.name.push(name);
|
||||
modified_genres.push(modified_genre);
|
||||
}
|
||||
else {
|
||||
modified_genres.push(DLSiteGenre {
|
||||
id, name: vec![DLSiteTranslation::try_from(genre.name)?]
|
||||
});
|
||||
}
|
||||
}
|
||||
db.set_values(&modified_genres)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_works(&self, app_conf: &ApplicationConfig, db: &mut RocksDB, crawler: &DLSiteCrawler) -> Result<()> {
|
||||
let existing_works = db.get_all_values::<DLSiteManiax>()?;
|
||||
|
||||
let work_list = self.get_work_list(&app_conf, &existing_works).await?;
|
||||
let rj_nums = work_list.clone().into_keys().collect::<Vec<_>>();
|
||||
|
||||
let mut game_infos = crawler.get_game_infos(rj_nums, &app_conf.basic_config.locale).await?;
|
||||
let existing_game_infos = db.get_all_values::<DLSiteManiax>()?;
|
||||
let mut modified_maniaxes: Vec<DLSiteManiax> = Vec::new();
|
||||
|
||||
let progress = ProgressBar::new(game_infos.len() as u64)
|
||||
.with_style(ProgressStyle::default_bar());
|
||||
while let Some(info) = game_infos.next().await {
|
||||
let maniax = info?;
|
||||
let existing_maniax = existing_game_infos.iter()
|
||||
.find(|v| v.rj_num == maniax.rj_num);
|
||||
if let Some(existing_maniax) = existing_maniax {
|
||||
let name = DLSiteTranslation::try_from(maniax.title)?;
|
||||
if existing_maniax.name.contains(&name) {
|
||||
modified_maniaxes.push(existing_maniax.clone());
|
||||
continue;
|
||||
}
|
||||
let mut modified_maniax = existing_maniax.clone();
|
||||
modified_maniax.name.push(name);
|
||||
modified_maniaxes.push(modified_maniax);
|
||||
}
|
||||
else {
|
||||
let mut value: DLSiteManiax = maniax.into();
|
||||
let maniax_folder = work_list.get(&value.rj_num).unwrap().to_owned();
|
||||
value.folder_path = maniax_folder;
|
||||
modified_maniaxes.push(value);
|
||||
}
|
||||
progress.inc(1);
|
||||
}
|
||||
db.set_values(&modified_maniaxes)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_work_list(&self, app_conf: &ApplicationConfig, existing_works: &[DLSiteManiax]) -> Result<HashMap<String, PathBuf>> {
|
||||
let existing_nums = existing_works.iter()
|
||||
.map(|x| x.rj_num.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let existing_folders = existing_works.iter()
|
||||
.map(|x| x.folder_path.to_str().unwrap().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let mut works_list: HashMap<String, PathBuf> = HashMap::new();
|
||||
let config_paths = app_conf.path_config.dlsite_paths.iter()
|
||||
.map(|path| Path::new(path))
|
||||
.collect::<Vec<_>>();
|
||||
let dir_paths = helpers::get_all_folders(&config_paths).await?;
|
||||
for dir_path in dir_paths {
|
||||
if !dir_path.is_dir() {
|
||||
println!(
|
||||
"{} {}",
|
||||
style(dir_path.to_str().unwrap()).blue(),
|
||||
style("is not a directory").red()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let dir_path_str = dir_path.to_str().unwrap().to_string();
|
||||
let dir_name = dir_path
|
||||
.file_name().unwrap()
|
||||
.to_str().unwrap()
|
||||
.to_string();
|
||||
if !dlsite::is_valid_rj_number(&dir_name) && !existing_folders.contains(&dir_path_str) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style(dir_path.to_str().unwrap()).blue(),
|
||||
style("is not a valid rj number, please add it manually").red()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if self.missing && existing_nums.contains(&dir_name) {
|
||||
continue;
|
||||
}
|
||||
works_list.insert(dir_name, dir_path);
|
||||
}
|
||||
Ok(works_list)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use std::hash::RandomState;
|
||||
use directories::BaseDirs;
|
||||
use lazy_static::lazy_static;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use dashmap::DashMap;
|
||||
use language_tags::LanguageTag;
|
||||
use crate::models::{DLSiteCategory, DLSiteGenre, DLSiteManiax, RocksColumn};
|
||||
|
||||
const APP_DIR_NAME: &str = "sus_manager";
|
||||
lazy_static! {
|
||||
static ref BASE_DIRS: BaseDirs = BaseDirs::new().unwrap();
|
||||
pub static ref APP_CONFIG_DIR: PathBuf =
|
||||
BASE_DIRS.config_dir().to_path_buf().join(APP_DIR_NAME);
|
||||
pub static ref APP_DATA_DIR: PathBuf = BASE_DIRS.data_dir().to_path_buf().join(APP_DIR_NAME);
|
||||
pub static ref APP_CACHE_PATH: PathBuf = BASE_DIRS.cache_dir().to_path_buf().join(APP_DIR_NAME);
|
||||
pub static ref APP_CONIFG_FILE_PATH: PathBuf = APP_CONFIG_DIR.clone().join("config.json");
|
||||
pub static ref APP_DB_DATA_DIR: PathBuf = APP_DATA_DIR.clone().join("db");
|
||||
|
||||
pub static ref DB_OPTIONS: rocksdb::Options = get_db_options();
|
||||
pub static ref DB_CF_OPTIONS: rocksdb::Options = rocksdb::Options::default();
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref DB_COLUMNS: [String; 3] = [
|
||||
DLSiteManiax::get_column_name().to_string(),
|
||||
DLSiteGenre::get_column_name().to_string(),
|
||||
DLSiteCategory::get_column_name().to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref EN_LOCALE: LanguageTag = LanguageTag::parse("en").unwrap();
|
||||
pub static ref JP_LOCALE: LanguageTag = LanguageTag::parse("ja").unwrap();
|
||||
pub static ref SUPPORTED_LOCALES: [LanguageTag; 2] = [JP_LOCALE.clone(), EN_LOCALE.clone()];
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CACHE_MAP: Arc<DashMap<String, serde_json::Value>> =
|
||||
Arc::new(DashMap::with_hasher(RandomState::default()));
|
||||
}
|
||||
|
||||
fn get_db_options() -> rocksdb::Options {
|
||||
let mut opts = rocksdb::Options::default();
|
||||
|
||||
opts.create_missing_column_families(true);
|
||||
opts.create_if_missing(true);
|
||||
opts.increase_parallelism(num_cpus::get() as i32);
|
||||
|
||||
opts
|
||||
}
|
||||
|
||||
pub(crate) fn get_db_read_options() -> rocksdb::ReadOptions {
|
||||
let mut opts = rocksdb::ReadOptions::default();
|
||||
opts.set_async_io(true);
|
||||
opts
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{PathBuf};
|
||||
use color_eyre::eyre::eyre;
|
||||
use color_eyre::owo_colors::OwoColorize;
|
||||
use reqwest::{Url};
|
||||
use color_eyre::{Report, Result};
|
||||
use futures::stream::FuturesUnordered;
|
||||
use itertools::Itertools;
|
||||
use language_tags::LanguageTag;
|
||||
use lazy_static::lazy_static;
|
||||
use scraper::{Element, Html, Selector};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::constants::{APP_DATA_DIR, JP_LOCALE};
|
||||
use crate::crawler::Crawler;
|
||||
use crate::helpers::matches_primary_language;
|
||||
use crate::models::{PrimaryLanguage};
|
||||
|
||||
//TODO: override locale with user one
|
||||
const DLSITE_URL: &str = "https://www.dlsite.com/";
|
||||
const DLSITE_PRODUCT_API_ENDPOINT: &str = "/maniax/product/info/ajax";
|
||||
const DLSITE_FS_ENDPOINT: &str = "/maniax/fs/=/api_access/1/";
|
||||
const DLSITE_MANIAX_PATH: &str = "/maniax/work/=/product_id/";
|
||||
lazy_static! {
|
||||
pub static ref DLSITE_IMG_FOLDER: PathBuf = APP_DATA_DIR.clone().join("dlsite").join("img");
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DLSiteCrawler {
|
||||
crawler: Crawler
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub(crate) struct DLSiteManiax {
|
||||
#[serde(rename = "work_name")]
|
||||
pub(crate) title: String,
|
||||
#[serde(rename = "work_image")]
|
||||
pub(crate) work_image_url: String,
|
||||
#[serde(rename = "dl_count")]
|
||||
pub(crate) sells_count: u32,
|
||||
#[serde(skip)]
|
||||
pub(crate) genre_ids: Vec<u16>,
|
||||
#[serde(skip)]
|
||||
pub(crate) rj_num: String,
|
||||
#[serde(skip)]
|
||||
pub(crate) folder_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
struct DLSiteFilter {
|
||||
pub(crate) genre_all: Value
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub(crate) struct DLSiteGenreCategory {
|
||||
pub(crate) category_name: String,
|
||||
pub(crate) values: Vec<DLSiteGenre>,
|
||||
#[serde(skip)]
|
||||
pub(crate) id: u8
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub(crate) struct DLSiteGenre {
|
||||
pub(crate) value: String,
|
||||
pub(crate) name: String
|
||||
}
|
||||
|
||||
impl DLSiteCrawler {
|
||||
pub fn new() -> Result<Self> {
|
||||
let url = Url::parse(DLSITE_URL)?;
|
||||
let crawler = Self {
|
||||
crawler: Crawler::new(
|
||||
"DLSite",
|
||||
url
|
||||
)
|
||||
};
|
||||
Ok(crawler)
|
||||
}
|
||||
|
||||
pub async fn get_game_infos(&self, rj_nums: Vec<String>, locale: &LanguageTag) -> Result<FuturesUnordered<impl Future<Output=Result<DLSiteManiax, Report>>>>
|
||||
{
|
||||
let invalid_nums = rj_nums.iter()
|
||||
.filter(|&n| !is_valid_rj_number(n))
|
||||
.map(|n| n.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
if !invalid_nums.is_empty() {
|
||||
return Err(
|
||||
eyre!("Invalid numbers: {}", invalid_nums.join(", "))
|
||||
);
|
||||
}
|
||||
let primary_language: PrimaryLanguage = locale.try_into()?;
|
||||
let locale_str = match primary_language {
|
||||
PrimaryLanguage::EN => "en_US",
|
||||
PrimaryLanguage::JP => "ja_JP",
|
||||
};
|
||||
let query = &format!("product_id={}&locale={}", rj_nums.join(","), locale_str);
|
||||
let (value, _) = self.crawler
|
||||
.get_json::<Value>(DLSITE_PRODUCT_API_ENDPOINT, Some(query))
|
||||
.await?;
|
||||
// try to catch '[]' empty result from the api
|
||||
let value_downcast_result: Result<HashMap<String, DLSiteManiax>, _> = serde_json::from_value(value);
|
||||
let maniax_result = value_downcast_result.unwrap_or(HashMap::new());
|
||||
|
||||
Self::verify_all_works_exists(&maniax_result, rj_nums);
|
||||
|
||||
let tasks = FuturesUnordered::new();
|
||||
for (rj_num, mut info) in maniax_result {
|
||||
tasks.push(async move {
|
||||
let html_path = format!("{DLSITE_MANIAX_PATH}{rj_num}");
|
||||
let query = format!("locale={locale_str}");
|
||||
let (_, html_result) = tokio::join!(
|
||||
self.save_main_image(&info, &rj_num),
|
||||
self.crawler.get_html(&html_path, Some(&query))
|
||||
);
|
||||
let (html, _) = html_result?;
|
||||
let genres = self.get_work_genres(&html, locale.try_into()?).await?;
|
||||
info.genre_ids = genres;
|
||||
info.rj_num = rj_num;
|
||||
Ok::<DLSiteManiax, Report>(info)
|
||||
})
|
||||
}
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
fn verify_all_works_exists(maniax_result: &HashMap<String, DLSiteManiax>, rj_nums: Vec<String>) {
|
||||
let keys = maniax_result.keys()
|
||||
.map(|k| k.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
let keys_hash: HashSet<String> = HashSet::from_iter(keys);
|
||||
let nums_hash: HashSet<String> = HashSet::from_iter(rj_nums);
|
||||
let nums_diff = nums_hash.difference(&keys_hash)
|
||||
.map(|n| n.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
if !nums_diff.is_empty() {
|
||||
println!("Restricted/Removed Works: {}", nums_diff.join(", ").red());
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_main_image(&self, info: &DLSiteManiax, rj_num: &str) -> Result<()> {
|
||||
let img_file_name = format!("{rj_num}.jpg");
|
||||
let img_save_path = DLSITE_IMG_FOLDER.clone().join(img_file_name);
|
||||
if img_save_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url_string = format!("https:{}", info.work_image_url);
|
||||
let url = Url::parse(&url_string)?;
|
||||
let (img, _) = self.crawler.get_img(&url).await?;
|
||||
img.save(img_save_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_work_genres(&self, html: &Html, primary_language: PrimaryLanguage) -> Result<Vec<u16>> {
|
||||
let selector = Result::unwrap(
|
||||
Selector::parse(
|
||||
"#work_outline > tbody:nth-child(1)"
|
||||
)
|
||||
);
|
||||
let genre_str = match primary_language {
|
||||
PrimaryLanguage::EN => "Genre",
|
||||
PrimaryLanguage::JP => "ジャンル"
|
||||
};
|
||||
|
||||
let result = html.select(&selector).next().unwrap();
|
||||
let genre_rows = result.child_elements().collect::<Vec<_>>();
|
||||
let genre_row = genre_rows.iter()
|
||||
.find(|v| v.first_element_child().unwrap().text().next().unwrap() == genre_str)
|
||||
.unwrap();
|
||||
let data = genre_row
|
||||
.child_elements().skip(1).next().unwrap()
|
||||
.child_elements().next().unwrap();
|
||||
let genre_urls = data.child_elements()
|
||||
.map(|e| e.attr("href").unwrap())
|
||||
.map(|s| Url::parse(s).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let genre_ids = genre_urls.iter()
|
||||
.map(|x| {
|
||||
x.path_segments().unwrap()
|
||||
.skip(4).next().unwrap()
|
||||
.parse::<u16>().unwrap()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(genre_ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl DLSiteCrawler {
|
||||
pub async fn get_all_genres(&self, locale: &LanguageTag) -> Result<Vec<DLSiteGenreCategory>> {
|
||||
let primary_language: PrimaryLanguage = locale.try_into()?;
|
||||
let locale_str = match primary_language {
|
||||
PrimaryLanguage::EN => "en_US",
|
||||
PrimaryLanguage::JP => "ja_JP",
|
||||
};
|
||||
let query = format!("locale={}", locale_str);
|
||||
|
||||
let (json, _) = self.crawler.get_json::<DLSiteFilter>(DLSITE_FS_ENDPOINT, Some(&query)).await?;
|
||||
let values =
|
||||
if matches_primary_language(&locale, &JP_LOCALE) {
|
||||
serde_json::from_value::<Vec<DLSiteGenreCategory>>(json.genre_all)?
|
||||
} else {
|
||||
// IDK why they are using different object type bruh
|
||||
serde_json::from_value::<HashMap<u16, DLSiteGenreCategory>>(json.genre_all)?
|
||||
.into_iter().map(|(_, v)| v)
|
||||
.collect_vec()
|
||||
};
|
||||
|
||||
let mut categories = Vec::new();
|
||||
for (i, value) in values.into_iter().enumerate() {
|
||||
let mut category = value.clone();
|
||||
category.id = i as u8;
|
||||
categories.push(category);
|
||||
}
|
||||
Ok(categories)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid_rj_number(rj_num: &str) -> bool {
|
||||
let len = rj_num.len();
|
||||
if len != 8 && len != 10 {
|
||||
return false;
|
||||
}
|
||||
if !rj_num.starts_with("RJ") {
|
||||
return false;
|
||||
}
|
||||
if !rj_num.chars().skip(2).all(|c| c.is_numeric()) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
pub mod dlsite;
|
||||
pub use dlsite::*;
|
||||
|
||||
use color_eyre::eyre::eyre;
|
||||
use crate::constants::APP_CACHE_PATH;
|
||||
use color_eyre::Result;
|
||||
use image::DynamicImage;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use robotstxt::DefaultMatcher;
|
||||
use scraper::Html;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Crawler {
|
||||
id: String,
|
||||
pub(crate) base_url: Url,
|
||||
client: Client,
|
||||
robots_txt: Option<String>
|
||||
}
|
||||
|
||||
impl Crawler {
|
||||
pub fn new(id: &str, base_url: Url) -> Self {
|
||||
let crawler = Self {
|
||||
id: id.to_string(),
|
||||
client: Client::new(),
|
||||
robots_txt: None,
|
||||
base_url,
|
||||
};
|
||||
crawler
|
||||
}
|
||||
|
||||
async fn check_access(&self, url: &Url) -> Result<()> {
|
||||
let mut matcher = DefaultMatcher::default();
|
||||
let is_access_allowed = matcher.one_agent_allowed_by_robots(
|
||||
&self.get_robots_txt().await?,
|
||||
"reqwest",
|
||||
url.as_str(),
|
||||
);
|
||||
if !is_access_allowed {
|
||||
return Err(eyre!("Crawler cannot access site {}", self.base_url.as_str()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_robots_txt(&self) -> Result<String> {
|
||||
if let Some(txt) = &self.robots_txt {
|
||||
return Ok(txt.clone());
|
||||
}
|
||||
|
||||
let local_robots_path = APP_CACHE_PATH.clone().join(&self.id).join("robots.txt");
|
||||
if !local_robots_path.exists() {
|
||||
let mut robots_url = self.base_url.clone();
|
||||
robots_url.set_path("/robots.txt");
|
||||
let response = reqwest::get(robots_url).await.expect(
|
||||
format!(
|
||||
"Failed to get robots.txt in `{}/robots.txt`",
|
||||
self.base_url.as_str()
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
let content = response.text().await?;
|
||||
tokio::fs::create_dir_all(local_robots_path.parent().unwrap()).await?;
|
||||
tokio::fs::write(&local_robots_path, &content).await?;
|
||||
Ok(content)
|
||||
} else {
|
||||
Ok(tokio::fs::read_to_string(&local_robots_path).await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_html(&self, path: &str, query: Option<&str>) -> Result<(Html, StatusCode)> {
|
||||
let mut url = self.base_url.clone();
|
||||
self.check_access(&url).await?;
|
||||
url.set_path(path);
|
||||
url.set_query(query);
|
||||
let res = self.client.get(url).send().await?;
|
||||
let status = res.status();
|
||||
let html_text = &res.text().await?;
|
||||
Ok((Html::parse_document(html_text), status))
|
||||
}
|
||||
|
||||
pub async fn get_json<T>(&self, path: &str, query: Option<&str>) -> Result<(T, StatusCode)>
|
||||
where T : DeserializeOwned {
|
||||
let mut url = self.base_url.clone();
|
||||
url.set_path(path);
|
||||
url.set_query(query);
|
||||
self.check_access(&url).await?;
|
||||
let res = self.client.get(url).send().await?;
|
||||
let status = res.status();
|
||||
let json = res.json().await?;
|
||||
Ok((json, status))
|
||||
}
|
||||
|
||||
pub async fn get_img(&self, url: &Url) -> Result<(DynamicImage, StatusCode)> {
|
||||
self.check_access(url).await?;
|
||||
let res = self.client.get(url.clone()).send().await?;
|
||||
let status = res.status();
|
||||
let bytes = res.bytes().await?;
|
||||
let img = image::load_from_memory(&bytes)?;
|
||||
Ok((img, status))
|
||||
}
|
||||
|
||||
pub async fn get_bytes(&self, path: &str) -> Result<(Vec<u8>, StatusCode)> {
|
||||
let mut url = self.base_url.clone();
|
||||
url.set_path(path);
|
||||
self.check_access(&url).await?;
|
||||
let res = self.client.get(url).send().await?;
|
||||
let status = res.status();
|
||||
let bytes = res.bytes().await?;
|
||||
Ok((bytes.to_vec(), status))
|
||||
}
|
||||
}
|
||||
54
src/event.rs
54
src/event.rs
@@ -1,54 +0,0 @@
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use crossterm::event::EventStream;
|
||||
use futures::FutureExt;
|
||||
use futures::StreamExt;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum AppEvent {
|
||||
Error,
|
||||
Tick,
|
||||
Raw(crossterm::event::Event),
|
||||
}
|
||||
|
||||
pub(crate) struct EventHandler {
|
||||
_tx: UnboundedSender<AppEvent>,
|
||||
rx: UnboundedReceiver<AppEvent>,
|
||||
pub task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new(tick_rate: Duration) -> Self {
|
||||
let mut interval = tokio::time::interval(tick_rate);
|
||||
let mut event_reader = EventStream::new();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let _tx = tx.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let delay = interval.tick();
|
||||
let crossterm_event = event_reader.next().fuse();
|
||||
tokio::select! {
|
||||
maybe_event = crossterm_event => {
|
||||
if let Some(Err(_)) = maybe_event {
|
||||
tx.send(AppEvent::Error).unwrap()
|
||||
} else if let Some(Ok(event)) = maybe_event {
|
||||
tx.send(AppEvent::Raw(event)).unwrap();
|
||||
}
|
||||
}
|
||||
_ = delay => {
|
||||
tx.send(AppEvent::Tick).unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { _tx, rx, task }
|
||||
}
|
||||
|
||||
pub(crate) async fn next(&mut self) -> Result<AppEvent> {
|
||||
self.rx.recv().await.ok_or(eyre!("Unable to get event"))
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
use crate::constants::{APP_DB_DATA_DIR, DB_CF_OPTIONS, DB_COLUMNS, DB_OPTIONS};
|
||||
use rocksdb::{ColumnFamilyDescriptor, IteratorMode, OptimisticTransactionDB, Options};
|
||||
use serde::{Serialize};
|
||||
use serde::de::DeserializeOwned;
|
||||
use crate::models::{RocksColumn, RocksReference, RocksReferences};
|
||||
use color_eyre::Result;
|
||||
|
||||
pub struct RocksDB {
|
||||
db: OptimisticTransactionDB,
|
||||
}
|
||||
|
||||
impl Default for RocksDB {
|
||||
fn default() -> Self {
|
||||
RocksDB::new(DB_OPTIONS.clone(), DB_CF_OPTIONS.clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl RocksDB {
|
||||
pub fn new(db_opts: Options, cf_opts: Options) -> Result<Self> {
|
||||
let cfs = DB_COLUMNS.iter()
|
||||
.map(|cf| ColumnFamilyDescriptor::new(cf.to_string(), cf_opts.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let db = OptimisticTransactionDB::open_cf_descriptors(
|
||||
&db_opts,
|
||||
APP_DB_DATA_DIR.as_path(),
|
||||
cfs
|
||||
)?;
|
||||
let rocks = Self {
|
||||
db
|
||||
};
|
||||
Ok(rocks)
|
||||
}
|
||||
|
||||
pub fn get_value<TColumn>(&self, id: &TColumn::Id) -> Result<Option<TColumn>>
|
||||
where TColumn: RocksColumn + DeserializeOwned
|
||||
{
|
||||
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
||||
let query_res = self.db.get_cf(&cf, serde_json::to_string(id)?)?;
|
||||
if query_res.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut value: TColumn = serde_json::from_slice(&query_res.unwrap())?;
|
||||
value.set_id(id.clone());
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
pub fn set_value<TColumn>(&self, value: &TColumn) -> Result<()>
|
||||
where TColumn: RocksColumn + Serialize
|
||||
{
|
||||
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
||||
self.db.put_cf(&cf, serde_json::to_string(&value.get_id())?, serde_json::to_string(value)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_values<TColumn>(&self, ids: &[TColumn::Id]) -> Result<Vec<TColumn>>
|
||||
where TColumn: RocksColumn + DeserializeOwned
|
||||
{
|
||||
let transaction = self.db.transaction();
|
||||
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
||||
let mut values = Vec::new();
|
||||
for id in ids {
|
||||
let query_res = transaction.get_cf(&cf, serde_json::to_string(id)?)?;
|
||||
if let Some(res) = query_res {
|
||||
let mut value: TColumn = serde_json::from_slice(&res)?;
|
||||
value.set_id(id.clone());
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
pub fn get_reference_value<TReference, TColumn>(&self, id: &TReference::Id) -> Result<Option<TReference>>
|
||||
where TReference: RocksColumn + DeserializeOwned,
|
||||
TColumn: RocksColumn + RocksReference<TReference>
|
||||
{
|
||||
let reference = self.get_value::<TReference>(id)?;
|
||||
if reference.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(reference.unwrap()))
|
||||
}
|
||||
|
||||
pub fn get_reference_values<TReference, TColumn>(&self, ids: &[TReference::Id]) -> Result<Vec<TReference>>
|
||||
where TReference: RocksColumn + DeserializeOwned,
|
||||
TColumn: RocksColumn + RocksReferences<TReference>
|
||||
{
|
||||
self.get_values::<TReference>(ids)
|
||||
}
|
||||
|
||||
pub fn get_all_values<TColumn>(&self) -> Result<Vec<TColumn>>
|
||||
where TColumn: RocksColumn + DeserializeOwned
|
||||
{
|
||||
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
||||
let values = self.db.iterator_cf_opt(&cf, crate::constants::get_db_read_options(), IteratorMode::Start)
|
||||
.filter_map(Result::ok)
|
||||
.map(|(k, v)| {
|
||||
let id = serde_json::from_slice::<TColumn::Id>(&k).unwrap();
|
||||
let mut value = serde_json::from_slice::<TColumn>(&v).unwrap();
|
||||
value.set_id(id);
|
||||
value
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
pub fn set_values<TColumn>(&mut self, values: &[TColumn]) -> Result<()>
|
||||
where TColumn: RocksColumn + Serialize
|
||||
{
|
||||
let transaction = self.db.transaction();
|
||||
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
||||
for value in values {
|
||||
transaction.put_cf(&cf, serde_json::to_string(&value.get_id())?, serde_json::to_string(value)?)?;
|
||||
}
|
||||
transaction.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
pub mod db;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use color_eyre::eyre::eyre;
|
||||
use color_eyre::owo_colors::OwoColorize;
|
||||
use language_tags::LanguageTag;
|
||||
use tokio::fs;
|
||||
use crate::constants::{APP_CONFIG_DIR, APP_DATA_DIR, APP_DB_DATA_DIR};
|
||||
use crate::crawler::DLSITE_IMG_FOLDER;
|
||||
|
||||
|
||||
pub async fn initialize_folders() -> color_eyre::Result<()> {
|
||||
if !APP_CONFIG_DIR.exists() {
|
||||
fs::create_dir_all(APP_CONFIG_DIR.as_path()).await?;
|
||||
}
|
||||
if !APP_DATA_DIR.exists() {
|
||||
fs::create_dir_all(APP_DATA_DIR.as_path()).await?;
|
||||
}
|
||||
if !DLSITE_IMG_FOLDER.exists() {
|
||||
fs::create_dir_all(DLSITE_IMG_FOLDER.as_path()).await?;
|
||||
}
|
||||
if !APP_DB_DATA_DIR.exists() {
|
||||
fs::create_dir_all(APP_DB_DATA_DIR.as_path()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_all_folders(paths: &[&Path]) -> color_eyre::Result<Vec<PathBuf>> {
|
||||
let mut folders: Vec<PathBuf> = Vec::new();
|
||||
for path in paths {
|
||||
let path = path.to_path_buf();
|
||||
if !path.exists() {
|
||||
return Err(eyre!("{:?} {}", path.blue(), "does not exist".red()));
|
||||
}
|
||||
|
||||
let mut dirs = fs::read_dir(path).await?;
|
||||
while let Some(dir) = dirs.next_entry().await? {
|
||||
folders.push(dir.path());
|
||||
}
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
pub fn matches_primary_language(left: &LanguageTag, right: &LanguageTag) -> bool {
|
||||
left.primary_language() == right.primary_language()
|
||||
}
|
||||
23
src/main.rs
23
src/main.rs
@@ -1,21 +1,8 @@
|
||||
mod app;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod crawler;
|
||||
mod event;
|
||||
mod helpers;
|
||||
mod models;
|
||||
mod widgets;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use clap::Parser;
|
||||
use color_eyre::Result;
|
||||
use tokio;
|
||||
|
||||
use clap_builder::Parser;
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
async fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
let cli = Cli::parse();
|
||||
cli.run().await
|
||||
let cli = ui::Cli::parse();
|
||||
cli.run().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
use color_eyre::Result;
|
||||
use std::path::PathBuf;
|
||||
use color_eyre::{eyre, Report};
|
||||
use ratatui::prelude::Text;
|
||||
use ratatui::widgets::ListState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::de::DeserializeOwned;
|
||||
use crate::config::types::ApplicationConfig;
|
||||
use crate::constants::{EN_LOCALE, JP_LOCALE};
|
||||
use crate::crawler::DLSiteGenreCategory;
|
||||
use crate::helpers::db::RocksDB;
|
||||
use crate::helpers::matches_primary_language;
|
||||
use crate::models::{RocksColumn, RocksReferences};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct GameList<T> {
|
||||
pub games: Vec<T>,
|
||||
pub state: ListState,
|
||||
}
|
||||
|
||||
impl<T> Default for GameList<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
games: Vec::new(),
|
||||
state: ListState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> GameList<T>
|
||||
where T: DeserializeOwned + RocksColumn
|
||||
{
|
||||
pub fn new() -> Result<Self> {
|
||||
let db = RocksDB::default();
|
||||
let mut state = ListState::default();
|
||||
state.select_first();
|
||||
let game_list = GameList {
|
||||
games: db.get_all_values::<T>()?,
|
||||
state
|
||||
};
|
||||
Ok(game_list)
|
||||
}
|
||||
}
|
||||
|
||||
//region Maniax
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct DLSiteManiax {
|
||||
#[serde(skip)]
|
||||
pub rj_num: String,
|
||||
pub genre_ids: Vec<u16>,
|
||||
pub name: Vec<DLSiteTranslation>,
|
||||
pub sells_count: u32,
|
||||
pub folder_path: PathBuf,
|
||||
pub version: Option<String>
|
||||
}
|
||||
|
||||
impl From<crate::crawler::dlsite::DLSiteManiax> for DLSiteManiax {
|
||||
fn from(value: crate::crawler::DLSiteManiax) -> Self {
|
||||
let title = DLSiteTranslation::try_from(value.title.as_str()).unwrap();
|
||||
Self {
|
||||
rj_num: value.rj_num,
|
||||
genre_ids: value.genre_ids,
|
||||
name: vec![title],
|
||||
sells_count: value.sells_count,
|
||||
folder_path: value.folder_path,
|
||||
version: None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RocksColumn for DLSiteManiax {
|
||||
type Id = String;
|
||||
|
||||
fn get_id(&self) -> Self::Id {
|
||||
self.rj_num.clone()
|
||||
}
|
||||
|
||||
fn set_id(&mut self, id: Self::Id) {
|
||||
self.rj_num = id;
|
||||
}
|
||||
|
||||
fn get_column_name() -> String {
|
||||
String::from("dl_games")
|
||||
}
|
||||
}
|
||||
|
||||
impl RocksReferences<DLSiteGenre> for DLSiteManiax {
|
||||
fn get_reference_ids(&self) -> Vec<<DLSiteGenre as RocksColumn>::Id> {
|
||||
self.genre_ids.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<Text<'_>> for &DLSiteManiax {
|
||||
fn into(self) -> Text<'static> {
|
||||
Text::from(self.rj_num.to_string())
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Genre
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DLSiteGenre {
|
||||
#[serde(skip)]
|
||||
pub id: u16,
|
||||
pub name: Vec<DLSiteTranslation>
|
||||
}
|
||||
|
||||
impl RocksColumn for DLSiteGenre {
|
||||
type Id = u16;
|
||||
|
||||
fn get_id(&self) -> Self::Id {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn set_id(&mut self, id: Self::Id) {
|
||||
self.id = id;
|
||||
}
|
||||
|
||||
fn get_column_name() -> String {
|
||||
String::from("dl_genres")
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Category
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct DLSiteCategory {
|
||||
#[serde(skip)]
|
||||
pub id: String,
|
||||
pub genre_ids: Vec<u16>,
|
||||
pub name: DLSiteTranslation
|
||||
}
|
||||
|
||||
impl TryFrom<DLSiteGenreCategory> for DLSiteCategory {
|
||||
type Error = Report;
|
||||
|
||||
fn try_from(value: DLSiteGenreCategory) -> Result<Self, Self::Error> {
|
||||
let category = Self {
|
||||
id: format!(
|
||||
"{}/{}",
|
||||
value.id,
|
||||
ApplicationConfig::get_config()?.basic_config.locale.primary_language()
|
||||
),
|
||||
genre_ids: value.values.iter()
|
||||
.map(|v| v.value.parse::<u16>())
|
||||
.filter_map(Result::ok)
|
||||
.collect(),
|
||||
name: DLSiteTranslation::try_from(value.category_name.as_str())?,
|
||||
};
|
||||
Ok(category)
|
||||
}
|
||||
}
|
||||
|
||||
impl RocksReferences<DLSiteGenre> for DLSiteCategory {
|
||||
fn get_reference_ids(&self) -> Vec<<DLSiteGenre as RocksColumn>::Id> {
|
||||
self.genre_ids.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl RocksColumn for DLSiteCategory {
|
||||
type Id = String;
|
||||
fn get_id(&self) -> Self::Id {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn set_id(&mut self, id: Self::Id) {
|
||||
self.id = id;
|
||||
}
|
||||
|
||||
fn get_column_name() -> String {
|
||||
String::from("dl_categories")
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Translation
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) enum DLSiteTranslation {
|
||||
EN(String), JP(String)
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for DLSiteTranslation {
|
||||
type Error = Report;
|
||||
fn try_from(value: &str) -> color_eyre::Result<Self> {
|
||||
Self::try_from(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for DLSiteTranslation {
|
||||
type Error = Report;
|
||||
fn try_from(value: String) -> color_eyre::Result<Self> {
|
||||
let app_conf = ApplicationConfig::get_config()?;
|
||||
let locale = app_conf.basic_config.locale;
|
||||
|
||||
if matches_primary_language(&locale, &EN_LOCALE) {
|
||||
return Ok(DLSiteTranslation::EN(value));
|
||||
}
|
||||
if matches_primary_language(&locale, &JP_LOCALE) {
|
||||
return Ok(DLSiteTranslation::JP(value));
|
||||
}
|
||||
Err(eyre::eyre!(
|
||||
"Invalid Locale: {:?}; Support {:?}",
|
||||
locale,
|
||||
[EN_LOCALE.to_string(), JP_LOCALE.to_string()])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<String> for DLSiteTranslation {
|
||||
type Error = Report;
|
||||
|
||||
fn try_into(self) -> Result<String, Self::Error> {
|
||||
match self {
|
||||
DLSiteTranslation::EN(val) => Ok(val),
|
||||
DLSiteTranslation::JP(val) => Ok(val),
|
||||
}
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
@@ -1,46 +0,0 @@
|
||||
mod game;
|
||||
|
||||
use color_eyre::eyre::eyre;
|
||||
use color_eyre::Report;
|
||||
use language_tags::LanguageTag;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
pub(crate) use game::*;
|
||||
use crate::constants::{EN_LOCALE, JP_LOCALE};
|
||||
use crate::helpers::matches_primary_language;
|
||||
|
||||
pub trait RocksColumn {
|
||||
type Id: Serialize + DeserializeOwned + Clone;
|
||||
fn get_id(&self) -> Self::Id;
|
||||
fn set_id(&mut self, id: Self::Id);
|
||||
fn get_column_name() -> String;
|
||||
}
|
||||
|
||||
pub trait RocksReference<T> where T: RocksColumn {
|
||||
fn get_reference_id(&self) -> T::Id;
|
||||
}
|
||||
|
||||
pub trait RocksReferences<T> where T: RocksColumn {
|
||||
fn get_reference_ids(&self) -> Vec<T::Id>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PrimaryLanguage {
|
||||
EN, JP
|
||||
}
|
||||
|
||||
impl TryFrom<&LanguageTag> for PrimaryLanguage {
|
||||
type Error = Report;
|
||||
|
||||
fn try_from(value: &LanguageTag) -> Result<Self, Self::Error> {
|
||||
if matches_primary_language(&value, &EN_LOCALE) {
|
||||
Ok(PrimaryLanguage::EN)
|
||||
}
|
||||
else if matches_primary_language(&value, &JP_LOCALE) {
|
||||
Ok(PrimaryLanguage::JP)
|
||||
}
|
||||
else {
|
||||
Err(eyre!("No matching primary language found for {}", value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
mod textarea;
|
||||
pub use textarea::*;
|
||||
@@ -1,159 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use rat_cursor::HasScreenCursor;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::prelude::StatefulWidget;
|
||||
use ratatui::style::{Color, Stylize};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
use tui_input::Input;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TextArea {
|
||||
title: String,
|
||||
style: TextAreaStyle,
|
||||
auto_scroll: bool,
|
||||
validate_fn: Arc<dyn Fn(&str) -> bool>,
|
||||
pub state: TextAreaState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TextAreaState {
|
||||
pub is_active: bool,
|
||||
input_area: Option<Rect>,
|
||||
scroll_offset: u16,
|
||||
input: Input,
|
||||
is_valid: bool
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum TextAreaStyle {
|
||||
Block,
|
||||
SingleLine,
|
||||
}
|
||||
|
||||
impl StatefulWidget for TextArea {
|
||||
type State = TextAreaState;
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let input_value = state.input.value().to_string();
|
||||
let title = self.title.clone();
|
||||
match self.style {
|
||||
TextAreaStyle::Block => {
|
||||
let block = Block::default().borders(Borders::ALL).title(title);
|
||||
let paragraph = Paragraph::new(Text::from(input_value)).block(block);
|
||||
state.input_area = Some(area);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
TextAreaStyle::SingleLine => {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Max((self.title.len() + 1) as u16), Constraint::Fill(0)])
|
||||
.split(area);
|
||||
let label_text = Text::from(self.title);
|
||||
let label = Paragraph::new(label_text);
|
||||
|
||||
let input_text = Span::from(input_value.clone()).fg(Color::White);
|
||||
let input_line = Line::from(input_text);
|
||||
let paragraph = Paragraph::new(input_line)
|
||||
.bg(if state.is_valid { Color::Green } else { Color::Red })
|
||||
.scroll((0, state.scroll_offset));
|
||||
|
||||
if state.input_area.is_none() {
|
||||
state.input_area = Some(chunks[1]);
|
||||
}
|
||||
else if let Some(area) = self.state.input_area && area != chunks[1] {
|
||||
state.input_area = Some(chunks[1]);
|
||||
}
|
||||
|
||||
label.render(chunks[0], buf);
|
||||
paragraph.render(chunks[1], buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasScreenCursor for TextArea {
|
||||
fn screen_cursor(&self) -> Option<(u16, u16)> {
|
||||
if self.state.input_area.is_none() {
|
||||
return None;
|
||||
}
|
||||
let area = self.state.input_area.unwrap();
|
||||
let scroll = self.state.input.visual_scroll(1);
|
||||
let x = self.state.input.visual_cursor().max(scroll) as u16 - self.state.scroll_offset;
|
||||
Some((area.x + x, area.y))
|
||||
}
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub fn new(title: &str,
|
||||
placeholder_text: &str,
|
||||
validate_fn: fn(&str) -> bool,
|
||||
) -> Self {
|
||||
let func = Arc::new(validate_fn);
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
style: TextAreaStyle::SingleLine,
|
||||
auto_scroll: true,
|
||||
validate_fn: func,
|
||||
state: TextAreaState {
|
||||
input: Input::new(placeholder_text.to_string()),
|
||||
is_active: false,
|
||||
input_area: None,
|
||||
scroll_offset: 0,
|
||||
is_valid: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_style(mut self, style: TextAreaStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_auto_scroll(mut self, auto_scroll: bool) -> Self {
|
||||
self.auto_scroll = auto_scroll;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn handle_input(&mut self, event: &Event) -> Result<()> {
|
||||
let _ = self.state.input.handle_event(event);
|
||||
self.state.is_valid = (self.validate_fn)(self.state.input.value());
|
||||
if let Event::Key(key) = event &&
|
||||
!matches!(key.kind, KeyEventKind::Release) &&
|
||||
let Some(area) = self.state.input_area
|
||||
{
|
||||
let scroll_offset = self.state.scroll_offset;
|
||||
let cursor_pos = self.state.input.cursor() as u16;
|
||||
if scroll_offset > cursor_pos {
|
||||
self.state.scroll_offset = cursor_pos;
|
||||
} else if cursor_pos >= area.width + scroll_offset {
|
||||
self.state.scroll_offset = cursor_pos - area.width;
|
||||
} else if self.auto_scroll && scroll_offset > 0 && key.code.is_delete() {
|
||||
self.state.scroll_offset -= 1;
|
||||
// HACK: with_cursor function requires to be owned so use handle event
|
||||
let key_event = Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::empty()));
|
||||
let _ = self.state.input.handle_event(&key_event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_value(&self) -> Option<String> {
|
||||
if self.state.is_valid {
|
||||
return Some(self.state.input.value().to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn reset_value(&mut self) -> Result<()> {
|
||||
self.state.is_valid = false;
|
||||
Ok(self.state.input.reset())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod components;
|
||||
pub mod popups;
|
||||
pub mod views;
|
||||
@@ -1,61 +0,0 @@
|
||||
use std::path::Path;
|
||||
use crate::widgets::components::TextArea;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
|
||||
use ratatui::prelude::{StatefulWidget, Widget};
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AddFolderPopup {
|
||||
pub textarea: TextArea,
|
||||
}
|
||||
|
||||
impl AddFolderPopup {
|
||||
pub fn new() -> Self {
|
||||
let mut textarea = TextArea::new(
|
||||
"Folder Path",
|
||||
"",
|
||||
|x| {
|
||||
let path = Path::new(x);
|
||||
path.exists() && path.is_dir()
|
||||
}
|
||||
);
|
||||
textarea.state.is_active = true;
|
||||
Self { textarea }
|
||||
}
|
||||
|
||||
pub fn get_folder_value(&mut self) -> Option<String> {
|
||||
let value = self.textarea.get_value();
|
||||
if value.is_none() {
|
||||
return None;
|
||||
}
|
||||
if let Some(path) = value && !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidget for AddFolderPopup {
|
||||
type State = AddFolderPopup;
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let popup_area = Rect {
|
||||
x: area.width / 4,
|
||||
y: area.height / 3,
|
||||
width: area.width / 2,
|
||||
height: area.height / 3,
|
||||
};
|
||||
let block = Block::default()
|
||||
.title("Add New Folder")
|
||||
.borders(Borders::ALL);
|
||||
block.render(popup_area, buf);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1)])
|
||||
.split(popup_area.inner(Margin::new(1, 1)));
|
||||
self.textarea.render(chunks[0], buf, &mut state.textarea.state);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
use ratatui::widgets::StatefulWidget;
|
||||
|
||||
pub mod folder;
|
||||
|
||||
pub enum AppPopup {
|
||||
AddFolder(folder::AddFolderPopup)
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
use crate::config::types::ApplicationConfig;
|
||||
use crate::widgets::popups::folder::AddFolderPopup;
|
||||
use crossterm::event::KeyCode::Char;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
use rat_cursor::HasScreenCursor;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::prelude::{Color, Line, Span, Style, Text, Widget};
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::palette::tailwind::SLATE;
|
||||
use ratatui::widgets::{Block, Borders, HighlightSpacing, List, Paragraph, StatefulWidget};
|
||||
use crate::models::{DLSiteManiax, GameList};
|
||||
use crate::widgets::popups::AppPopup;
|
||||
use crate::widgets::views::View;
|
||||
|
||||
pub struct MainView {
|
||||
pub state: MainViewState
|
||||
}
|
||||
|
||||
pub struct MainViewState {
|
||||
popup: Option<AppPopup>,
|
||||
status: Status,
|
||||
dl_game_list: GameList<DLSiteManiax>,
|
||||
list_page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Status {
|
||||
Running,
|
||||
Exiting,
|
||||
Popup,
|
||||
}
|
||||
|
||||
impl MainView {
|
||||
pub fn new() -> color_eyre::Result<Self> {
|
||||
let dl_game_list = GameList::new()?;
|
||||
let view = Self {
|
||||
state: MainViewState {
|
||||
popup: None,
|
||||
status: Status::Running,
|
||||
list_page_size: 0,
|
||||
dl_game_list
|
||||
}
|
||||
};
|
||||
Ok(view)
|
||||
}
|
||||
}
|
||||
|
||||
impl MainViewState {
|
||||
fn quit(&mut self) -> color_eyre::Result<()> {
|
||||
if self.popup.is_none() {
|
||||
self.status = Status::Exiting;
|
||||
ApplicationConfig::get_config()?.save()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn folder_popup(&mut self) {
|
||||
self.popup = Some(AppPopup::AddFolder(AddFolderPopup::new()));
|
||||
self.status = Status::Popup;
|
||||
}
|
||||
|
||||
fn handle_popup(&mut self, event: &Event) -> color_eyre::Result<()> {
|
||||
let Some(current_popup) = self.popup.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
match current_popup {
|
||||
AppPopup::AddFolder(folder_popup) => {
|
||||
folder_popup.textarea.handle_input(event)?;
|
||||
if let Event::Key(key) = event &&
|
||||
key.code.is_enter() &&
|
||||
let Some(value) = folder_popup.get_folder_value()
|
||||
{
|
||||
let mut config = ApplicationConfig::get_config()?;
|
||||
config.path_config.dlsite_paths.push(value);
|
||||
|
||||
folder_popup.textarea.reset_value()?;
|
||||
config.save()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_game_list_key(&mut self, event: &KeyEvent) -> color_eyre::Result<()> {
|
||||
let game_list_state = &mut self.dl_game_list.state;
|
||||
let game_list_len = self.dl_game_list.games.len();
|
||||
let selected_value = game_list_state.selected().unwrap_or(0);
|
||||
match event.code {
|
||||
KeyCode::Down => game_list_state.select_next(),
|
||||
KeyCode::Up => game_list_state.select_previous(),
|
||||
KeyCode::PageUp => {
|
||||
let selected_index =
|
||||
if selected_value < self.list_page_size { 0 }
|
||||
else { selected_value - self.list_page_size };
|
||||
game_list_state.select(Some(selected_index))
|
||||
},
|
||||
KeyCode::PageDown => {
|
||||
game_list_state.select(Some((selected_value + self.list_page_size).clamp(0, game_list_len)))
|
||||
},
|
||||
KeyCode::Home => game_list_state.select_first(),
|
||||
KeyCode::End => game_list_state.select_last(),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl View for MainView {
|
||||
fn handle_input(&mut self, event: &Event) -> color_eyre::Result<()> {
|
||||
let state = &mut self.state;
|
||||
state.handle_popup(event)?;
|
||||
|
||||
if let Event::Key(key_event) = event {
|
||||
if matches!(state.status, Status::Popup) &&
|
||||
matches!(key_event.code, KeyCode::Esc)
|
||||
{
|
||||
state.status = Status::Running;
|
||||
state.popup = None;
|
||||
}
|
||||
if !matches!(state.status, Status::Popup) &&
|
||||
matches!(key_event.kind, KeyEventKind::Press)
|
||||
{
|
||||
match key_event.code {
|
||||
Char('q') => state.quit()?,
|
||||
Char('a') => state.folder_popup(),
|
||||
_ => {}
|
||||
}
|
||||
state.handle_game_list_key(key_event)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
!matches!(self.state.status, Status::Exiting)
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidget for MainView {
|
||||
type State = MainViewState;
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
state.list_page_size = chunks[1].height as usize;
|
||||
Self::render_header(chunks[0], buf);
|
||||
Self::render_game_info(chunks[1], buf, state);
|
||||
Self::render_footer(state, chunks[2], buf);
|
||||
|
||||
let Some(popup) = state.popup.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match popup {
|
||||
AppPopup::AddFolder(popup) => {
|
||||
popup.clone().render(area, buf, popup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasScreenCursor for MainView {
|
||||
fn screen_cursor(&self) -> Option<(u16, u16)> {
|
||||
let Some(popup) = &self.state.popup else {
|
||||
return None;
|
||||
};
|
||||
match popup {
|
||||
AppPopup::AddFolder(popup) => {
|
||||
popup.textarea.screen_cursor()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MainView {
|
||||
const SELECTED_STYLE: Style = Style::new()
|
||||
.bg(SLATE.c800)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
fn render_game_info(area: Rect, buf: &mut Buffer, state: &mut MainViewState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(13),
|
||||
])
|
||||
.split(area);
|
||||
Self::render_game_list(chunks[0], buf, state);
|
||||
}
|
||||
|
||||
fn render_game_list(area: Rect, buf: &mut Buffer, state: &mut MainViewState) {
|
||||
let list_block = Block::new()
|
||||
.title(Line::raw("Games"))
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default());
|
||||
let game_list = List::new(&state.dl_game_list.games)
|
||||
.block(list_block)
|
||||
.highlight_style(Self::SELECTED_STYLE)
|
||||
.highlight_symbol(">")
|
||||
.highlight_spacing(HighlightSpacing::WhenSelected);
|
||||
StatefulWidget::render(game_list, area, buf, &mut state.dl_game_list.state);
|
||||
}
|
||||
|
||||
fn render_header(area: Rect, buf: &mut Buffer) {
|
||||
let title = Paragraph::new(Text::styled(
|
||||
"SuS Manager",
|
||||
Style::default().fg(Color::Green),
|
||||
));
|
||||
title.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_footer(state: &mut MainViewState, area: Rect, buf: &mut Buffer) {
|
||||
let mut navigation_text = vec![Span::styled(
|
||||
"(q) quit / (a) add folders",
|
||||
Style::default().fg(Color::Green),
|
||||
)];
|
||||
if matches!(state.status, Status::Popup) {
|
||||
navigation_text[0] = Span::styled("(Esc) close", Style::default().fg(Color::Green));
|
||||
}
|
||||
let line = Line::from(navigation_text);
|
||||
let footer = Paragraph::new(line);
|
||||
footer.render(area, buf);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
mod main_view;
|
||||
|
||||
use crossterm::event::{Event};
|
||||
use rat_cursor::HasScreenCursor;
|
||||
pub use main_view::MainView;
|
||||
|
||||
pub trait View: HasScreenCursor {
|
||||
fn handle_input(&mut self, event: &Event) -> color_eyre::Result<()>;
|
||||
fn is_running(&self) -> bool;
|
||||
}
|
||||
|
||||
pub enum AppView {
|
||||
Main(MainView),
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
pub fn get_view(&mut self) -> &mut dyn View
|
||||
{
|
||||
match self {
|
||||
AppView::Main(main_view) => main_view
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user