Compare commits

...

4 commits

Author SHA1 Message Date
d531356693
Initial Commit, Basic Navigation implemented 2024-06-20 00:14:19 +02:00
60c3a2da87
.idea config files 2024-06-20 00:13:20 +02:00
d110c4bd48
Updated .gitignore 2024-06-20 00:13:11 +02:00
1d7c7ce245
Changed Readme 2024-06-20 00:12:58 +02:00
24 changed files with 6972 additions and 4 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# These are backup files generated by rustfmt
**/*.rs.bk

5
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-349a7812:1900e527711:-7ffe" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kavita-upload-helper.iml" filepath="$PROJECT_DIR$/.idea/kavita-upload-helper.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

5628
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

21
Cargo.toml Normal file
View 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"

View file

@ -1,5 +1,46 @@
# kavita-upload-helper
# Kavita Upload Helper
Tool for easily uploading new chapters/series to a Kavita server.
Requires sftp access to the Kavita library directories as well as Kavita Admin access.
Tool for easily uploading new chapters/series to a Kavita server.
Requires sftp access to the Kavita library directories as well as Kavita Admin access.
# Slint Rust Template
A template for a Rust application that's using [Slint](https://slint.rs) for the user interface.
## About
This template helps you get started developing a Rust application with Slint as toolkit
for the user interface. It demonstrates the integration between the `.slint` UI markup and
Rust code, how to trigger react to callbacks, get and set properties and use basic widgets.
## Usage
1. Install Rust by following the [Rust Getting Started Guide](https://www.rust-lang.org/learn/get-started).
Once this is done, you should have the ```rustc``` compiler and the ```cargo``` build system installed in your path.
2. Install [`cargo-generate`](https://github.com/cargo-generate/cargo-generate)
```
cargo install cargo-generate
```
3. Set up a sample project with this template
```
cargo generate --git https://github.com/slint-ui/slint-rust-template --name my-project
cd my-project
```
3. Build with cargo
```
cargo build
```
4. Run the application binary
```
cargo run
```
We recommend using an IDE for development, along with our [LSP-based IDE integration for `.slint` files](https://github.com/slint-ui/slint/blob/master/tools/lsp/README.md). You can also load this project directly in [Visual Studio Code](https://code.visualstudio.com) and install our [Slint extension](https://marketplace.visualstudio.com/items?itemName=Slint.slint).
## Next Steps
We hope that this template helps you get started and you enjoy exploring making user interfaces with Slint. To learn more
about the Slint APIs and the `.slint` markup language check out our [online documentation](https://slint.dev/docs).
Don't forget to edit this README to replace it by yours

5
build.rs Normal file
View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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
}
}

View 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
View 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
View 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
View 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";
}
}
}

View 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";
}
}
}

View 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
View 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
}
}
}
}