Initial Commit, Basic Navigation implemented
This commit is contained in:
parent
60c3a2da87
commit
d531356693
16 changed files with 6874 additions and 0 deletions
5628
Cargo.lock
generated
Normal file
5628
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "kavita-upload-helper"
|
||||
version = "0.1.0"
|
||||
authors = ["Neshura <neshura@neshweb.net>"]
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
slint = "1.6"
|
||||
reqwest = { version = "0.12.4", features = ["blocking"]}
|
||||
serde_json = "1.0.117"
|
||||
chrono = "0.4.38"
|
||||
arrayvec = "0.7.4"
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
copypasta = "0.10.1"
|
||||
parking_lot = { version = "0.12.3", features = ["deadlock_detection"] }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1.6"
|
5
build.rs
Normal file
5
build.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
fn main() {
|
||||
let config = slint_build::CompilerConfiguration::new()
|
||||
.with_style("qt".to_owned());
|
||||
slint_build::compile_with_config("ui/appwindow.slint", config).unwrap();
|
||||
}
|
154
src/auth/jnovel/mod.rs
Normal file
154
src/auth/jnovel/mod.rs
Normal file
|
@ -0,0 +1,154 @@
|
|||
use std::str::FromStr;
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use crate::auth::{GenericAuthentication, Otp, OtpAuthentication, OtpState, AuthToken};
|
||||
use crate::LoginState;
|
||||
|
||||
static API_BASE: &str = "https://labs.j-novel.club";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JNCAuth {
|
||||
pub otp: Otp,
|
||||
token: AuthToken,
|
||||
pub state: LoginState,
|
||||
}
|
||||
|
||||
impl GenericAuthentication for JNCAuth {
|
||||
fn login(&mut self) {
|
||||
if self.check_otp() == Ok(OtpState::NotCreated) && self.state == LoginState::LoggedOut {
|
||||
self.state = LoginState::Processing;
|
||||
match self.create_otp() {
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
println!("{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if self.state != LoginState::LoggedIn { // not logged in
|
||||
println!("Login Button should not be visible when OTP state is '{:?}' and Auth state is '{:?}'", self.otp, self.state);
|
||||
}
|
||||
}
|
||||
|
||||
fn logout(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let url = format!("{API_BASE}/app/v1/auth/logout?access_token={}", self.token.token);
|
||||
let res = client.post(url).send();
|
||||
|
||||
match res {
|
||||
Ok(data) => println!("{}", data.status()),
|
||||
Err(e) => println!("{}", e.to_string()),
|
||||
}
|
||||
|
||||
self.otp.state = OtpState::NotCreated;
|
||||
self.state = LoginState::LoggedOut;
|
||||
}
|
||||
|
||||
fn check_login(&mut self) -> LoginState {
|
||||
if self.check_otp() == Ok(OtpState::Validated) {
|
||||
println!("Logged In");
|
||||
self.otp.state = OtpState::Used;
|
||||
self.state = LoginState::LoggedIn;
|
||||
self.state
|
||||
}
|
||||
else {
|
||||
LoginState::Processing
|
||||
}
|
||||
}
|
||||
|
||||
fn get_auth(&self) -> String {
|
||||
self.token.token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl OtpAuthentication for JNCAuth {
|
||||
fn create_otp(&mut self) -> Result<OtpState, String> {
|
||||
let res = match reqwest::blocking::get(format!("{API_BASE}/app/v1/auth/otp4app/generate?format=json")) {
|
||||
Ok(res) => res,
|
||||
Err(e) => return Err(e.to_string())
|
||||
};
|
||||
|
||||
return match res.text() {
|
||||
Ok(otp_data) => {
|
||||
let mut otp: Otp = match serde_json::from_str(&otp_data) {
|
||||
Ok(otp) => otp,
|
||||
Err(e) => return Err(e.to_string())
|
||||
};
|
||||
|
||||
let start = Utc::now();
|
||||
|
||||
otp.start = start;
|
||||
otp.state = OtpState::Created;
|
||||
|
||||
self.otp = otp;
|
||||
|
||||
Ok(self.otp.state)
|
||||
}
|
||||
Err(e) => Err(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_otp(&mut self) -> Result<OtpState, String> {
|
||||
if self.otp.state == OtpState::NotCreated || self.otp.state == OtpState::Validated {
|
||||
return Ok(self.otp.state);
|
||||
}
|
||||
|
||||
let url = format!("{API_BASE}/app/v1/auth/otp4app/check/{}/{}?format=json", self.otp.code, self.otp.proof);
|
||||
|
||||
let res = match reqwest::blocking::get(url) {
|
||||
Ok(res) => res,
|
||||
Err(e) => return Err(e.to_string())
|
||||
};
|
||||
|
||||
return match res.status() {
|
||||
StatusCode::NO_CONTENT => {
|
||||
self.otp.state = OtpState::Created;
|
||||
Ok(self.otp.state)
|
||||
}
|
||||
StatusCode::OK => {
|
||||
match res.text() {
|
||||
Ok(text) => {
|
||||
let data: JNCAuthReturn = match serde_json::from_str(&text) {
|
||||
Ok(data) => data,
|
||||
Err(e) => return Err(e.to_string())
|
||||
};
|
||||
|
||||
let valid_for: u32 = data.ttl.strip_suffix('s').unwrap().parse().unwrap();
|
||||
let created = DateTime::<Utc>::from_str(&data.created).unwrap();
|
||||
|
||||
self.token = AuthToken {
|
||||
token: data.id,
|
||||
valid_for,
|
||||
created
|
||||
};
|
||||
},
|
||||
Err(e) => return Err(e.to_string())
|
||||
}
|
||||
|
||||
self.otp.state = OtpState::Validated;
|
||||
Ok(self.otp.state)
|
||||
}
|
||||
_ => {
|
||||
self.otp.state = OtpState::TimedOut;
|
||||
Ok(self.otp.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JNCAuth {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
otp: Otp::default(),
|
||||
token: AuthToken::default(),
|
||||
state: LoginState::LoggedOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JNCAuthReturn {
|
||||
id: String,
|
||||
ttl: String,
|
||||
created: String,
|
||||
}
|
82
src/auth/mod.rs
Normal file
82
src/auth/mod.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use crate::LoginState;
|
||||
|
||||
pub mod jnovel;
|
||||
|
||||
pub enum AuthenticationType {
|
||||
OTP,
|
||||
None
|
||||
}
|
||||
|
||||
pub trait GenericAuthentication {
|
||||
fn login(&mut self);
|
||||
fn logout(&mut self);
|
||||
fn check_login(&mut self) -> LoginState;
|
||||
fn get_auth(&self) -> String;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthToken {
|
||||
token: String,
|
||||
valid_for: u32,
|
||||
created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for AuthToken {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
token: "".to_owned(),
|
||||
valid_for: 0,
|
||||
created: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait OtpAuthentication: GenericAuthentication {
|
||||
fn create_otp(&mut self) -> Result<OtpState, String>;
|
||||
fn check_otp(&mut self) -> Result<OtpState, String>;
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Deserialize, Copy, Clone, Debug)]
|
||||
pub enum OtpState {
|
||||
Created,
|
||||
Validated,
|
||||
Used,
|
||||
TimedOut,
|
||||
NotCreated,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Otp {
|
||||
#[serde(default = "Otp::default_state")]
|
||||
state: OtpState,
|
||||
#[serde(alias = "otp")]
|
||||
pub code: String,
|
||||
pub proof: String,
|
||||
#[serde(skip_deserializing, default = "Otp::default_time")]
|
||||
pub start: DateTime<Utc>,
|
||||
pub ttl: u16,
|
||||
}
|
||||
|
||||
impl Otp {
|
||||
fn default_time() -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
fn default_state() -> OtpState {
|
||||
OtpState::NotCreated
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Otp {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: Self::default_state(),
|
||||
code: "".to_owned(),
|
||||
proof: "".to_owned(),
|
||||
start: Self::default_time(),
|
||||
ttl: 0
|
||||
}
|
||||
}
|
||||
}
|
184
src/main.rs
Normal file
184
src/main.rs
Normal file
|
@ -0,0 +1,184 @@
|
|||
mod sources;
|
||||
mod auth;
|
||||
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::{Arc};
|
||||
use std::thread;
|
||||
use chrono::{Duration, Utc};
|
||||
use copypasta::{ClipboardContext, ClipboardProvider};
|
||||
use parking_lot::RwLock;
|
||||
use slint::{SharedString, Weak};
|
||||
use crate::auth::{GenericAuthentication};
|
||||
use crate::auth::jnovel::JNCAuth;
|
||||
use crate::sources::jnc::JNCSource;
|
||||
use crate::sources::{Source, SourceState};
|
||||
slint::include_modules!();
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct AppState {
|
||||
jnc: JNovelClub,
|
||||
main: UploadPageState,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct UploadPageState {
|
||||
tab: i32,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct JNovelClub {
|
||||
auth: JNCAuth,
|
||||
source: JNCSource,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), slint::PlatformError> {
|
||||
let ui = AppWindow::new()?;
|
||||
let data: Arc<RwLock<AppState>> = Arc::new(RwLock::new(AppState::default()));
|
||||
|
||||
let callback_ui = ui.as_weak();
|
||||
let callback_data = data.clone();
|
||||
ui.global::<JNCSettingsInterface>().on_start_login({
|
||||
move || {
|
||||
let thread_ui = callback_ui.clone();
|
||||
let thread_data = callback_data.clone();
|
||||
thread::spawn(move || {
|
||||
thread_data.write().jnc.auth.login();
|
||||
|
||||
println!("Login State: {:?}", thread_data.read().jnc.auth.state);
|
||||
|
||||
let ttl = thread_data.read().jnc.auth.otp.start + Duration::seconds(thread_data.read().jnc.auth.otp.ttl as i64) - Utc::now();
|
||||
let ttl_string = format!("{}s", ttl.to_std().unwrap().as_secs());
|
||||
|
||||
let data = thread_data.read().clone();
|
||||
thread_ui.upgrade_in_event_loop(move |handle| {
|
||||
handle.global::<JNCSettingsInterface>().set_login_state(data.jnc.auth.state);
|
||||
handle.global::<JNCSettingsInterface>().set_login_timeout(SharedString::from(ttl_string));
|
||||
handle.global::<JNCSettingsInterface>().set_otp_code(SharedString::from(&data.jnc.auth.otp.code));
|
||||
}).expect("TODO: panic message");
|
||||
|
||||
thread_data.write().jnc.auth.check_login();
|
||||
|
||||
while thread_data.read().jnc.auth.state == LoginState::Processing {
|
||||
println!("Running");
|
||||
thread_data.write().jnc.auth.check_login();
|
||||
|
||||
thread::sleep(Duration::milliseconds(100).to_std().unwrap());
|
||||
|
||||
let state = thread_data.read().jnc.auth.state;
|
||||
let ttl = thread_data.read().jnc.auth.otp.start + Duration::seconds(thread_data.read().jnc.auth.otp.ttl as i64) - Utc::now();
|
||||
let ttl_string = format!("{}s", ttl.to_std().unwrap().as_secs());
|
||||
thread_ui.upgrade_in_event_loop(move |handle| {
|
||||
handle.global::<JNCSettingsInterface>().set_login_state(state);
|
||||
handle.global::<JNCSettingsInterface>().set_login_timeout(SharedString::from(ttl_string));
|
||||
}).expect("TODO: panic message");
|
||||
}
|
||||
let state = thread_data.read().jnc.auth.state;
|
||||
match state {
|
||||
LoginState::LoggedIn => {
|
||||
thread_ui.upgrade_in_event_loop(move |handle| {
|
||||
handle.global::<JNCSettingsInterface>().set_login_state(state);
|
||||
}).expect("TODO: panic message");
|
||||
}
|
||||
LoginState::LoginTimeout => {
|
||||
println!("Login timed out, handle");
|
||||
}
|
||||
LoginState::LoginError => {
|
||||
println!("Error during login, handle");
|
||||
}
|
||||
_ => {
|
||||
println!("Impossible State reached");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
println!("Login Start");
|
||||
}
|
||||
});
|
||||
|
||||
let ui_weak = ui.as_weak();
|
||||
let callback_data = data.clone();
|
||||
ui.global::<JNCSettingsInterface>().on_logout({
|
||||
move || {
|
||||
let thread_data = callback_data.clone();
|
||||
let ui_weak = ui_weak.clone();
|
||||
thread::spawn(move || {
|
||||
thread_data.write().jnc.auth.logout();
|
||||
ui_weak.upgrade_in_event_loop(move |handle| {
|
||||
handle.global::<JNCSettingsInterface>().set_login_state(thread_data.read().jnc.auth.state);
|
||||
}).expect("TODO: panic message");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let ui_weak = ui.as_weak();
|
||||
let thread_data = data.clone();
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
if update_library(thread_data.clone(), ui_weak.clone()) {
|
||||
break;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::milliseconds(50).to_std().unwrap());
|
||||
}
|
||||
});
|
||||
|
||||
let callback_ui = ui.as_weak();
|
||||
let callback_data = data.clone();
|
||||
ui.global::<JNCSettingsInterface>().on_refresh_library({
|
||||
move || {
|
||||
let thread_ui = callback_ui.clone();
|
||||
let thread_data = callback_data.clone();
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
if update_library(thread_data.clone(), thread_ui.clone()) {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::milliseconds(50).to_std().unwrap());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let callback_ui = ui.as_weak();
|
||||
let callback_data = data.clone();
|
||||
ui.global::<UploadPageInterface>().on_paginate_tabs({
|
||||
const TAB_COUNT: i32 = 3;
|
||||
let mut tab = callback_data.read().main.tab;
|
||||
move |input: i32| -> i32 {
|
||||
tab = input;
|
||||
callback_ui.upgrade_in_event_loop(move |handle| {
|
||||
handle.global::<UploadPageInterface>().set_prev_button_enabled(tab > 0);
|
||||
handle.global::<UploadPageInterface>().set_next_button_enabled(tab < TAB_COUNT - 1);
|
||||
}).expect("TODO: panic message");
|
||||
return tab;
|
||||
}
|
||||
});
|
||||
|
||||
ui.run()
|
||||
}
|
||||
|
||||
fn update_library(data: Arc<RwLock<AppState>>, ui_weak: Weak<AppWindow>) -> bool {
|
||||
let mut jnc: JNovelClub = data.read().jnc.clone();
|
||||
if jnc.auth.state == LoginState::LoggedIn {
|
||||
match jnc.source.get_state() {
|
||||
SourceState::Fresh => {
|
||||
let auth_data = jnc.auth.clone();
|
||||
jnc.source.initialize(&auth_data, ui_weak.clone());
|
||||
data.write().jnc = jnc;
|
||||
}
|
||||
SourceState::Initializing => {}
|
||||
SourceState::Ready => {
|
||||
let series_count = jnc.source.get_series_count();
|
||||
let book_count = jnc.source.get_book_count(None);
|
||||
ui_weak.upgrade_in_event_loop({
|
||||
move |handle| {
|
||||
handle.global::<JNCSettingsInterface>().set_series_count(series_count as i32);
|
||||
handle.global::<JNCSettingsInterface>().set_book_count(book_count as i32);
|
||||
}
|
||||
}).expect("TODO: panic message");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
30
src/sources/file_sources/mod.rs
Normal file
30
src/sources/file_sources/mod.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use std::collections::HashMap;
|
||||
use serde::Deserialize;
|
||||
use crate::auth::GenericAuthentication;
|
||||
use crate::sources::metadata_sources::{SeriesInfo, SeriesType};
|
||||
|
||||
pub trait FileSource {
|
||||
type FileAuth: GenericAuthentication;
|
||||
|
||||
fn fetch_library(&mut self, auth: Self::FileAuth) -> HashMap<SeriesType, Vec<SeriesInfo>>;
|
||||
|
||||
fn download();
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub enum FileType {
|
||||
#[serde(alias="EPUB")]
|
||||
Epub,
|
||||
#[serde(alias="PDF")]
|
||||
Pdf,
|
||||
#[serde(other)]
|
||||
Unknown
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Download {
|
||||
link: String,
|
||||
r#type: FileType,
|
||||
label: String,
|
||||
}
|
||||
|
130
src/sources/jnc/api.rs
Normal file
130
src/sources/jnc/api.rs
Normal file
|
@ -0,0 +1,130 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use crate::sources::file_sources::Download;
|
||||
use crate::sources::metadata_sources::SeriesType;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiSeries {
|
||||
id: String,
|
||||
pub legacy_id: String,
|
||||
#[serde(rename="type")]
|
||||
series_type: SeriesType,
|
||||
status: ApiStatus,
|
||||
pub title: String,
|
||||
short_title: String,
|
||||
original_title: String,
|
||||
slug: String,
|
||||
hidden: bool,
|
||||
created: String,
|
||||
description: String,
|
||||
short_description: String,
|
||||
tags: Vec<String>,
|
||||
cover: ApiCover,
|
||||
following: bool,
|
||||
catchup: bool,
|
||||
rentals: bool,
|
||||
topic_id: Option<i64>
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ApiLibrary {
|
||||
pub books: Vec<ApiBook>,
|
||||
pub pagination: ApiPagination,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiPagination {
|
||||
limit: usize,
|
||||
skip: usize,
|
||||
last_page: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiBook {
|
||||
id: String,
|
||||
legacy_id: String,
|
||||
pub volume: ApiVolume,
|
||||
serie: Value,
|
||||
purchased: Option<String>,
|
||||
last_download: Option<String>,
|
||||
last_updated: Option<String>,
|
||||
pub downloads: Vec<Download>,
|
||||
status: ApiStatus
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiVolume {
|
||||
id: String,
|
||||
pub legacy_id: String,
|
||||
pub title: String,
|
||||
short_title: String,
|
||||
original_title: String,
|
||||
slug: String,
|
||||
number: usize,
|
||||
original_publisher: String,
|
||||
label: String,
|
||||
creators: Vec<ApiCreator>,
|
||||
hidden: bool,
|
||||
forum_topic_id: usize,
|
||||
created: String,
|
||||
publishing: String,
|
||||
pub description: String,
|
||||
#[serde(rename="shortDescription")]
|
||||
description_short: String,
|
||||
cover: ApiCover,
|
||||
owned: bool,
|
||||
premium_extras: Option<String>,
|
||||
no_ebook: bool,
|
||||
total_parts: usize
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiCreator {
|
||||
id: String,
|
||||
name: String,
|
||||
role: CreatorRole,
|
||||
original_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
enum CreatorRole {
|
||||
#[serde(alias="AUTHOR")]
|
||||
Author,
|
||||
#[serde(alias="ARTIST")]
|
||||
Artist,
|
||||
#[serde(alias="EDITOR")]
|
||||
Editor,
|
||||
#[serde(alias="ILLUSTRATOR")]
|
||||
Illustrator,
|
||||
#[serde(alias="LETTERER")]
|
||||
Letterer,
|
||||
#[serde(alias="TRANSLATOR")]
|
||||
Translator,
|
||||
#[serde(other)]
|
||||
Unknown
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiCover {
|
||||
original_url: String,
|
||||
cover_url: String,
|
||||
thumbnail_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
enum ApiStatus {
|
||||
#[serde(alias="PREORDER")]
|
||||
Preorder,
|
||||
#[serde(alias="READY")]
|
||||
Ready,
|
||||
#[serde(alias="DEFAULT")]
|
||||
Default,
|
||||
#[serde(other)]
|
||||
Unknown
|
||||
}
|
197
src/sources/jnc/mod.rs
Normal file
197
src/sources/jnc/mod.rs
Normal file
|
@ -0,0 +1,197 @@
|
|||
mod api;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::thread::JoinHandle;
|
||||
use arrayvec::ArrayVec;
|
||||
use chrono::Duration;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::Deserialize;
|
||||
use serde_json::from_str;
|
||||
use slint::{ComponentHandle, Weak};
|
||||
use crate::{AppWindow, JNCSettingsInterface};
|
||||
use crate::auth::GenericAuthentication;
|
||||
use crate::auth::jnovel::JNCAuth;
|
||||
use crate::sources::file_sources::FileSource;
|
||||
use crate::sources::jnc::api::{ApiLibrary, ApiSeries};
|
||||
use crate::sources::metadata_sources::{BookInfo, MetadataSource, SeriesInfo, SeriesType};
|
||||
use crate::sources::{Source, SourceState};
|
||||
|
||||
static API_BASE: &str = "https://labs.j-novel.club";
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct JNCSource {
|
||||
series: HashMap<String, SeriesInfo>,
|
||||
state: SourceState,
|
||||
}
|
||||
|
||||
impl JNCSource {
|
||||
|
||||
}
|
||||
|
||||
impl FileSource for JNCSource {
|
||||
type FileAuth = JNCAuth;
|
||||
|
||||
fn fetch_library(&mut self, auth: Self::FileAuth) -> HashMap<SeriesType, Vec<SeriesInfo>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn download() {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl MetadataSource for JNCSource {
|
||||
type MetadataAuth = JNCAuth;
|
||||
|
||||
fn get_series_info(&self) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_book_info(&self) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_chapter_info(&self) {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for JNCSource {
|
||||
type Auth = JNCAuth;
|
||||
fn get_state(&self) -> SourceState {
|
||||
self.state
|
||||
}
|
||||
fn initialize(&mut self, auth: &Self::Auth, ui: Weak<AppWindow>) {
|
||||
println!("alive");
|
||||
self.state = SourceState::Initializing;
|
||||
println!("alive");
|
||||
let url = format!("{API_BASE}/app/v1/me/library?format=json&access_token={}", auth.get_auth());
|
||||
println!("alive");
|
||||
let res = reqwest::blocking::get(url).unwrap();
|
||||
println!("alive");
|
||||
let text = res.text().unwrap();
|
||||
println!("alive");
|
||||
let library = serde_json::from_str::<ApiLibrary>(&text).unwrap();
|
||||
|
||||
println!("alive");
|
||||
|
||||
let total = library.books.len();
|
||||
let library: Arc<RwLock<ApiLibrary>> = Arc::new(RwLock::new(library));
|
||||
const THREAD_NUM: usize = 3;
|
||||
let mut threads: ArrayVec<JoinHandle<()>, THREAD_NUM> = ArrayVec::<JoinHandle<()>, THREAD_NUM>::new();
|
||||
let next_index: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
|
||||
let series_map: Arc<Mutex<HashMap<String, SeriesInfo>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
println!("alive");
|
||||
|
||||
for _i in 0..=(THREAD_NUM-1) {
|
||||
let thread_index = next_index.clone();
|
||||
let thread_library = library.clone();
|
||||
let thread_map = series_map.clone();
|
||||
let auth_token = auth.get_auth();
|
||||
|
||||
threads.push(thread::spawn(move || {
|
||||
loop {
|
||||
let index = thread_index.fetch_add(1, Ordering::SeqCst);
|
||||
if index >= total {
|
||||
break;
|
||||
}
|
||||
let book = thread_library.read().books[index].clone();
|
||||
|
||||
let url = format!("{API_BASE}/app/v1/volumes/{}/serie?format=json&access_toke={}", book.volume.legacy_id, auth_token);
|
||||
let res = reqwest::blocking::get(url).unwrap();
|
||||
let data_raw = res.text().unwrap();
|
||||
let data = serde_json::from_str::<ApiSeries>(&data_raw).unwrap();
|
||||
{
|
||||
let mut map = thread_map.lock();
|
||||
if map.contains_key(&data.legacy_id) {
|
||||
let download_link = if book.downloads.len() > 0 {
|
||||
Some(book.downloads[0].clone())
|
||||
}
|
||||
else {
|
||||
None
|
||||
};
|
||||
let book_info = BookInfo {
|
||||
id: book.volume.legacy_id.clone(),
|
||||
title: book.volume.title.clone(),
|
||||
description: book.volume.description.clone(),
|
||||
chapters: vec![],
|
||||
link: download_link,
|
||||
};
|
||||
|
||||
let data = map.get_mut(&data.legacy_id).unwrap();
|
||||
data.books.push(book_info);
|
||||
}
|
||||
else {
|
||||
let download_link = if book.downloads.len() > 0 {
|
||||
Some(book.downloads[0].clone())
|
||||
}
|
||||
else {
|
||||
None
|
||||
};
|
||||
let book_info = BookInfo {
|
||||
id: book.volume.legacy_id.clone(),
|
||||
title: book.volume.title.clone(),
|
||||
description: book.volume.description.clone(),
|
||||
chapters: vec![],
|
||||
link: download_link,
|
||||
};
|
||||
let info = SeriesInfo {
|
||||
id: data.legacy_id.clone(),
|
||||
title: data.title.clone(),
|
||||
books: vec![book_info]
|
||||
};
|
||||
// create seriesInfo
|
||||
map.insert(data.legacy_id, info);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::milliseconds(50).to_std().unwrap());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
println!("alive");
|
||||
|
||||
while threads.len() != 0 {
|
||||
let mut finished_list = vec![];
|
||||
for (index, thread) in threads.iter().enumerate() {
|
||||
let finished = &thread.is_finished();
|
||||
if !finished {
|
||||
{
|
||||
let series_count = series_map.lock().len();
|
||||
let book_count = next_index.load(Ordering::SeqCst);
|
||||
ui.upgrade_in_event_loop({
|
||||
move |handle| {
|
||||
handle.global::<JNCSettingsInterface>().set_series_count(series_count as i32);
|
||||
handle.global::<JNCSettingsInterface>().set_book_count(book_count as i32);
|
||||
}
|
||||
}).expect("TODO: panic message");
|
||||
}
|
||||
}
|
||||
else {
|
||||
finished_list.push(index);
|
||||
}
|
||||
}
|
||||
finished_list.into_iter().for_each(|index| {
|
||||
threads.pop_at(index);
|
||||
})
|
||||
}
|
||||
self.series = series_map.lock().clone();
|
||||
self.state = SourceState::Ready;
|
||||
}
|
||||
fn get_series_count(&self) -> usize {
|
||||
self.series.len()
|
||||
}
|
||||
fn get_book_count(&self, series: Option<String>) -> usize {
|
||||
let mut count = 0;
|
||||
for series in self.series.values() {
|
||||
count += series.books.len();
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
49
src/sources/metadata_sources/mod.rs
Normal file
49
src/sources/metadata_sources/mod.rs
Normal file
|
@ -0,0 +1,49 @@
|
|||
use serde::Deserialize;
|
||||
use crate::auth::GenericAuthentication;
|
||||
use crate::sources::file_sources::Download;
|
||||
|
||||
pub trait MetadataSource {
|
||||
type MetadataAuth: GenericAuthentication;
|
||||
|
||||
fn get_series_info(&self);
|
||||
fn get_book_info(&self);
|
||||
fn get_chapter_info(&self);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct SeriesInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub books: Vec<BookInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct BookInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub chapters: Vec<ChapterInfo>,
|
||||
pub link: Option<Download>
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ChapterInfo {
|
||||
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub enum BookStatus {
|
||||
Preorder,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
pub enum SeriesType {
|
||||
#[serde(alias="NOVEL")]
|
||||
Novel,
|
||||
#[serde(alias="MANGA")]
|
||||
Manga,
|
||||
#[serde(alias="HENTAI")]
|
||||
Hentai,
|
||||
#[serde(other)]
|
||||
Unknown
|
||||
}
|
33
src/sources/mod.rs
Normal file
33
src/sources/mod.rs
Normal file
|
@ -0,0 +1,33 @@
|
|||
mod file_sources;
|
||||
mod metadata_sources;
|
||||
pub mod jnc;
|
||||
|
||||
use slint::Weak;
|
||||
use crate::AppWindow;
|
||||
use crate::auth::GenericAuthentication;
|
||||
use crate::sources::file_sources::FileSource;
|
||||
use crate::sources::metadata_sources::MetadataSource;
|
||||
|
||||
|
||||
pub enum SourceType {
|
||||
Full,
|
||||
File,
|
||||
Metadata
|
||||
}
|
||||
|
||||
#[derive(Default, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum SourceState {
|
||||
#[default]
|
||||
Fresh,
|
||||
Initializing,
|
||||
Ready
|
||||
}
|
||||
|
||||
|
||||
pub trait Source: FileSource + MetadataSource {
|
||||
type Auth: GenericAuthentication;
|
||||
fn get_state(&self) -> SourceState;
|
||||
fn initialize(&mut self, auth: &Self::Auth, ui: Weak<AppWindow>);
|
||||
fn get_series_count(&self) -> usize;
|
||||
fn get_book_count(&self, series: Option<String>) -> usize;
|
||||
}
|
30
ui/appwindow.slint
Normal file
30
ui/appwindow.slint
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Button, VerticalBox, HorizontalBox } from "std-widgets.slint";
|
||||
import { SideBar } from "sidebar.slint";
|
||||
import { AboutPage } from "pages/about_page.slint";
|
||||
import { SettingsPage, SettingsPageInterface, JNCSettingsInterface } from "pages/settings_page.slint";
|
||||
import { UploadPage, UploadPageInterface } from "pages/upload_page.slint";
|
||||
|
||||
export { SettingsPageInterface, JNCSettingsInterface, UploadPageInterface }
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
title: "Kavita Upload Tool";
|
||||
preferred-width: 1024px;
|
||||
preferred-height: 720px;
|
||||
HorizontalLayout {
|
||||
VerticalBox {
|
||||
padding-right: 0px;
|
||||
side-bar := SideBar {
|
||||
title: "Kavita Upload Tool";
|
||||
model: [
|
||||
"Upload Tool",
|
||||
"Settings",
|
||||
"About"
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (side-bar.current-item == 0) : UploadPage {}
|
||||
if (side-bar.current-item == 1) : SettingsPage {}
|
||||
if (side-bar.current-item == 2) : AboutPage {}
|
||||
}
|
||||
}
|
13
ui/pages/about_page.slint
Normal file
13
ui/pages/about_page.slint
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { VerticalBox, Palette } from "std-widgets.slint";
|
||||
|
||||
export component AboutPage inherits VerticalBox {
|
||||
Rectangle {
|
||||
border-width: 1px;
|
||||
border-radius: 4px;
|
||||
border-color: Palette.border;
|
||||
background: Palette.alternate-background;
|
||||
Text {
|
||||
text: "ToDo: Add Author Info and Link to Repository";
|
||||
}
|
||||
}
|
||||
}
|
131
ui/pages/settings_page.slint
Normal file
131
ui/pages/settings_page.slint
Normal file
|
@ -0,0 +1,131 @@
|
|||
import { VerticalBox, Palette, TextEdit, HorizontalBox, ScrollView, LineEdit, Button } from "std-widgets.slint";
|
||||
|
||||
export enum LoginState {
|
||||
LoggedIn,
|
||||
LoggedOut,
|
||||
Processing,
|
||||
LoginError,
|
||||
LoginTimeout
|
||||
}
|
||||
|
||||
export global JNCSettingsInterface {
|
||||
in-out property <LoginState> login_state: LoginState.LoggedOut;
|
||||
in-out property <string> login_timeout;
|
||||
in-out property <string> otp_code;
|
||||
in-out property <int> series_count: 0;
|
||||
in-out property <int> book_count: 0;
|
||||
|
||||
callback start_login();
|
||||
callback logout();
|
||||
callback refresh_library();
|
||||
}
|
||||
|
||||
export global SettingsPageInterface {
|
||||
|
||||
}
|
||||
|
||||
component SettingsMember inherits Rectangle {
|
||||
border-width: 1px;
|
||||
border-radius: 4px;
|
||||
border-color: Palette.border;
|
||||
background: Palette.alternate-background;
|
||||
VerticalBox {
|
||||
alignment: start;
|
||||
spacing: 4px;
|
||||
@children
|
||||
}
|
||||
}
|
||||
|
||||
component StringSetting inherits HorizontalBox {
|
||||
in property <string> name;
|
||||
in property <string> placeholder: "" ;
|
||||
in property <length> w: 100px;
|
||||
in-out property <string> value;
|
||||
|
||||
alignment: start;
|
||||
height: 24px;
|
||||
Text {
|
||||
font-size: 12px;
|
||||
height: self.font-size + 8px;
|
||||
vertical-alignment: center;
|
||||
text: name;
|
||||
}
|
||||
LineEdit {
|
||||
font-size: 12px;
|
||||
preferred-width: w + 4px;
|
||||
height: self.font-size + 8px;
|
||||
placeholder-text: placeholder;
|
||||
text <=> value;
|
||||
}
|
||||
}
|
||||
|
||||
export component SettingsPage inherits VerticalBox {
|
||||
SettingsMember {
|
||||
Text {
|
||||
text: "Kavita Settings (sftp)";
|
||||
}
|
||||
|
||||
StringSetting {
|
||||
name: "Server:";
|
||||
placeholder: "XXX.XXX.XXX.XXX";
|
||||
value: "";
|
||||
w: 120px;
|
||||
}
|
||||
StringSetting {
|
||||
name: "Username:";
|
||||
placeholder: "User";
|
||||
value: "";
|
||||
w: 80px;
|
||||
}
|
||||
}
|
||||
SettingsMember {
|
||||
Text {
|
||||
text: "J-Novel Club Settings";
|
||||
}
|
||||
|
||||
if JNCSettingsInterface.login_state == LoginState.LoggedOut : HorizontalLayout {
|
||||
alignment: start;
|
||||
Button {
|
||||
text: "Log In";
|
||||
clicked => {JNCSettingsInterface.start_login()}
|
||||
}
|
||||
}
|
||||
if JNCSettingsInterface.login_state == LoginState.Processing : HorizontalBox {
|
||||
otp := Text {
|
||||
text: JNCSettingsInterface.otp_code;
|
||||
}
|
||||
Button {
|
||||
text: "Copy";
|
||||
clicked => {}
|
||||
}
|
||||
Text {
|
||||
text: "Awaiting Login Confirmation. \{JNCSettingsInterface.login_timeout} until expiry.";
|
||||
}
|
||||
}
|
||||
if JNCSettingsInterface.login_state == LoginState.LoggedIn : VerticalBox {
|
||||
HorizontalBox {
|
||||
Text {
|
||||
text: "Logged In.";
|
||||
}
|
||||
Button {
|
||||
text: "Log Out";
|
||||
clicked => {JNCSettingsInterface.logout()}
|
||||
}
|
||||
}
|
||||
HorizontalBox {
|
||||
Text {
|
||||
text: "Library: \{JNCSettingsInterface.series_count} Series, \{JNCSettingsInterface.book_count} Books loaded";
|
||||
}
|
||||
Button {
|
||||
text: "refresh";
|
||||
clicked => {JNCSettingsInterface.refresh_library()}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsMember {
|
||||
Text {
|
||||
text: "Manual Parser Settings";
|
||||
}
|
||||
}
|
||||
}
|
58
ui/pages/upload_page.slint
Normal file
58
ui/pages/upload_page.slint
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { VerticalBox, Palette, TabWidget, HorizontalBox, Button } from "std-widgets.slint";
|
||||
|
||||
export global UploadPageInterface {
|
||||
in-out property <bool> prev_button_enabled: false;
|
||||
in-out property <bool> next_button_enabled: true;
|
||||
|
||||
pure callback paginate_tabs(int) -> int;
|
||||
}
|
||||
|
||||
export component UploadPage inherits VerticalBox {
|
||||
Rectangle {
|
||||
border-width: 1px;
|
||||
border-radius: 4px;
|
||||
border-color: Palette.border;
|
||||
background: Palette.alternate-background;
|
||||
VerticalLayout {
|
||||
property <int> current_tab: UploadPageInterface.paginate_tabs(tabs.current-index);
|
||||
tabs := TabWidget {
|
||||
Tab {
|
||||
title: "Parser";
|
||||
Text {
|
||||
text: "Parser Settings here";
|
||||
}
|
||||
}
|
||||
Tab {
|
||||
title: "Metadata";
|
||||
Text {
|
||||
text: "Metadata manipulation here";
|
||||
}
|
||||
}
|
||||
Tab {
|
||||
title: "Upload";
|
||||
Text {
|
||||
text: "Upload Stuff here";
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalBox {
|
||||
previous := Button {
|
||||
text: "Previous";
|
||||
enabled: UploadPageInterface.prev_button_enabled;
|
||||
clicked => {tabs.current-index -= 1}
|
||||
}
|
||||
Text {
|
||||
text: "\{current_tab + 1}/3";
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
next := Button {
|
||||
text: "Next";
|
||||
enabled: UploadPageInterface.next_button_enabled;
|
||||
clicked => {tabs.current-index += 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
129
ui/sidebar.slint
Normal file
129
ui/sidebar.slint
Normal file
|
@ -0,0 +1,129 @@
|
|||
import { HorizontalBox, VerticalBox, Palette } from "std-widgets.slint";
|
||||
|
||||
component SideBarItem inherits Rectangle {
|
||||
in property <bool> selected;
|
||||
in property <bool> has-focus;
|
||||
in-out property <string> text <=> label.text;
|
||||
|
||||
callback clicked <=> touch.clicked;
|
||||
|
||||
min-height: l.preferred-height;
|
||||
|
||||
states [
|
||||
pressed when touch.pressed : {
|
||||
state.opacity: 0.8;
|
||||
}
|
||||
hover when touch.has-hover : {
|
||||
state.opacity: 0.6;
|
||||
}
|
||||
selected when root.selected : {
|
||||
state.opacity: 1;
|
||||
}
|
||||
focused when root.has-focus : {
|
||||
state.opacity: 0.8;
|
||||
}
|
||||
]
|
||||
|
||||
state := Rectangle {
|
||||
opacity: 0;
|
||||
background: Palette.background;
|
||||
|
||||
animate opacity { duration: 150ms; }
|
||||
}
|
||||
|
||||
l := HorizontalBox {
|
||||
y: (parent.height - self.height) / 2;
|
||||
spacing: 0px;
|
||||
|
||||
label := Text {
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
export component SideBar inherits Rectangle {
|
||||
in property <[string]> model: [];
|
||||
in property <string> title <=> label.text;
|
||||
out property <int> current-item: 0;
|
||||
out property <int> current-focused: fs.has-focus ? fs.focused-tab : -1; // The currently focused tab
|
||||
|
||||
width: 180px;
|
||||
forward-focus: fs;
|
||||
accessible-role: tab;
|
||||
accessible-delegate-focus: root.current-focused >= 0 ? root.current-focused : root.current-item;
|
||||
|
||||
Rectangle {
|
||||
background: Palette.alternate-background;
|
||||
border-width: 1px;
|
||||
border-radius: 4px;
|
||||
border-color: Palette.border;
|
||||
|
||||
fs := FocusScope {
|
||||
key-pressed(event) => {
|
||||
if (event.text == "\n") {
|
||||
root.current-item = root.current-focused;
|
||||
return accept;
|
||||
}
|
||||
if (event.text == Key.UpArrow) {
|
||||
self.focused-tab = Math.max(self.focused-tab - 1, 0);
|
||||
return accept;
|
||||
}
|
||||
if (event.text == Key.DownArrow) {
|
||||
self.focused-tab = Math.min(self.focused-tab + 1, root.model.length - 1);
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
|
||||
key-released(event) => {
|
||||
if (event.text == " ") {
|
||||
root.current-item = root.current-focused;
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
|
||||
property <int> focused-tab: 0;
|
||||
|
||||
x: 0;
|
||||
width: 0; // Do not react on clicks
|
||||
}
|
||||
}
|
||||
|
||||
VerticalBox {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
alignment: start;
|
||||
|
||||
label := Text {
|
||||
font-size: 16px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
navigation := VerticalLayout {
|
||||
alignment: start;
|
||||
vertical-stretch: 0;
|
||||
for item[index] in root.model : SideBarItem {
|
||||
clicked => { root.current-item = index; }
|
||||
|
||||
has-focus: index == root.current-focused;
|
||||
text: item;
|
||||
selected: index == root.current-item;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
bottom := VerticalBox {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
|
||||
@children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue