lots of things
changed db storing method from json to message pack remove tui fix progressbar add logging
This commit is contained in:
11
Cargo.toml
11
Cargo.toml
@@ -29,13 +29,20 @@ lazy_static = "1.5.0"
|
|||||||
color-eyre = { version = "0.6.5" }
|
color-eyre = { version = "0.6.5" }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
|
rmp-serde = "1.3.1"
|
||||||
|
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
ratatui = { version = "0.29.0", features = ["all-widgets"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
dashmap = { version = "6.1.0", features = ["serde"] }
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
jemallocator = "0.5.4"
|
jemallocator = "0.5.4"
|
||||||
tokio = { version = "1.48.0", features = ["macros"] }
|
tokio = { version = "1.48.0", features = ["macros"] }
|
||||||
clap_builder = "4.5.53"
|
clap_builder = "4.5.53"
|
||||||
|
fern = { version = "0.7.1", features = ["chrono", "colored"] }
|
||||||
|
|
||||||
|
chrono.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
ui = { path = "./ui" }
|
ui = { path = "./ui" }
|
||||||
|
models = { path = "./models" }
|
||||||
@@ -12,10 +12,11 @@ robotstxt = "0.3.0"
|
|||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
color-eyre.workspace = true
|
color-eyre.workspace = true
|
||||||
lazy_static.workspace = true
|
lazy_static.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use models::dlsite::{matches_primary_language, PrimaryLanguage, JP_LOCALE};
|
|||||||
use super::Crawler;
|
use super::Crawler;
|
||||||
use models::dlsite::crawler::*;
|
use models::dlsite::crawler::*;
|
||||||
|
|
||||||
//TODO: override locale with user one
|
|
||||||
const DLSITE_URL: &str = "https://www.dlsite.com/";
|
const DLSITE_URL: &str = "https://www.dlsite.com/";
|
||||||
const DLSITE_PRODUCT_API_ENDPOINT: &str = "/maniax/product/info/ajax";
|
const DLSITE_PRODUCT_API_ENDPOINT: &str = "/maniax/product/info/ajax";
|
||||||
const DLSITE_FS_ENDPOINT: &str = "/maniax/fs/=/api_access/1/";
|
const DLSITE_FS_ENDPOINT: &str = "/maniax/fs/=/api_access/1/";
|
||||||
@@ -42,7 +41,14 @@ impl DLSiteCrawler {
|
|||||||
Ok(crawler)
|
Ok(crawler)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_game_infos(&self, rj_nums: Vec<String>, locale: &LanguageTag) -> Result<FuturesUnordered<impl Future<Output=Result<Option<DLSiteManiax>, Report>>>>
|
pub async fn get_game_infos(&self, rj_nums: Vec<String>, locale: &LanguageTag)
|
||||||
|
-> Result<
|
||||||
|
FuturesUnordered<
|
||||||
|
impl Future<
|
||||||
|
Output = Result<(DLSiteManiax, bool)>
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>
|
||||||
{
|
{
|
||||||
let invalid_nums = rj_nums.iter()
|
let invalid_nums = rj_nums.iter()
|
||||||
.filter(|&n| !is_valid_rj_number(n))
|
.filter(|&n| !is_valid_rj_number(n))
|
||||||
@@ -65,8 +71,9 @@ impl DLSiteCrawler {
|
|||||||
// try to catch '[]' empty result from the api
|
// try to catch '[]' empty result from the api
|
||||||
let value_downcast_result: Result<HashMap<String, DLSiteManiax>, _> = serde_json::from_value(value);
|
let value_downcast_result: Result<HashMap<String, DLSiteManiax>, _> = serde_json::from_value(value);
|
||||||
let maniax_result = value_downcast_result.unwrap_or(HashMap::new());
|
let maniax_result = value_downcast_result.unwrap_or(HashMap::new());
|
||||||
|
if let Err(e) = Self::verify_all_works_exists(&maniax_result, rj_nums) {
|
||||||
Self::verify_all_works_exists(&maniax_result, rj_nums);
|
println!("{}", e);
|
||||||
|
}
|
||||||
|
|
||||||
let tasks = FuturesUnordered::new();
|
let tasks = FuturesUnordered::new();
|
||||||
for (rj_num, mut info) in maniax_result {
|
for (rj_num, mut info) in maniax_result {
|
||||||
@@ -78,19 +85,18 @@ impl DLSiteCrawler {
|
|||||||
self.crawler.get_html(&html_path, Some(&query))
|
self.crawler.get_html(&html_path, Some(&query))
|
||||||
);
|
);
|
||||||
let (html, status) = html_result?;
|
let (html, status) = html_result?;
|
||||||
|
info.rj_num = rj_num;
|
||||||
if StatusCode::NOT_FOUND == status {
|
if StatusCode::NOT_FOUND == status {
|
||||||
println!("{} is no longer available", rj_num);
|
return Ok((info, false));
|
||||||
return Ok(None);
|
|
||||||
}
|
}
|
||||||
info.genre_ids = self.get_work_genres(&html, locale.try_into()?).await?;
|
info.genre_ids = self.get_work_genres(&html, locale.try_into()?).await?;
|
||||||
info.rj_num = rj_num;
|
Ok::<(DLSiteManiax, bool), Report>((info, true))
|
||||||
Ok::<Option<DLSiteManiax>, Report>(Some(info))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_all_works_exists(maniax_result: &HashMap<String, DLSiteManiax>, rj_nums: Vec<String>) {
|
fn verify_all_works_exists(maniax_result: &HashMap<String, DLSiteManiax>, rj_nums: Vec<String>) -> Result<()> {
|
||||||
let keys = maniax_result.keys()
|
let keys = maniax_result.keys()
|
||||||
.map(|k| k.to_string())
|
.map(|k| k.to_string())
|
||||||
.collect::<Vec<String>>();
|
.collect::<Vec<String>>();
|
||||||
@@ -100,8 +106,9 @@ impl DLSiteCrawler {
|
|||||||
.map(|n| n.to_string())
|
.map(|n| n.to_string())
|
||||||
.collect::<Vec<String>>();
|
.collect::<Vec<String>>();
|
||||||
if !nums_diff.is_empty() {
|
if !nums_diff.is_empty() {
|
||||||
println!("Restricted/Removed Works: {}", nums_diff.join(", ").red());
|
return Err(eyre!("Restricted/Removed Works: {}", nums_diff.join(", ").red()));
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_main_image(&self, info: &DLSiteManiax, rj_num: &str) -> Result<()> {
|
async fn save_main_image(&self, info: &DLSiteManiax, rj_num: &str) -> Result<()> {
|
||||||
@@ -133,9 +140,6 @@ impl DLSiteCrawler {
|
|||||||
return Err(eyre!("Genre url is empty"));
|
return Err(eyre!("Genre url is empty"));
|
||||||
};
|
};
|
||||||
let genre_url = Url::parse(genre_href)?;
|
let genre_url = Url::parse(genre_href)?;
|
||||||
let Some(path_segments) = genre_url.path_segments() else {
|
|
||||||
return Err(eyre!("Genre url has no segment: {}", genre_href));
|
|
||||||
};
|
|
||||||
let Some(genre_id) = genre_url.path_segments().unwrap()
|
let Some(genre_id) = genre_url.path_segments().unwrap()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(4)
|
.skip(4)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ num_cpus = "1.17.0"
|
|||||||
lazy_static.workspace = true
|
lazy_static.workspace = true
|
||||||
color-eyre.workspace = true
|
color-eyre.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
rmp-serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ impl RocksDB {
|
|||||||
if query_res.is_none() {
|
if query_res.is_none() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let mut value: TColumn = serde_json::from_slice(&query_res.unwrap())?;
|
let mut value: TColumn = rmp_serde::from_slice(&query_res.unwrap())?;
|
||||||
value.set_id(id.clone());
|
value.set_id(id.clone());
|
||||||
Ok(Some(value))
|
Ok(Some(value))
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ impl RocksDB {
|
|||||||
where TColumn: RocksColumn + Serialize
|
where TColumn: RocksColumn + Serialize
|
||||||
{
|
{
|
||||||
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
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)?)?;
|
self.db.put_cf(&cf, serde_json::to_string(&value.get_id())?, rmp_serde::to_vec(&value)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ impl RocksDB {
|
|||||||
for id in ids {
|
for id in ids {
|
||||||
let query_res = transaction.get_cf(&cf, serde_json::to_string(id)?)?;
|
let query_res = transaction.get_cf(&cf, serde_json::to_string(id)?)?;
|
||||||
if let Some(res) = query_res {
|
if let Some(res) = query_res {
|
||||||
let mut value: TColumn = serde_json::from_slice(&res)?;
|
let mut value: TColumn = rmp_serde::from_slice(&res)?;
|
||||||
value.set_id(id.clone());
|
value.set_id(id.clone());
|
||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ impl RocksDB {
|
|||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
.map(|(k, v)| {
|
.map(|(k, v)| {
|
||||||
let id = serde_json::from_slice::<TColumn::Id>(&k).unwrap();
|
let id = serde_json::from_slice::<TColumn::Id>(&k).unwrap();
|
||||||
let mut value = serde_json::from_slice::<TColumn>(&v).unwrap();
|
let mut value = rmp_serde::from_slice::<TColumn>(&v).unwrap();
|
||||||
value.set_id(id);
|
value.set_id(id);
|
||||||
value
|
value
|
||||||
})
|
})
|
||||||
@@ -177,7 +177,7 @@ impl RocksDB {
|
|||||||
let transaction = self.db.transaction();
|
let transaction = self.db.transaction();
|
||||||
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
let cf = self.db.cf_handle(TColumn::get_column_name().as_str()).unwrap();
|
||||||
for value in values {
|
for value in values {
|
||||||
transaction.put_cf(&cf, serde_json::to_string(&value.get_id())?, serde_json::to_string(value)?)?;
|
transaction.put_cf(&cf, serde_json::to_string(&value.get_id())?, rmp_serde::to_vec(value)?)?;
|
||||||
}
|
}
|
||||||
transaction.commit()?;
|
transaction.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ edition = "2024"
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
color-eyre.workspace = true
|
color-eyre.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
lazy_static.workspace = true
|
|
||||||
ratatui.workspace = true
|
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
dashmap.workspace = true
|
lazy_static.workspace = true
|
||||||
|
|
||||||
|
dashmap = { version = "6.1.0", features = ["serde"] }
|
||||||
language-tags = { version = "0.3.2", features = ["serde"] }
|
language-tags = { version = "0.3.2", features = ["serde"] }
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use ratatui::text::Text;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::db::{RocksColumn, RocksReferences};
|
use crate::db::{RocksColumn, RocksReferences};
|
||||||
use super::genre::DLSiteGenre;
|
use super::genre::DLSiteGenre;
|
||||||
@@ -50,16 +49,4 @@ impl RocksReferences<DLSiteGenre> for DLSiteManiax {
|
|||||||
fn get_reference_ids(&self) -> Vec<<DLSiteGenre as RocksColumn>::Id> {
|
fn get_reference_ids(&self) -> Vec<<DLSiteGenre as RocksColumn>::Id> {
|
||||||
self.genre_ids.clone()
|
self.genre_ids.clone()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Into<Text<'_>> for &DLSiteManiax {
|
|
||||||
fn into(self) -> Text<'static> {
|
|
||||||
Text::from(self.rj_num.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Into<Text<'_>> for DLSiteManiax {
|
|
||||||
fn into(self) -> Text<'static> {
|
|
||||||
Text::from(self.rj_num.to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
28
src/main.rs
28
src/main.rs
@@ -1,8 +1,36 @@
|
|||||||
use clap_builder::Parser;
|
use clap_builder::Parser;
|
||||||
|
use fern::colors::{Color, ColoredLevelConfig};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> color_eyre::Result<()> {
|
async fn main() -> color_eyre::Result<()> {
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
let cli = ui::Cli::parse();
|
let cli = ui::Cli::parse();
|
||||||
|
let colors = ColoredLevelConfig::new()
|
||||||
|
.error(Color::Red)
|
||||||
|
.warn(Color::Yellow)
|
||||||
|
.info(Color::BrightBlue)
|
||||||
|
.debug(Color::Blue)
|
||||||
|
.trace(Color::Cyan);
|
||||||
|
let mut dispatcher = fern::Dispatch::new()
|
||||||
|
.format(move |out, message, record| {
|
||||||
|
out.finish(
|
||||||
|
format_args!(
|
||||||
|
"[{} {} {}] {}",
|
||||||
|
chrono::Local::now().format("%H:%M:%S"),
|
||||||
|
colors.color(record.level()),
|
||||||
|
record.target(),
|
||||||
|
message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.level_for("ui", log::LevelFilter::Debug)
|
||||||
|
.chain(fern::log_file(models::APP_CACHE_PATH.join("debug.log"))?);
|
||||||
|
if cli.is_gui_mode() {
|
||||||
|
dispatcher = dispatcher.chain(std::io::stdout());
|
||||||
|
}
|
||||||
|
dispatcher.apply()?;
|
||||||
|
log::info!("Logger initialized");
|
||||||
cli.run().await?;
|
cli.run().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -9,35 +9,21 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
futures = "0.3.28"
|
futures = "0.3.28"
|
||||||
rat-cursor = "1.2.1"
|
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
|
|
||||||
color-eyre.workspace = true
|
color-eyre.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
ratatui.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
db = { path = "../db" }
|
db = { path = "../db" }
|
||||||
crawler = { path = "../crawler" }
|
crawler = { path = "../crawler" }
|
||||||
|
|
||||||
[dependencies.ratatui-image]
|
|
||||||
version = "8.0.2"
|
|
||||||
features = ["tokio", "serde"]
|
|
||||||
|
|
||||||
[dependencies.indicatif]
|
[dependencies.indicatif]
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
features = ["futures", "tokio"]
|
features = ["futures", "tokio"]
|
||||||
|
|
||||||
[dependencies.tui-input]
|
|
||||||
version = "0.14.0"
|
|
||||||
features = ["crossterm"]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dependencies.crossterm]
|
|
||||||
version = "0.29.0"
|
|
||||||
features = ["event-stream"]
|
|
||||||
|
|
||||||
[dependencies.clap]
|
[dependencies.clap]
|
||||||
version = "4.5.48"
|
version = "4.5.48"
|
||||||
features = ["derive", "cargo"]
|
features = ["derive", "cargo"]
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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;
|
|
||||||
use db::{RocksDBFactory};
|
|
||||||
use models::config::ApplicationConfig;
|
|
||||||
|
|
||||||
pub(crate) struct App {
|
|
||||||
events: EventHandler,
|
|
||||||
state: AppState,
|
|
||||||
db_factory: RocksDBFactory
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppState {
|
|
||||||
view: Option<AppView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
pub async fn create() -> Result<Self> {
|
|
||||||
let config = ApplicationConfig::get_config()?;
|
|
||||||
let db_factory = RocksDBFactory::default();
|
|
||||||
let state = AppState {
|
|
||||||
view: Some(AppView::Main(MainView::new(db_factory.clone())?)),
|
|
||||||
};
|
|
||||||
let app = Self {
|
|
||||||
events: EventHandler::new(Duration::from_millis(config.basic_config.tick_rate)),
|
|
||||||
state,
|
|
||||||
db_factory
|
|
||||||
};
|
|
||||||
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(
|
|
||||||
main_view.clone(),
|
|
||||||
frame.area(),
|
|
||||||
&mut main_view.state,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
mod folder;
|
mod folder;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|
||||||
use crate::{app, helpers};
|
use crate::{helpers};
|
||||||
use clap::{command, Parser};
|
use clap::{command, Parser};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::crossterm;
|
|
||||||
use crate::cli::folder::FolderCommand;
|
use crate::cli::folder::FolderCommand;
|
||||||
use crate::cli::sync::DLSiteCommand;
|
use crate::cli::sync::DLSiteCommand;
|
||||||
|
|
||||||
@@ -33,23 +32,13 @@ impl Cli {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_gui_mode(&self) -> bool {
|
||||||
|
self.subcommand.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
async fn start_tui(&self) -> Result<()> {
|
async fn start_tui(&self) -> Result<()> {
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
todo!()
|
||||||
let mut terminal = ratatui::init();
|
|
||||||
struct TuiCleanup;
|
|
||||||
impl Drop for TuiCleanup {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
ratatui::restore();
|
|
||||||
let _ = crossterm::terminal::disable_raw_mode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _cleanup = TuiCleanup;
|
|
||||||
let app = app::App::create().await?;
|
|
||||||
let result = app.run(&mut terminal).await;
|
|
||||||
ratatui::restore();
|
|
||||||
crossterm::terminal::disable_raw_mode()?;
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use clap::{Parser};
|
use clap::{Parser};
|
||||||
use color_eyre::eyre::{Result};
|
use color_eyre::eyre::{Result};
|
||||||
use crossterm::style::{style, Stylize};
|
use color_eyre::owo_colors::OwoColorize;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@@ -54,22 +54,24 @@ impl DLSiteSyncCommand {
|
|||||||
Self::sync_genres(db_factory.clone(), &app_conf, &crawler).await?;
|
Self::sync_genres(db_factory.clone(), &app_conf, &crawler).await?;
|
||||||
println!(
|
println!(
|
||||||
"{} {} Done in {:.2?}",
|
"{} {} Done in {:.2?}",
|
||||||
style("Genres").cyan(),
|
"Genres".cyan(),
|
||||||
style("Syncing").green(),
|
"Syncing".green(),
|
||||||
genre_now.elapsed()
|
genre_now.elapsed()
|
||||||
);
|
);
|
||||||
|
log::debug!("Finished genres syncing in {:.2?}", genre_now.elapsed());
|
||||||
}
|
}
|
||||||
if self.do_sync_work {
|
if self.do_sync_work {
|
||||||
let work_now = Instant::now();
|
let work_now = Instant::now();
|
||||||
self.sync_works(&app_conf, db_factory.clone(), &crawler).await?;
|
self.sync_works(&app_conf, db_factory.clone(), &crawler).await?;
|
||||||
println!(
|
println!(
|
||||||
"{} {} Done in {:.2?}",
|
"{} {} Done in {:.2?}",
|
||||||
style("Works").cyan(),
|
"Works".cyan(),
|
||||||
style("Syncing").green(),
|
"Syncing".green(),
|
||||||
work_now.elapsed()
|
work_now.elapsed()
|
||||||
);
|
);
|
||||||
|
log::debug!("Finished works syncing in {:.2?}", work_now.elapsed());
|
||||||
}
|
}
|
||||||
println!("{} Done in {:.2?}", style("Syncing").green(), now.elapsed());
|
println!("{} Done in {:.2?}", "Syncing".green(), now.elapsed());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +112,6 @@ impl DLSiteSyncCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.set_values(&modified_genres)?;
|
db.set_values(&modified_genres)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,14 +124,20 @@ impl DLSiteSyncCommand {
|
|||||||
|
|
||||||
let mut game_infos = crawler.get_game_infos(rj_nums, &app_conf.basic_config.locale).await?;
|
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 existing_game_infos = db.get_all_values::<DLSiteManiax>()?;
|
||||||
let mut modified_maniaxes: Vec<DLSiteManiax> = Vec::new();
|
let mut modified_maniaxes = Vec::new();
|
||||||
|
|
||||||
let progress = ProgressBar::new(game_infos.len() as u64)
|
let progress = ProgressBar::new(game_infos.len() as u64)
|
||||||
.with_style(ProgressStyle::default_bar());
|
.with_style(ProgressStyle::with_template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")?)
|
||||||
|
.with_message("Retrieving game infos");
|
||||||
|
let mut non_exists_nums = Vec::new();
|
||||||
while let Some(info) = game_infos.next().await {
|
while let Some(info) = game_infos.next().await {
|
||||||
let Some(maniax) = info? else {
|
let (maniax, exists) = info?;
|
||||||
|
progress.set_message(format!("Retrieved {}", maniax.rj_num));
|
||||||
|
if !exists {
|
||||||
|
non_exists_nums.push(maniax.rj_num);
|
||||||
|
progress.inc(1);
|
||||||
continue;
|
continue;
|
||||||
};
|
}
|
||||||
let existing_maniax = existing_game_infos.iter()
|
let existing_maniax = existing_game_infos.iter()
|
||||||
.find(|v| v.rj_num == maniax.rj_num);
|
.find(|v| v.rj_num == maniax.rj_num);
|
||||||
if let Some(existing_maniax) = existing_maniax {
|
if let Some(existing_maniax) = existing_maniax {
|
||||||
@@ -138,6 +145,7 @@ impl DLSiteSyncCommand {
|
|||||||
let DLSiteTranslations(existing_translations) = existing_maniax.name.clone();
|
let DLSiteTranslations(existing_translations) = existing_maniax.name.clone();
|
||||||
if existing_translations.contains(&name) {
|
if existing_translations.contains(&name) {
|
||||||
modified_maniaxes.push(existing_maniax.clone());
|
modified_maniaxes.push(existing_maniax.clone());
|
||||||
|
progress.inc(1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut modified_maniax = existing_maniax.clone();
|
let mut modified_maniax = existing_maniax.clone();
|
||||||
@@ -153,6 +161,10 @@ impl DLSiteSyncCommand {
|
|||||||
}
|
}
|
||||||
progress.inc(1);
|
progress.inc(1);
|
||||||
}
|
}
|
||||||
|
progress.set_message("Finished");
|
||||||
|
for rj_num in non_exists_nums.clone() {
|
||||||
|
println!("{} is no longer available", rj_num);
|
||||||
|
}
|
||||||
db.set_values(&modified_maniaxes)?;
|
db.set_values(&modified_maniaxes)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -175,8 +187,8 @@ impl DLSiteSyncCommand {
|
|||||||
if !dir_path.is_dir() {
|
if !dir_path.is_dir() {
|
||||||
println!(
|
println!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
style(dir_path.to_str().unwrap()).blue(),
|
dir_path.to_str().unwrap().blue(),
|
||||||
style("is not a directory").red()
|
"is not a directory".red()
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -188,8 +200,8 @@ impl DLSiteSyncCommand {
|
|||||||
if !is_valid_rj_number(&dir_name) && !existing_folders.contains(&dir_path_str) {
|
if !is_valid_rj_number(&dir_name) && !existing_folders.contains(&dir_path_str) {
|
||||||
println!(
|
println!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
style(dir_path.to_str().unwrap()).blue(),
|
dir_path.to_str().unwrap().blue(),
|
||||||
style("is not a valid rj number, please add it manually").red()
|
"is not a valid rj number, please add it manually".red()
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +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};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) enum AppEvent {
|
|
||||||
Error(String),
|
|
||||||
Tick,
|
|
||||||
Raw(crossterm::event::Event),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct EventHandler {
|
|
||||||
_tx: UnboundedSender<AppEvent>,
|
|
||||||
rx: UnboundedReceiver<AppEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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(e)) = maybe_event {
|
|
||||||
tx.send(AppEvent::Error(e.to_string())).unwrap()
|
|
||||||
} else if let Some(Ok(event)) = maybe_event {
|
|
||||||
tx.send(AppEvent::Raw(event)).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = delay => {
|
|
||||||
tx.send(AppEvent::Tick).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Self { _tx, rx }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn next(&mut self) -> Result<AppEvent> {
|
|
||||||
self.rx.recv().await.ok_or(eyre!("Unable to get event"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
mod app;
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod event;
|
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod widgets;
|
|
||||||
|
|
||||||
pub use cli::Cli;
|
pub use cli::Cli;
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use ratatui::buffer::Buffer;
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::prelude::Line;
|
|
||||||
use ratatui::style::Style;
|
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidget, Widget};
|
|
||||||
use db::RocksDBFactory;
|
|
||||||
use models::config::ApplicationConfig;
|
|
||||||
use models::db::RocksReferences;
|
|
||||||
use models::dlsite::{DLSiteGenre, DLSiteManiax};
|
|
||||||
use crate::widgets::components::Component;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct GameInfoBox {
|
|
||||||
pub state: GameInfoBoxState
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct GameInfoBoxState {
|
|
||||||
game_title: String,
|
|
||||||
genres: Vec<String>,
|
|
||||||
active: bool
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Component for GameInfoBoxState {
|
|
||||||
fn set_active(&mut self, active: bool) {
|
|
||||||
self.active = active;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<DLSiteManiax> for GameInfoBoxState {
|
|
||||||
type Error = color_eyre::Report;
|
|
||||||
|
|
||||||
fn try_from(value: DLSiteManiax) -> Result<Self, Self::Error> {
|
|
||||||
let db = RocksDBFactory::default().get_current_context()?;
|
|
||||||
let locale = ApplicationConfig::get_config()?.basic_config.locale;
|
|
||||||
let game_title = value.name.get_translation(locale.clone())?;
|
|
||||||
let game_genres =
|
|
||||||
db.get_reference_values::<DLSiteGenre, DLSiteManiax>(&value.get_reference_ids())?
|
|
||||||
.iter()
|
|
||||||
.map(|v| v.name.get_translation(locale.clone()))
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok(Self { game_title, genres: game_genres, active: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GameInfoBox {
|
|
||||||
pub fn new(maniax: DLSiteManiax) -> color_eyre::Result<Self> {
|
|
||||||
Ok(
|
|
||||||
Self {
|
|
||||||
state: GameInfoBoxState::try_from(maniax)?
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_info(&mut self, maniax: &DLSiteManiax) -> color_eyre::Result<()> {
|
|
||||||
self.state = GameInfoBoxState::try_from(maniax.clone())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StatefulWidget for GameInfoBox {
|
|
||||||
type State = GameInfoBoxState;
|
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
|
||||||
let block = Block::new()
|
|
||||||
.title(Line::raw("Info"))
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.style(Style::default());
|
|
||||||
let title_text = Line::styled(
|
|
||||||
state.game_title.clone(),
|
|
||||||
Style::default().fg(ratatui::style::Color::Yellow)
|
|
||||||
);
|
|
||||||
let text = Paragraph::new(
|
|
||||||
vec![
|
|
||||||
title_text,
|
|
||||||
Line::raw(state.genres.join(", "))
|
|
||||||
]
|
|
||||||
).block(block.clone());
|
|
||||||
text.render(area, buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
use std::marker::PhantomData;
|
|
||||||
use color_eyre::Result;
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
|
||||||
use ratatui::buffer::Buffer;
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::style::palette::tailwind::SLATE;
|
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
|
||||||
use ratatui::text::{Line};
|
|
||||||
use ratatui::widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState, StatefulWidget};
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use models::db::RocksColumn;
|
|
||||||
use crate::widgets::components::Component;
|
|
||||||
|
|
||||||
const SELECTED_STYLE: Style = Style::new()
|
|
||||||
.bg(SLATE.c800)
|
|
||||||
.add_modifier(Modifier::BOLD);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GameList<'a, T> where T: Into<ListItem<'a>> + DeserializeOwned + RocksColumn + Clone {
|
|
||||||
pub state: GameListState<'a, T>
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GameListState<'a, T> where T: Into<ListItem<'a>> + DeserializeOwned + RocksColumn + Clone {
|
|
||||||
games: Vec<T>,
|
|
||||||
list_state: ListState,
|
|
||||||
list_page_size: usize,
|
|
||||||
active: bool,
|
|
||||||
_phantom: PhantomData<&'a ()>
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, T> Component for GameList<'a, T> where T: Into<ListItem<'a>> + DeserializeOwned + RocksColumn + Clone {
|
|
||||||
fn set_active(&mut self, active: bool) {
|
|
||||||
self.state.active = active;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, T> Default for GameList<'a, T> where T: Into<ListItem<'a>> + DeserializeOwned + RocksColumn + Clone {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(vec![])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, T> StatefulWidget for GameList<'a, T>
|
|
||||||
where
|
|
||||||
T: Into<ListItem<'a>> + DeserializeOwned + RocksColumn + Clone
|
|
||||||
{
|
|
||||||
type State = GameListState<'a, T>;
|
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
|
||||||
let list_block = Block::new()
|
|
||||||
.title(Line::raw("Games"))
|
|
||||||
.borders(Borders::ALL);
|
|
||||||
let list_block =
|
|
||||||
if state.active { list_block.style(Style::default().fg(Color::Yellow)) }
|
|
||||||
else { list_block.style(Style::default()) };
|
|
||||||
let game_list = List::new(state.games.clone())
|
|
||||||
.block(list_block)
|
|
||||||
.style(Style::default().fg(Color::White))
|
|
||||||
.highlight_style(SELECTED_STYLE)
|
|
||||||
.highlight_symbol(">")
|
|
||||||
.highlight_spacing(HighlightSpacing::WhenSelected);
|
|
||||||
state.list_page_size = (area.height - 2) as usize;
|
|
||||||
game_list.render(area, buf, &mut state.list_state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, T> GameList<'a, T>
|
|
||||||
where
|
|
||||||
T: Into<ListItem<'a>> + DeserializeOwned + RocksColumn + Clone
|
|
||||||
{
|
|
||||||
pub fn new(games: Vec<T>) -> Self {
|
|
||||||
let mut state = ListState::default();
|
|
||||||
state.select_first();
|
|
||||||
let game_list = GameList {
|
|
||||||
state: GameListState::<T> {
|
|
||||||
games,
|
|
||||||
list_state: state,
|
|
||||||
_phantom: PhantomData,
|
|
||||||
list_page_size: 0,
|
|
||||||
active: false
|
|
||||||
},
|
|
||||||
};
|
|
||||||
game_list
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_game_list_key(&mut self, event: &KeyEvent) -> Result<()> {
|
|
||||||
let mut game_list_state = self.state.list_state.clone();
|
|
||||||
match event.code {
|
|
||||||
KeyCode::Down => game_list_state.select_next(),
|
|
||||||
KeyCode::Up => game_list_state.select_previous(),
|
|
||||||
KeyCode::PageUp =>game_list_state.scroll_up_by(self.state.list_page_size as u16),
|
|
||||||
KeyCode::PageDown => game_list_state.scroll_down_by(self.state.list_page_size as u16),
|
|
||||||
KeyCode::Home => game_list_state.select_first(),
|
|
||||||
KeyCode::End => game_list_state.select_last(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
self.state.list_state = game_list_state;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_selected_value(&self) -> Option<&T> {
|
|
||||||
let Some(index) = self.state.list_state.selected() else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
self.state.games.get(index.clamp(0, self.state.games.len() - 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
mod textarea;
|
|
||||||
mod game_list;
|
|
||||||
mod game_info_box;
|
|
||||||
|
|
||||||
pub use textarea::*;
|
|
||||||
pub use game_list::*;
|
|
||||||
pub use game_info_box::*;
|
|
||||||
|
|
||||||
pub trait Component {
|
|
||||||
fn set_active(&mut self, active: bool);
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
"",
|
|
||||||
|v| {
|
|
||||||
let path = Path::new(v);
|
|
||||||
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,6 +0,0 @@
|
|||||||
pub mod folder;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum AppPopup {
|
|
||||||
AddFolder(folder::AddFolderPopup)
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
use crate::widgets::popups::folder::AddFolderPopup;
|
|
||||||
use crossterm::event::KeyCode::Char;
|
|
||||||
use crossterm::event::{Event, KeyCode, 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::widgets::{Paragraph, StatefulWidget};
|
|
||||||
use db::{RocksDBFactory};
|
|
||||||
use models::config::ApplicationConfig;
|
|
||||||
use models::dlsite::{DLSiteManiax};
|
|
||||||
use crate::widgets::components::{Component, GameInfoBox, GameList};
|
|
||||||
use crate::widgets::popups::AppPopup;
|
|
||||||
use crate::widgets::views::View;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct MainView {
|
|
||||||
pub state: MainViewState,
|
|
||||||
db_factory: RocksDBFactory
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct MainViewState {
|
|
||||||
status: Status,
|
|
||||||
dl_game_list: GameList<'static, DLSiteManiax>,
|
|
||||||
game_info_box: GameInfoBox
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum Status {
|
|
||||||
Running,
|
|
||||||
Exiting,
|
|
||||||
Popup(AppPopup),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MainView {
|
|
||||||
pub fn new(mut db_factory: RocksDBFactory) -> color_eyre::Result<Self> {
|
|
||||||
let mut games = {
|
|
||||||
let db = db_factory.get_current_context()?;
|
|
||||||
let values = db.get_all_values::<DLSiteManiax>()?;
|
|
||||||
values
|
|
||||||
};
|
|
||||||
games.sort_by(|a, b| {
|
|
||||||
let left = a.rj_num
|
|
||||||
.chars().skip(2)
|
|
||||||
.collect::<String>()
|
|
||||||
.parse::<u32>()
|
|
||||||
.unwrap();
|
|
||||||
let right = b.rj_num
|
|
||||||
.chars().skip(2)
|
|
||||||
.collect::<String>()
|
|
||||||
.parse::<u32>()
|
|
||||||
.unwrap();
|
|
||||||
left.cmp(&right)
|
|
||||||
});
|
|
||||||
let first_game = games[0].clone();
|
|
||||||
let mut dl_game_list = GameList::new(games);
|
|
||||||
dl_game_list.set_active(true);
|
|
||||||
let view = Self {
|
|
||||||
state: MainViewState {
|
|
||||||
status: Status::Running,
|
|
||||||
dl_game_list,
|
|
||||||
game_info_box: GameInfoBox::new(first_game)?
|
|
||||||
},
|
|
||||||
db_factory
|
|
||||||
};
|
|
||||||
Ok(view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MainViewState {
|
|
||||||
fn quit(&mut self) -> color_eyre::Result<()> {
|
|
||||||
if matches!(self.status, Status::Running) {
|
|
||||||
self.status = Status::Exiting;
|
|
||||||
ApplicationConfig::get_config()?.save()?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn folder_popup(&mut self) {
|
|
||||||
self.status = Status::Popup(AppPopup::AddFolder(AddFolderPopup::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_popup(&mut self, event: &Event) -> color_eyre::Result<()> {
|
|
||||||
let Status::Popup(popup) = &mut self.status else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
match 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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 let Status::Popup(_) = &state.status &&
|
|
||||||
matches!(key_event.code, KeyCode::Esc)
|
|
||||||
{
|
|
||||||
state.status = Status::Running;
|
|
||||||
}
|
|
||||||
if matches!(state.status, Status::Running) &&
|
|
||||||
matches!(key_event.kind, KeyEventKind::Press)
|
|
||||||
{
|
|
||||||
match key_event.code {
|
|
||||||
Char('q') => state.quit()?,
|
|
||||||
Char('a') => state.folder_popup(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
state.dl_game_list.handle_game_list_key(key_event)?;
|
|
||||||
match state.dl_game_list.get_selected_value() {
|
|
||||||
Some(value) => {
|
|
||||||
state.game_info_box.set_info(value)?;
|
|
||||||
}
|
|
||||||
None => println!("No game selected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
Self::render_header(chunks[0], buf);
|
|
||||||
Self::render_game_info(chunks[1], buf, state);
|
|
||||||
Self::render_footer(state, chunks[2], buf);
|
|
||||||
|
|
||||||
let Status::Popup(popup) = &mut state.status 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 Status::Popup(popup) = &self.state.status else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
match popup {
|
|
||||||
AppPopup::AddFolder(popup) => {
|
|
||||||
popup.textarea.screen_cursor()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MainView {
|
|
||||||
fn render_game_info(area: Rect, buf: &mut Buffer, state: &mut MainViewState) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(14),
|
|
||||||
Constraint::Fill(0),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
state.dl_game_list.clone().render(chunks[0], buf, &mut state.dl_game_list.state);
|
|
||||||
state.game_info_box.clone().render(chunks[1], buf, &mut state.game_info_box.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 let Status::Popup(_) = state.status {
|
|
||||||
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,24 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
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