Compare commits

..

54 Commits

Author SHA1 Message Date
Aria Moradi da44d3b2b4 bump to v0.4.7
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 17s
2021-08-21 06:30:45 +04:30
Aria Moradi 99ec2aca6a update WebUI 2021-08-21 06:27:14 +04:30
Aria Moradi 6c278604ec got rid of legacy backups 2021-08-21 06:23:58 +04:30
Aria Moradi 1e094a467a not TODO 2021-08-21 06:13:55 +04:30
Aria Moradi 978ccfeeba the true commit 2021-08-21 06:12:22 +04:30
Aria Moradi e93d66d8a1 add backup validation endpoints 2021-08-21 06:08:17 +04:30
Aria Moradi c29a749833 proto export support 2021-08-21 05:48:05 +04:30
Aria Moradi b08d5d1261 all forms of Default are illegal 2021-08-21 05:10:09 +04:30
Aria Moradi 9b129789e9 all forms of Default are illegal 2021-08-21 05:05:01 +04:30
Aria Moradi a76a6d2798 creating a categort named Default is illegal 2021-08-21 03:58:46 +04:30
Aria Moradi 086a760378 update WebUI 2021-08-21 03:55:22 +04:30
Aria Moradi f78c8d4fd8 Default is now a category, no more library 2021-08-21 03:54:16 +04:30
Aria Moradi 7b91489997 better print 2021-08-21 01:20:18 +04:30
Aria Moradi 36a8980c95 TODO block no longer relevant 2021-08-21 01:11:47 +04:30
Aria Moradi 7c65640cb7 include extra chapter data in restore 2021-08-21 00:37:50 +04:30
Aria Moradi d70e68495a restoring with clean db and not installed extensions work 2021-08-21 00:18:03 +04:30
Aria Moradi 2586202772 better comments 2021-08-19 21:11:53 +04:30
Aria Moradi b5f771368a put back dex2jar where it should be 2021-08-19 03:15:35 +04:30
Aria Moradi 0c28320ce3 better debug launcher 2021-08-19 02:54:36 +04:30
Aria Moradi c8b4fbc36b new observation 2021-08-19 02:06:48 +04:30
Aria Moradi e9b07849fe move dex2jar to server, lint 2021-08-19 01:47:26 +04:30
Aria Moradi 409260af6f Merge pull request #176 from Suwayomi/protobuf
protobuf backup support
2021-08-19 00:47:32 +04:30
Aria Moradi d3d53d1a4e initial support for portobuf backup 2021-08-19 00:46:45 +04:30
Aria Moradi e2db191f70 consolidate the external backup api 2021-08-18 23:34:39 +04:30
Aria Moradi d61816734d add all proto backup classes we need 2021-08-18 22:58:56 +04:30
Aria Moradi f4dad8058f Merge branch 'master' into protobuf 2021-08-18 21:51:12 +04:30
Aria Moradi 70bdb375c3 Update README.md 2021-08-18 19:10:46 +04:30
Aria Moradi e724ab0a29 Update README.md 2021-08-18 19:10:16 +04:30
Aria Moradi 7d0ee2ac11 Update README.md 2021-08-18 06:17:04 +04:30
Aria Moradi 59b7e852e2 Update README.md 2021-08-18 06:15:44 +04:30
Aria Moradi b2eb1a391d Update README.md 2021-08-18 05:31:34 +04:30
Aria Moradi 9b3aee98d3 Update CONTRIBUTING.md 2021-08-18 05:30:19 +04:30
Aria Moradi 0476f4144c Update CONTRIBUTING.md 2021-08-18 05:29:24 +04:30
Aria Moradi ed77f45fae Update CONTRIBUTING.md 2021-08-18 05:28:26 +04:30
Aria Moradi 0cd529e746 Update CONTRIBUTING.md 2021-08-18 05:26:55 +04:30
Aria Moradi 5969048318 Update README.md 2021-08-18 05:19:42 +04:30
Aria Moradi d1a7f8baa0 Update README.md 2021-08-18 05:04:22 +04:30
Aria Moradi 18dc936002 Update README.md (#177)
* Update README.md

* Update README.md
2021-08-18 04:50:10 +04:30
Aria Moradi b4b7b5d572 bump to v0.4.6
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 17s
2021-08-18 04:29:14 +04:30
Aria Moradi 291c2e692d clean up build.gradle files, move constants to buildSrc 2021-08-18 04:24:58 +04:30
Aria Moradi 8a9a4f21b1 remove some stuff we don't use 2021-08-18 04:06:13 +04:30
Aria Moradi cd31332b39 better download progress 2021-08-18 03:55:52 +04:30
Aria Moradi cc8d2162a0 fix compile issue 2021-08-18 02:59:07 +04:30
Aria Moradi e6313cdc67 yeet improvments from jui 2021-08-18 01:21:17 +04:30
Aria Moradi 5af64892e7 Merge branch 'master' into protobuf 2021-08-18 00:28:42 +04:30
Aria Moradi a5578a7ac7 fix compile warnings 2021-08-17 23:54:02 +04:30
Aria Moradi fcdda6406e update dependencies 2021-08-17 23:53:41 +04:30
Aria Moradi 9bdd9f8aa6 better endpoint urls based on suggestion from @mgn-norm 2021-08-17 21:45:19 +04:30
Aria Moradi f3856f051b protobuf backup endpoints 2021-08-17 20:09:31 +04:30
Aria Moradi d3a6662c60 make it compile 2021-08-15 03:16:13 +04:30
Aria Moradi 5474eddf84 fix some inconsitencies 2021-08-15 02:41:23 +04:30
Aria Moradi b666cd47d4 fix shouldOverwrite 2021-08-15 00:25:08 +04:30
Aria Moradi 8a986383fe fixes #175, better webUI download task 2021-08-14 17:10:41 +04:30
Aria Moradi 9fa17f617e add anime seach functionality 2021-08-11 08:47:07 +04:30
72 changed files with 1179 additions and 1040 deletions
-4
View File
@@ -1,4 +0,0 @@
dependencies {
// Config API, moved to the global build.gradle
// implementation("com.typesafe:config:1.4.0")
}
+2 -11
View File
@@ -20,16 +20,9 @@ dependencies {
// Android stub library // Android stub library
implementation(fileTree("lib/")) implementation(fileTree("lib/"))
// Android JAR libs
// compileOnly( fileTree(dir: new File(rootProject.rootDir, "libs/other"), include: "*.jar")
// JSON // JSON
compileOnly("com.google.code.gson:gson:2.8.6") compileOnly("com.google.code.gson:gson:2.8.6")
// Javassist
compileOnly("org.javassist:javassist:3.27.0-GA")
// XML // XML
compileOnly(group= "xmlpull", name= "xmlpull", version= "1.1.3.1") compileOnly(group= "xmlpull", name= "xmlpull", version= "1.1.3.1")
@@ -43,10 +36,8 @@ dependencies {
compileOnly("androidx.annotation:annotation:1.2.0-alpha01") compileOnly("androidx.annotation:annotation:1.2.0-alpha01")
// substitute for duktape-android // substitute for duktape-android
// 'org.mozilla:rhino' includes some code that we don't need so use 'org.mozilla:rhino-runtime' instead implementation("org.mozilla:rhino-runtime:1.7.13") // slimmer version of 'org.mozilla:rhino'
implementation("org.mozilla:rhino-runtime:1.7.13") implementation("org.mozilla:rhino-engine:1.7.13") // provides the same interface as 'javax.script' a.k.a Nashorn
// 'org.mozilla:rhino-engine' provides the same interface as 'javax.script' a.k.a Nashorn
implementation("org.mozilla:rhino-engine:1.7.13")
// Kotlin wrapper around Java Preferences, makes certain things easier // Kotlin wrapper around Java Preferences, makes certain things easier
val multiplatformSettingsVersion = "0.7.7" val multiplatformSettingsVersion = "0.7.7"
@@ -1,5 +0,0 @@
package kotlinx.coroutines.experimental.android
import kotlinx.coroutines.GlobalScope
val UI = GlobalScope.coroutineContext
@@ -2,7 +2,6 @@ package xyz.nulldev.androidcompat
import org.kodein.di.DI import org.kodein.di.DI
import org.kodein.di.conf.global import org.kodein.di.conf.global
import xyz.nulldev.androidcompat.bytecode.ModApplier
import xyz.nulldev.androidcompat.config.ApplicationInfoConfigModule import xyz.nulldev.androidcompat.config.ApplicationInfoConfigModule
import xyz.nulldev.androidcompat.config.FilesConfigModule import xyz.nulldev.androidcompat.config.FilesConfigModule
import xyz.nulldev.androidcompat.config.SystemConfigModule import xyz.nulldev.androidcompat.config.SystemConfigModule
@@ -12,12 +11,7 @@ import xyz.nulldev.ts.config.GlobalConfigManager
* Initializes the Android compatibility module * Initializes the Android compatibility module
*/ */
class AndroidCompatInitializer { class AndroidCompatInitializer {
val modApplier by lazy { ModApplier() }
fun init() { fun init() {
modApplier.apply()
DI.global.addImport(AndroidCompatModule().create()) DI.global.addImport(AndroidCompatModule().create())
//Register config modules //Register config modules
@@ -1,22 +0,0 @@
package xyz.nulldev.androidcompat.bytecode
import javassist.CtClass
import mu.KotlinLogging
/**
* Applies Javassist modifications
*/
class ModApplier {
val logger = KotlinLogging.logger {}
fun apply() {
logger.info { "Applying Javassist mods..." }
val modifiedClasses = mutableListOf<CtClass>()
modifiedClasses.forEach {
it.toClass()
}
}
}
@@ -4,11 +4,21 @@ import java.io.InputStream
import java.io.Reader import java.io.Reader
import java.math.BigDecimal import java.math.BigDecimal
import java.net.URL import java.net.URL
import java.sql.*
import java.sql.Array import java.sql.Array
import java.sql.Blob
import java.sql.Clob
import java.sql.Date import java.sql.Date
import java.util.* import java.sql.NClob
import java.sql.Ref
import java.sql.ResultSet
import java.sql.ResultSetMetaData
import java.sql.RowId
import java.sql.SQLXML
import java.sql.Time
import java.sql.Timestamp
import java.util.Calendar
@Suppress("UNCHECKED_CAST")
class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent { class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
private val cachedContent = mutableListOf<ResultSetEntry>() private val cachedContent = mutableListOf<ResultSetEntry>()
@@ -18,7 +18,7 @@ class ServiceSupport {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
fun startService(context: Context, intent: Intent) { fun startService(@Suppress("UNUSED_PARAMETER") context: Context, intent: Intent) {
val name = intentToClassName(intent) val name = intentToClassName(intent)
logger.debug { "Starting service: $name" } logger.debug { "Starting service: $name" }
@@ -35,7 +35,7 @@ class ServiceSupport {
} }
} }
fun stopService(context: Context, intent: Intent) { fun stopService(@Suppress("UNUSED_PARAMETER") context: Context, intent: Intent) {
val name = intentToClassName(intent) val name = intentToClassName(intent)
stopService(name) stopService(name)
} }
@@ -25,6 +25,7 @@ object KodeinGlobalHelper {
* Get a dependency * Get a dependency
*/ */
@JvmStatic @JvmStatic
@Suppress("UNCHECKED_CAST")
fun <T : Any> instance(type: Class<T>, kodein: DI? = null): T { fun <T : Any> instance(type: Class<T>, kodein: DI? = null): T {
return when(type) { return when(type) {
AndroidFiles::class.java -> { AndroidFiles::class.java -> {
+21 -36
View File
@@ -2,51 +2,36 @@
## Where should I start? ## Where should I start?
Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap. Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap.
**Note to potential contributors:** Notify the developers on Suwayomi discord (#programming channel) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature. **Note to potential contributors:** Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#programming channel) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature.
## How does Tachidesk work? ## How does Tachidesk-Server work?
This project has two components: This project has two components:
1. **server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run apk extensions. All this concludes to serving a REST API to `webUI`. 1. **Server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run jar libraries converted from apk extensions. All this concludes to serving a REST API.
2. **webUI:** A react SPA(`create-react-app`) project that works with the server to do the presentation. 2. **WebUI:** A react SPA(`create-react-app`) project that works with the server to do the presentation located at https://github.com/Suwayomi/Tachidesk-WebUI
## Why a web app? ## Why a web server app?
This structure is chosen to This structure is chosen to
- Achieve the maximum multi-platform-ness - Achieve the maximum multi-platform-ness
- Gives the ability to acces Tachidesk from a remote web browser e.g. your phone, tablet or smart TV - Gives the ability to acces Tachidesk-Server from a remote client e.g. your phone, tablet or smart TV
- Eaise development of alternative user intefaces for Tachidesk - Eaise development of user intefaces for Tachidesk
## User Interfaces for Tachidesk server
Currently, there are three known interfaces for Tachidesk:
1. [webUI](https://github.com/Suwayomi/Tachidesk/tree/master/webUI/react): The react SPA that Tachidesk is traditionally shipped with.
2. [TachideskJUI](https://github.com/Suwayomi/TachideskJUI): A Jetbrains Compose Native app, re-uses components made for the upcoming Tachiyomi 1.x
3. [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stages of development.
## Building from source ## Building from source
### Prerequisites ### Prerequisites
You need these software packages installed in order to build the project You need these software packages installed in order to build the project
### Server
- Java Development Kit and Java Runtime Environment version 8 or newer(both Oracle JDK and OpenJDK works) - Java Development Kit and Java Runtime Environment version 8 or newer(both Oracle JDK and OpenJDK works)
- Android stubs jar - Android stubs jar
- Manual download: Download [android.jar](https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`. - **Manual download:** Download [android.jar](https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`.
- Automated download: Run `AndroidCompat/getAndroid.sh`(MacOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows) from project's root directory to download and rebuild the jar file from Google's repository. - **Automated download:** Run `AndroidCompat/getAndroid.sh`(MacOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows) from project's root directory to download and rebuild the jar file from Google's repository.
### webUI
- Nodejs LTS or latest
- Yarn
- Git
### building the full-blown jar
Run `./gradlew :webUI:copyBuild server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building without `webUI` bundled(server only)
Delete the `server/src/main/resources/react` directory if exists from previous runs, then run `./gradlew server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building the Windows package
First Build the jar, then cd into the `scripts` directory and run `./windows<bits>-bundler.sh` (or `./windows<bits>-bundler.ps1` if you are on windows), the resulting built zip package file will be `server/build/Tachidesk-vX.Y.Z-rxxx-win64.zip`.
## Running in development mode
First satisfy [the prerequisites](#prerequisites)
### server
run `./gradlew :server:run --stacktrace` to run the server
### webUI
How to do it is described in `webUI/react/README.md` but for short,
first cd into `webUI/react` then run `yarn` to install the node modules(do this only once)
then `yarn start` to start the development server, if a new browser window doesn't get opened automatically,
then open `http://127.0.0.1:3000` in a modern browser. This is a `create-react-app` project
and supports HMR and all the other goodies you'll need.
### building the full-blown jar (Tachidesk-Server + Tachidesk-WebUI bundle)
Run `./gradlew server:downloadWebUI server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx.jar`.
### building without `webUI` bundled (server only)
Delete `server/src/main/resources/WebUI.zip` if exists from previous runs, then run `./gradlew server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx.jar`.
### building the Windows package
First Build the jar, then cd into the `scripts` directory and run `./windows-bundler.sh win32` or `./windows-bundler.sh win64` depending on the target architecture, the resulting built zip package file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx-winXX.zip`.
## Running in development mode
run `./gradlew :server:run --stacktrace` to run the server
+25 -12
View File
@@ -3,30 +3,43 @@
|-------|----------|---------|---------| |-------|----------|---------|---------|
| ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) | | ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) |
# Tachidesk # Tachidesk-Server is a server app! You may not want to Download Tachidesk-Server directly.
Yes, you need a client/user interface app as a front-end for Tachidesk-Server, if you Directly Download Tachidesk-Server you'll get a bundled version of [Tachodesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.
Here's a list of known clients/user interfaces for Tachidesk-Server:
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The "official" front-end for Tachidesk-Server, A native desktop Application.
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/electrion front-end that Tachidesk is traditionally shipped with.
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development.
# What is Tachidesk then?
<img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/> <img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/>
A free and open source manga reader that runs extensions built for [Tachiyomi](https://tachiyomi.org/). A free and open source manga reader server that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
Tachidesk is an independent Tachiyomi compatible software and is **not a Fork of** Tachiyomi. Tachidesk is an independent Tachiyomi compatible software and is **not a Fork of** Tachiyomi.
Tachidesk is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions. Tachidesk-Server is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions.
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature. Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
**Tachidesk needs serious front-end dev help for it's reader and other parts, if you like the app and want to see it become better please don't hesitate to contribute some code!**
## Is this application usable? Should I test it? ## Is this application usable? Should I test it?
Here is a list of current features: Here is a list of current features:
- Installing and executing Tachiyomi's Extensions, So you'll get the same sources. - From Tachiyomi
- A library to save your mangas and categories to put them into. - Installing and executing Tachiyomi's Extensions, So you'll get the same sources
- Searching and browsing installed sources. - A library to save your mangas and categories to put them into
- A decent chapter reader. - Searching and browsing installed sources
- Ability to download Manga for offline read - Ability to download Manga for offline read
- Backup and restore support powered by Tachiyomi Legacy Backups - Backup and restore support powered by Tachiyomi Legacy Backups
- From Aniyomi
- Installing and executing Aniyomi's Extensions
- Searching and browsing installed sources.
- Viewing an anime and it's episodes
**Note:** Keep in mind that Tachidesk is alpha software and can break rarely and/or with each update. See [Troubleshooting](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting) if it happens. **Note:** These are capabilities of Tachidesk-Server, the actual working support is provided by each front-end app, checkout their respective readme for more info.
**Note:** Tachidesk-Server is alpha software and can break rarely and/or with each update. See [Troubleshooting](https://github.com/Suwayomi/Tachidesk-Server/wiki/Troubleshooting) if it happens.
## Downloading and Running the app ## Downloading and Running the app
### All Operating Systems ### All Operating Systems
+5 -4
View File
@@ -1,8 +1,8 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
kotlin("jvm") version "1.4.32" kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version "1.4.32" apply false kotlin("plugin.serialization") version kotlinVersion
} }
allprojects { allprojects {
@@ -46,12 +46,12 @@ configure(projects) {
testImplementation(kotlin("test-junit5")) testImplementation(kotlin("test-junit5"))
// coroutines // coroutines
val coroutinesVersion = "1.4.3" val coroutinesVersion = "1.5.0"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
val kotlinSerializationVersion = "1.1.0" val kotlinSerializationVersion = "1.2.1"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
@@ -80,6 +80,7 @@ configure(projects) {
implementation("net.harawata:appdirs:1.2.1") implementation("net.harawata:appdirs:1.2.1")
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon // dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
// note: watch https://github.com/ThexXTURBOXx/dex2jar for future developments
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon") implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
// APK parser // APK parser
+11
View File
@@ -0,0 +1,11 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
dependencies {
implementation("net.lingala.zip4j:zip4j:2.9.0")
}
+33
View File
@@ -0,0 +1,33 @@
import java.io.BufferedReader
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
const val kotlinVersion = "1.5.21"
const val MainClass = "suwayomi.tachidesk.MainKt"
// should be bumped with each stable release
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.4.7"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r36"
// counts commits on the the master branch
val tachideskRevision = runCatching {
System.getenv("ProductRevision") ?: Runtime
.getRuntime()
.exec("git rev-list HEAD --count")
.let { process ->
process.waitFor()
val output = process.inputStream.use {
it.bufferedReader().use(BufferedReader::readText)
}
process.destroy()
"r" + output.trim()
}
}.getOrDefault("r0")
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
@@ -1 +1,7 @@
:: cleaner output
@echo off
jre\bin\java -Dsuwayomi.tachidesk.config.server.debugLogsEnabled=true -jar Tachidesk.jar jre\bin\java -Dsuwayomi.tachidesk.config.server.debugLogsEnabled=true -jar Tachidesk.jar
:: Prevent cmd from closing when Tachidesk crashes
pause
@@ -1 +1 @@
jre\bin\javaw "-Dsuwayomi.tachidesk.config.server.webInterface=electron" "-Dsuwayomi.tachidesk.config.server.electronPath=electron/electron.exe" -jar Tachidesk.jar jre\bin\javaw "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron" "-Dsuwayomi.tachidesk.config.server.electronPath=electron/electron.exe" -jar Tachidesk.jar
+51 -47
View File
@@ -1,15 +1,16 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.tasks.FormatTask import org.jmailen.gradle.kotlinter.tasks.FormatTask
import org.jmailen.gradle.kotlinter.tasks.LintTask import org.jmailen.gradle.kotlinter.tasks.LintTask
import java.io.BufferedReader
import java.time.Instant import java.time.Instant
plugins { plugins {
application application
kotlin("plugin.serialization")
id("com.github.johnrengelman.shadow") version "7.0.0" id("com.github.johnrengelman.shadow") version "7.0.0"
id("org.jmailen.kotlinter") version "3.4.3" id("org.jmailen.kotlinter") version "3.4.3"
id("de.fuerstenau.buildconfig") version "1.1.8" id("com.github.gmazzo.buildconfig") version "3.0.2"
} }
repositories { repositories {
@@ -53,7 +54,6 @@ dependencies {
implementation("com.dorkbox:Utilities:1.9") implementation("com.dorkbox:Utilities:1.9")
// dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference // dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference
implementation("com.github.inorichi.injekt:injekt-core:65b0440") implementation("com.github.inorichi.injekt:injekt-core:65b0440")
implementation("com.squareup.okhttp3:okhttp:4.9.1") implementation("com.squareup.okhttp3:okhttp:4.9.1")
@@ -81,14 +81,13 @@ dependencies {
// implementation(fileTree("lib/")) // implementation(fileTree("lib/"))
} }
val MainClass = "suwayomi.tachidesk.MainKt"
application { application {
mainClass.set(MainClass) mainClass.set(MainClass)
// for testing electron // uncomment for testing electron
// applicationDefaultJvmArgs = listOf( // applicationDefaultJvmArgs = listOf(
// "-Dsuwayomi.tachidesk.webInterface=electron", // "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron",
// "-Dsuwayomi.tachidesk.electronPath=/usr/bin/electron" // "-Dsuwayomi.tachidesk.config.server.electronPath=/usr/bin/electron"
// ) // )
} }
@@ -100,56 +99,40 @@ sourceSets {
} }
} }
// should be bumped with each stable release
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.4.5"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r23"
// counts commit count on master
val tachideskRevision = runCatching {
System.getenv("ProductRevision") ?: Runtime
.getRuntime()
.exec("git rev-list HEAD --count")
.let { process ->
process.waitFor()
val output = process.inputStream.use {
it.bufferedReader().use(BufferedReader::readText)
}
process.destroy()
"r" + output.trim()
}
}.getOrDefault("r0")
buildConfig { buildConfig {
clsName = "BuildConfig" className("BuildConfig")
packageName = "suwayomi.tachidesk.server" packageName("suwayomi.tachidesk.server")
useKotlinOutput()
buildConfigField("String", "NAME", rootProject.name) fun quoteWrap(obj: Any): String = """"$obj""""
buildConfigField("String", "VERSION", tachideskVersion)
buildConfigField("String", "REVISION", tachideskRevision) buildConfigField("String", "NAME", quoteWrap(rootProject.name))
buildConfigField("String", "BUILD_TYPE", if (System.getenv("ProductBuildType") == "Stable") "Stable" else "Preview") buildConfigField("String", "VERSION", quoteWrap(tachideskVersion))
buildConfigField("String", "REVISION", quoteWrap(tachideskRevision))
buildConfigField("String", "BUILD_TYPE", quoteWrap(if (System.getenv("ProductBuildType") == "Stable") "Stable" else "Preview"))
buildConfigField("long", "BUILD_TIME", Instant.now().epochSecond.toString()) buildConfigField("long", "BUILD_TIME", Instant.now().epochSecond.toString())
buildConfigField("String", "WEBUI_REPO", "https://github.com/Suwayomi/Tachidesk-WebUI-preview") buildConfigField("String", "WEBUI_REPO", quoteWrap("https://github.com/Suwayomi/Tachidesk-WebUI-preview"))
buildConfigField("String", "WEBUI_TAG", webUIRevisionTag) buildConfigField("String", "WEBUI_TAG", quoteWrap(webUIRevisionTag))
buildConfigField("String", "GITHUB", "https://github.com/Suwayomi/Tachidesk") buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Tachidesk"))
buildConfigField("String", "DISCORD", "https://discord.gg/DDZdqZWaHA") buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
} }
tasks { tasks {
shadowJar { shadowJar {
manifest { manifest {
attributes( attributes(
mapOf( mapOf(
"Main-Class" to MainClass, "Main-Class" to MainClass,
"Implementation-Title" to rootProject.name, "Implementation-Title" to rootProject.name,
"Implementation-Vendor" to "The Suwayomi Project", "Implementation-Vendor" to "The Suwayomi Project",
"Specification-Version" to tachideskVersion, "Specification-Version" to tachideskVersion,
"Implementation-Version" to tachideskRevision "Implementation-Version" to tachideskRevision
) )
) )
} }
archiveBaseName.set(rootProject.name) archiveBaseName.set(rootProject.name)
@@ -159,9 +142,10 @@ tasks {
withType<KotlinCompile> { withType<KotlinCompile> {
kotlinOptions { kotlinOptions {
freeCompilerArgs = listOf( freeCompilerArgs = listOf(
"-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi" "-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
) )
} }
} }
@@ -175,7 +159,7 @@ tasks {
} }
named("run") { named("run") {
dependsOn("formatKotlin", "lintKotlin") dependsOn("formatKotlin", "lintKotlin", "downloadWebUI")
} }
named<Copy>("processResources") { named<Copy>("processResources") {
@@ -186,6 +170,26 @@ tasks {
register<de.undercouch.gradle.tasks.download.Download>("downloadWebUI") { register<de.undercouch.gradle.tasks.download.Download>("downloadWebUI") {
src("https://github.com/Suwayomi/Tachidesk-WebUI-preview/releases/download/$webUIRevisionTag/Tachidesk-WebUI-$webUIRevisionTag.zip") src("https://github.com/Suwayomi/Tachidesk-WebUI-preview/releases/download/$webUIRevisionTag/Tachidesk-WebUI-$webUIRevisionTag.zip")
dest("src/main/resources/WebUI.zip") dest("src/main/resources/WebUI.zip")
fun shouldOverwrite(): Boolean {
val zipPath = project.projectDir.absolutePath + "/src/main/resources/WebUI.zip"
val zipFile = net.lingala.zip4j.ZipFile(zipPath)
var shouldOverwrite = true
if (zipFile.isValidZipFile) {
val zipRevision = zipFile.getInputStream(zipFile.getFileHeader("revision")).bufferedReader().use {
it.readText().trim()
}
if (zipRevision == webUIRevisionTag)
shouldOverwrite = false
}
return shouldOverwrite
}
overwrite(shouldOverwrite())
} }
withType<LintTask> { withType<LintTask> {
@@ -54,7 +54,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* Note the generated id sets the sign bit to 0. * Note the generated id sets the sign bit to 0.
*/ */
override val id by lazy { override val id by lazy {
val key = "${name.toLowerCase()}/$lang/$versionId" val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
} }
@@ -80,7 +80,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
/** /**
* Visible name of the source. * Visible name of the source.
*/ */
override fun toString() = "$name (${lang.toUpperCase()})" override fun toString() = "$name (${lang.uppercase()})"
/** /**
* Returns an observable containing a page with a list of anime. Normally it's not needed to * Returns an observable containing a page with a list of anime. Normally it's not needed to
@@ -18,6 +18,7 @@ import okhttp3.OkHttpClient
// import uy.kohesive.injekt.injectLazy // import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@Suppress("UNUSED_PARAMETER")
class NetworkHelper(context: Context) { class NetworkHelper(context: Context) {
// private val preferences: PreferencesHelper by injectLazy() // private val preferences: PreferencesHelper by injectLazy()
@@ -110,6 +110,7 @@ fun Call.asObservableSuccess(): Observable<Response> {
// return progressClient.newCall(request) // return progressClient.newCall(request)
// } // }
@Suppress("UNUSED_PARAMETER")
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder() val progressClient = newBuilder()
// .cache(null) // .cache(null)
@@ -61,3 +61,30 @@ interface SManga : Serializable {
} }
} }
} }
// fun SManga.toMangaInfo(): MangaInfo {
// return MangaInfo(
// key = this.url,
// title = this.title,
// artist = this.artist ?: "",
// author = this.author ?: "",
// description = this.description ?: "",
// genres = this.genre?.split(", ") ?: emptyList(),
// status = this.status,
// cover = this.thumbnail_url ?: ""
// )
// }
//
// fun MangaInfo.toSManga(): SManga {
// val mangaInfo = this
// return SManga.create().apply {
// url = mangaInfo.key
// title = mangaInfo.title
// artist = mangaInfo.artist
// author = mangaInfo.author
// description = mangaInfo.description
// genre = mangaInfo.genres.joinToString(", ")
// status = mangaInfo.status
// thumbnail_url = mangaInfo.cover
// }
// }
@@ -55,7 +55,7 @@ abstract class HttpSource : CatalogueSource {
* Note the generated id sets the sign bit to 0. * Note the generated id sets the sign bit to 0.
*/ */
override val id by lazy { override val id by lazy {
val key = "${name.toLowerCase()}/$lang/$versionId" val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
} }
@@ -81,7 +81,7 @@ abstract class HttpSource : CatalogueSource {
/** /**
* Visible name of the source. * Visible name of the source.
*/ */
override fun toString() = "$name (${lang.toUpperCase()})" override fun toString() = "$name (${lang.uppercase()})"
/** /**
* Returns an observable containing a page with a list of manga. Normally it's not needed to * Returns an observable containing a page with a list of manga. Normally it's not needed to
@@ -14,6 +14,7 @@ import suwayomi.tachidesk.anime.impl.AnimeList.getAnimeList
import suwayomi.tachidesk.anime.impl.Episode.getEpisode import suwayomi.tachidesk.anime.impl.Episode.getEpisode
import suwayomi.tachidesk.anime.impl.Episode.getEpisodeList import suwayomi.tachidesk.anime.impl.Episode.getEpisodeList
import suwayomi.tachidesk.anime.impl.Episode.modifyEpisode import suwayomi.tachidesk.anime.impl.Episode.modifyEpisode
import suwayomi.tachidesk.anime.impl.Search.sourceSearch
import suwayomi.tachidesk.anime.impl.Source.getAnimeSource import suwayomi.tachidesk.anime.impl.Source.getAnimeSource
import suwayomi.tachidesk.anime.impl.Source.getSourceList import suwayomi.tachidesk.anime.impl.Source.getSourceList
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIcon import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIcon
@@ -219,13 +220,13 @@ object AnimeAPI {
// ctx.json(sourceGlobalSearch(searchTerm)) // ctx.json(sourceGlobalSearch(searchTerm))
// } // }
// //
// // single source search // single source search
// app.get("/api/v1/source/:sourceId/search/:searchTerm/:pageNum") { ctx -> app.get("/api/v1/anime/source/:sourceId/search/:searchTerm/:pageNum") { ctx ->
// val sourceId = ctx.pathParam("sourceId").toLong() val sourceId = ctx.pathParam("sourceId").toLong()
// val searchTerm = ctx.pathParam("searchTerm") val searchTerm = ctx.pathParam("searchTerm")
// val pageNum = ctx.pathParam("pageNum").toInt() val pageNum = ctx.pathParam("pageNum").toInt()
// ctx.json(JavalinSetup.future { sourceSearch(sourceId, searchTerm, pageNum) }) ctx.json(future { sourceSearch(sourceId, searchTerm, pageNum) })
// } }
// //
// // source filter list // // source filter list
// app.get("/api/v1/source/:sourceId/filters/") { ctx -> // app.get("/api/v1/source/:sourceId/filters/") { ctx ->
@@ -0,0 +1,21 @@
package suwayomi.tachidesk.anime.impl
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import suwayomi.tachidesk.anime.impl.AnimeList.processEntries
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.PagedAnimeListDataClass
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
object Search {
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedAnimeListDataClass {
val source = getAnimeHttpSource(sourceId)
val searchManga = source.fetchSearchAnime(pageNum, searchTerm, source.getFilterList()).awaitSingle()
return searchManga.processEntries(sourceId)
}
}
@@ -138,7 +138,7 @@ object Extension {
else -> "all" else -> "all"
} }
val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Tachiyomi: ") val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Aniyomi: ")
// update extension info // update extension info
transaction { transaction {
@@ -32,7 +32,7 @@ object ExtensionGithubApi {
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
} }
.map { element -> .map { element ->
val name = element["name"].string.substringAfter("Tachiyomi: ") val name = element["name"].string.substringAfter("Aniyomi: ")
val pkgName = element["pkg"].string val pkgName = element["pkg"].string
val apkName = element["apk"].string val apkName = element["apk"].string
val versionName = element["version"].string val versionName = element["version"].string
@@ -7,7 +7,6 @@ package suwayomi.tachidesk.manga
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.delete import io.javalin.apibuilder.ApiBuilder.delete
import io.javalin.apibuilder.ApiBuilder.get import io.javalin.apibuilder.ApiBuilder.get
import io.javalin.apibuilder.ApiBuilder.patch import io.javalin.apibuilder.ApiBuilder.patch
@@ -15,14 +14,14 @@ import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.apibuilder.ApiBuilder.post import io.javalin.apibuilder.ApiBuilder.post
import io.javalin.apibuilder.ApiBuilder.ws import io.javalin.apibuilder.ApiBuilder.ws
import suwayomi.tachidesk.manga.controller.BackupController import suwayomi.tachidesk.manga.controller.BackupController
import suwayomi.tachidesk.manga.controller.CategoryController
import suwayomi.tachidesk.manga.controller.DownloadController import suwayomi.tachidesk.manga.controller.DownloadController
import suwayomi.tachidesk.manga.controller.ExtensionController import suwayomi.tachidesk.manga.controller.ExtensionController
import suwayomi.tachidesk.manga.controller.LibraryController
import suwayomi.tachidesk.manga.controller.MangaController import suwayomi.tachidesk.manga.controller.MangaController
import suwayomi.tachidesk.manga.controller.SourceController import suwayomi.tachidesk.manga.controller.SourceController
object MangaAPI { object MangaAPI {
fun defineEndpoints(app: Javalin) { fun defineEndpoints() {
path("extension") { path("extension") {
get("list", ExtensionController::list) get("list", ExtensionController::list)
@@ -71,27 +70,29 @@ object MangaAPI {
get(":mangaId/chapter/:chapterIndex/page/:index", MangaController::pageRetrieve) get(":mangaId/chapter/:chapterIndex/page/:index", MangaController::pageRetrieve)
} }
path("") { path("category") {
get("library", LibraryController::list) get("", CategoryController::categoryList)
post("", CategoryController::categoryCreate)
path("category") { get(":categoryId", CategoryController::categoryMangas)
get("", LibraryController::categoryList) patch(":categoryId", CategoryController::categoryModify)
post("", LibraryController::categoryCreate) delete(":categoryId", CategoryController::categoryDelete)
get(":categoryId", LibraryController::categoryMangas) patch(
patch(":categoryId", LibraryController::categoryModify) ":categoryId/reorder",
delete(":categoryId", LibraryController::categoryDelete) CategoryController::categoryReorder
) // TODO: the underlying code doesn't need `:categoryId`, remove it
patch(":categoryId/reorder", LibraryController::categoryReorder)
}
} }
path("backup") { path("backup") {
post("legacy/import", BackupController::legacyImport) post("import", BackupController::protobufImport)
post("legacy/import/file", BackupController::legacyImportFile) post("import/file", BackupController::protobufImportFile)
get("legacy/export", BackupController::legacyExport) post("validate", BackupController::protobufValidate)
get("legacy/export/file", BackupController::legacyExportFile) post("validate/file", BackupController::protobufValidateFile)
get("export", BackupController::protobufExport)
get("export/file", BackupController::protobufExportFile)
} }
path("downloads") { path("downloads") {
@@ -2,8 +2,9 @@ package suwayomi.tachidesk.manga.controller
import io.javalin.http.Context import io.javalin.http.Context
import suwayomi.tachidesk.manga.impl.backup.BackupFlags import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupExport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupImport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator
import suwayomi.tachidesk.server.JavalinSetup import suwayomi.tachidesk.server.JavalinSetup
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -16,30 +17,31 @@ import java.util.Date
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
object BackupController { object BackupController {
/** expects a Tachiyomi legacy backup json in the body */
fun legacyImport(ctx: Context) { /** expects a Tachiyomi protobuf backup in the body */
ctx.result( fun protobufImport(ctx: Context) {
ctx.json(
JavalinSetup.future { JavalinSetup.future {
LegacyBackupImport.restoreLegacyBackup(ctx.bodyAsInputStream()) ProtoBackupImport.performRestore(ctx.bodyAsInputStream())
} }
) )
} }
/** expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json" */ /** expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
fun legacyImportFile(ctx: Context) { fun protobufImportFile(ctx: Context) {
ctx.result( ctx.json(
JavalinSetup.future { JavalinSetup.future {
LegacyBackupImport.restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content) ProtoBackupImport.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content)
} }
) )
} }
/** returns a Tachiyomi legacy backup json created from the current database as a json body */ /** returns a Tachiyomi protobuf backup created from the current database as a body */
fun legacyExport(ctx: Context) { fun protobufExport(ctx: Context) {
ctx.contentType("application/json") ctx.contentType("application/octet-stream")
ctx.result( ctx.result(
JavalinSetup.future { JavalinSetup.future {
LegacyBackupExport.createLegacyBackup( ProtoBackupExport.createBackup(
BackupFlags( BackupFlags(
includeManga = true, includeManga = true,
includeCategories = true, includeCategories = true,
@@ -52,16 +54,15 @@ object BackupController {
) )
} }
/** returns a Tachiyomi legacy backup json created from the current database as a file */ /** returns a Tachiyomi protobuf backup created from the current database as a file */
fun legacyExportFile(ctx: Context) { fun protobufExportFile(ctx: Context) {
ctx.contentType("application/json") ctx.contentType("application/octet-stream")
val sdf = SimpleDateFormat("yyyy-MM-dd_HH-mm") val currentDate = SimpleDateFormat("yyyy-MM-dd_HH-mm").format(Date())
val currentDate = sdf.format(Date())
ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"") ctx.header("Content-Disposition", """attachment; filename="tachidesk_$currentDate.proto.gz"""")
ctx.result( ctx.result(
JavalinSetup.future { JavalinSetup.future {
LegacyBackupExport.createLegacyBackup( ProtoBackupExport.createBackup(
BackupFlags( BackupFlags(
includeManga = true, includeManga = true,
includeCategories = true, includeCategories = true,
@@ -73,4 +74,22 @@ object BackupController {
} }
) )
} }
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup in the body */
fun protobufValidate(ctx: Context) {
ctx.json(
JavalinSetup.future {
ProtoBackupValidator.validate(ctx.bodyAsInputStream())
}
)
}
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
fun protobufValidateFile(ctx: Context) {
ctx.json(
JavalinSetup.future {
ProtoBackupValidator.validate(ctx.uploadedFile("backup.proto.gz")!!.content)
}
)
}
} }
@@ -10,14 +10,8 @@ package suwayomi.tachidesk.manga.controller
import io.javalin.http.Context import io.javalin.http.Context
import suwayomi.tachidesk.manga.impl.Category import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.CategoryManga import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Library
object LibraryController {
/** lists mangas that have no category assigned */
fun list(ctx: Context) {
ctx.json(Library.getLibraryMangas())
}
object CategoryController {
/** category list */ /** category list */
fun categoryList(ctx: Context) { fun categoryList(ctx: Context) {
ctx.json(Category.getCategoryList()) ctx.json(Category.getCategoryList())
@@ -54,10 +48,9 @@ object LibraryController {
/** category re-ordering */ /** category re-ordering */
fun categoryReorder(ctx: Context) { fun categoryReorder(ctx: Context) {
val categoryId = ctx.pathParam("categoryId").toInt()
val from = ctx.formParam("from")!!.toInt() val from = ctx.formParam("from")!!.toInt()
val to = ctx.formParam("to")!!.toInt() val to = ctx.formParam("to")!!.toInt()
Category.reorderCategory(categoryId, from, to) Category.reorderCategory(from, to)
ctx.status(200) ctx.status(200)
} }
} }
@@ -97,7 +97,7 @@ object MangaController {
fun chapterList(ctx: Context) { fun chapterList(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
ctx.json(future { Chapter.getChapterList(mangaId, onlineFetch) }) ctx.json(future { Chapter.getChapterList(mangaId, onlineFetch) })
} }
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
@@ -15,9 +16,11 @@ import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.CategoryManga.removeMangaFromCategory import suwayomi.tachidesk.manga.impl.CategoryManga.removeMangaFromCategory
import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
object Category { object Category {
@@ -25,6 +28,8 @@ object Category {
* The new category will be placed at the end of the list * The new category will be placed at the end of the list
*/ */
fun createCategory(name: String) { fun createCategory(name: String) {
if (name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) return
transaction { transaction {
val count = CategoryTable.selectAll().count() val count = CategoryTable.selectAll().count()
if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null) if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null)
@@ -38,7 +43,7 @@ object Category {
fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?) { fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?) {
transaction { transaction {
CategoryTable.update({ CategoryTable.id eq categoryId }) { CategoryTable.update({ CategoryTable.id eq categoryId }) {
if (name != null) it[CategoryTable.name] = name if (name != null && !name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) it[CategoryTable.name] = name
if (isDefault != null) it[CategoryTable.isDefault] = isDefault if (isDefault != null) it[CategoryTable.isDefault] = isDefault
} }
} }
@@ -47,7 +52,7 @@ object Category {
/** /**
* Move the category from position `from` to `to` * Move the category from position `from` to `to`
*/ */
fun reorderCategory(categoryId: Int, from: Int, to: Int) { fun reorderCategory(from: Int, to: Int) {
transaction { transaction {
val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).toMutableList() val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).toMutableList()
categories.add(to - 1, categories.removeAt(from - 1)) categories.add(to - 1, categories.removeAt(from - 1))
@@ -68,11 +73,22 @@ object Category {
} }
} }
const val DEFAULT_CATEGORY_ID = 0
const val DEFAULT_CATEGORY_NAME = "Default"
private fun addDefaultIfNecessary(categories: List<CategoryDataClass>): List<CategoryDataClass> =
if (MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.isNotEmpty()) {
listOf(CategoryDataClass(DEFAULT_CATEGORY_ID, 0, DEFAULT_CATEGORY_NAME, true)) + categories
} else {
categories
}
fun getCategoryList(): List<CategoryDataClass> { fun getCategoryList(): List<CategoryDataClass> {
return transaction { return transaction {
CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map { val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map {
CategoryTable.toDataClass(it) CategoryTable.toDataClass(it)
} }
addDefaultIfNecessary(categories)
} }
} }
} }
@@ -14,6 +14,8 @@ import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID
import suwayomi.tachidesk.manga.impl.util.lang.isEmpty
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
@@ -23,8 +25,10 @@ import suwayomi.tachidesk.manga.model.table.toDataClass
object CategoryManga { object CategoryManga {
fun addMangaToCategory(mangaId: Int, categoryId: Int) { fun addMangaToCategory(mangaId: Int, categoryId: Int) {
fun notAlreadyInCategory() = CategoryMangaTable.select { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }.isEmpty()
transaction { transaction {
if (CategoryMangaTable.select { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }.firstOrNull() == null) { if (notAlreadyInCategory()) {
CategoryMangaTable.insert { CategoryMangaTable.insert {
it[CategoryMangaTable.category] = categoryId it[CategoryMangaTable.category] = categoryId
it[CategoryMangaTable.manga] = mangaId it[CategoryMangaTable.manga] = mangaId
@@ -52,6 +56,13 @@ object CategoryManga {
* list of mangas that belong to a category * list of mangas that belong to a category
*/ */
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> { fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
if (categoryId == DEFAULT_CATEGORY_ID)
return transaction {
MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.map {
MangaTable.toDataClass(it)
}
}
return transaction { return transaction {
CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category eq categoryId }.map { CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category eq categoryId }.map {
MangaTable.toDataClass(it) MangaTable.toDataClass(it)
@@ -30,8 +30,8 @@ import java.time.Instant
object Chapter { object Chapter {
/** get chapter list when showing a manga */ /** get chapter list when showing a manga */
suspend fun getChapterList(mangaId: Int, onlineFetch: Boolean?): List<ChapterDataClass> { suspend fun getChapterList(mangaId: Int, onlineFetch: Boolean = false): List<ChapterDataClass> {
return if (onlineFetch == true) { return if (onlineFetch) {
getSourceChapters(mangaId) getSourceChapters(mangaId)
} else { } else {
transaction { transaction {
@@ -40,21 +40,18 @@ object Chapter {
ChapterTable.toDataClass(it) ChapterTable.toDataClass(it)
} }
}.ifEmpty { }.ifEmpty {
// If it was explicitly set to offline dont grab chapters getSourceChapters(mangaId)
if (onlineFetch == null) {
getSourceChapters(mangaId)
} else emptyList()
} }
} }
} }
private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> { private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> {
val mangaDetails = getManga(mangaId) val manga = getManga(mangaId)
val source = getHttpSource(mangaDetails.sourceId.toLong()) val source = getHttpSource(manga.sourceId.toLong())
val chapterList = source.fetchChapterList( val chapterList = source.fetchChapterList(
SManga.create().apply { SManga.create().apply {
title = mangaDetails.title title = manga.title
url = mangaDetails.url url = manga.url
} }
).awaitSingle() ).awaitSingle()
@@ -72,7 +69,7 @@ object Chapter {
it[scanlator] = fetchedChapter.scanlator it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1 it[chapterIndex] = index + 1
it[manga] = mangaId it[ChapterTable.manga] = mangaId
} }
} else { } else {
ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) { ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) {
@@ -82,7 +79,7 @@ object Chapter {
it[scanlator] = fetchedChapter.scanlator it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1 it[chapterIndex] = index + 1
it[manga] = mangaId it[ChapterTable.manga] = mangaId
} }
} }
} }
@@ -163,7 +160,10 @@ object Chapter {
// update page list for this chapter // update page list for this chapter
transaction { transaction {
pageList.forEach { page -> pageList.forEach { page ->
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }.firstOrNull() } val pageEntry = transaction {
PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }
.firstOrNull()
}
if (pageEntry == null) { if (pageEntry == null) {
PageTable.insert { PageTable.insert {
it[index] = page.index it[index] = page.index
@@ -210,7 +210,14 @@ object Chapter {
} }
} }
fun modifyChapter(mangaId: Int, chapterIndex: Int, isRead: Boolean?, isBookmarked: Boolean?, markPrevRead: Boolean?, lastPageRead: Int?) { fun modifyChapter(
mangaId: Int,
chapterIndex: Int,
isRead: Boolean?,
isBookmarked: Boolean?,
markPrevRead: Boolean?,
lastPageRead: Int?
) {
transaction { transaction {
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) { if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) { update -> ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) { update ->
@@ -244,9 +251,11 @@ object Chapter {
fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) { fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) {
transaction { transaction {
val chapter = ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) } val chapter =
.first()[ChapterTable.id] ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
val meta = transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapter) and (ChapterMetaTable.key eq key) } }.firstOrNull() .first()[ChapterTable.id]
val meta =
transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapter) and (ChapterMetaTable.key eq key) } }.firstOrNull()
if (meta == null) { if (meta == null) {
ChapterMetaTable.insert { ChapterMetaTable.insert {
it[ChapterMetaTable.key] = key it[ChapterMetaTable.key] = key
@@ -7,23 +7,17 @@ package suwayomi.tachidesk.manga.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Manga.getManga import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
object Library { object Library {
// TODO: `Category.isLanding` is to handle the default categories a new library manga gets,
// ..implement that shit at some time...
// ..also Consider to rename it to `isDefault`
suspend fun addMangaToLibrary(mangaId: Int) { suspend fun addMangaToLibrary(mangaId: Int) {
val manga = getManga(mangaId) val manga = getManga(mangaId)
if (!manga.inLibrary) { if (!manga.inLibrary) {
@@ -57,12 +51,4 @@ object Library {
} }
} }
} }
fun getLibraryMangas(): List<MangaDataClass> {
return transaction {
MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.map {
MangaTable.toDataClass(it)
}
}
}
} }
@@ -13,22 +13,25 @@ import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
object Search { object Search {
// TODO
fun sourceFilters(sourceId: Long) {
val source = getHttpSource(sourceId)
// source.getFilterList().toItems()
}
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass { suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass {
val source = getHttpSource(sourceId) val source = getHttpSource(sourceId)
val searchManga = source.fetchSearchManga(pageNum, searchTerm, source.getFilterList()).awaitSingle() val searchManga = source.fetchSearchManga(pageNum, searchTerm, source.getFilterList()).awaitSingle()
return searchManga.processEntries(sourceId) return searchManga.processEntries(sourceId)
} }
// TODO
@Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE")
fun sourceFilters(sourceId: Long) {
val source = getHttpSource(sourceId)
// source.getFilterList().toItems()
}
@Suppress("UNUSED_PARAMETER")
fun sourceGlobalSearch(searchTerm: String) { fun sourceGlobalSearch(searchTerm: String) {
// TODO // TODO
} }
@Suppress("unused")
data class FilterWrapper( data class FilterWrapper(
val type: String, val type: String,
val filter: Any val filter: Any
@@ -0,0 +1,12 @@
package suwayomi.tachidesk.manga.impl.backup
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
abstract class AbstractBackupValidator {
data class ValidationResult(val missingSources: List<String>, val missingTrackers: List<String>)
}
@@ -1,45 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.github.salomonbrys.kotson.registerTypeAdapter
import com.github.salomonbrys.kotson.registerTypeHierarchyAdapter
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import suwayomi.tachidesk.manga.impl.backup.legacy.models.DHistory
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.CategoryTypeAdapter
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.ChapterTypeAdapter
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.HistoryTypeAdapter
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.MangaTypeAdapter
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.TrackTypeAdapter
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
import java.util.Date
open class LegacyBackupBase {
protected val parser: Gson = when (version) {
2 -> GsonBuilder()
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
.create()
else -> throw Exception("Unknown backup version")
}
protected var sourceMapping: Map<Long, String> = emptyMap()
protected val errors = mutableListOf<Pair<Date, String>>()
companion object {
internal const val version = 2
}
}
@@ -1,154 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.github.salomonbrys.kotson.set
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.source.LocalSource
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.Category.getCategoryList
import suwayomi.tachidesk.manga.impl.CategoryManga.getMangaCategories
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup.CURRENT_VERSION
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
object LegacyBackupExport : LegacyBackupBase() {
suspend fun createLegacyBackup(flags: BackupFlags): String? {
// Create root object
val root = JsonObject()
// Create manga array
val mangaEntries = JsonArray()
// Create category array
val categoryEntries = JsonArray()
// Create extension ID/name mapping
val extensionEntries = JsonArray()
// Add values to root
root[Backup.VERSION] = CURRENT_VERSION
root[Backup.MANGAS] = mangaEntries
root[Backup.CATEGORIES] = categoryEntries
root[Backup.EXTENSIONS] = extensionEntries
transaction {
val mangas = MangaTable.select { (MangaTable.inLibrary eq true) }
val extensions: MutableSet<String> = mutableSetOf()
// Backup library manga and its dependencies
mangas.map {
MangaImpl.fromQuery(it)
}.forEach { manga ->
mangaEntries.add(backupMangaObject(manga, flags))
// Maintain set of extensions/sources used (excludes local source)
if (manga.source != LocalSource.ID) {
getHttpSource(manga.source).let {
extensions.add("${it.id}:${it.name}")
}
}
}
// Backup categories
if (flags.includeCategories) {
backupCategories(categoryEntries)
}
// Backup extension ID/name mapping
backupExtensionInfo(extensionEntries, extensions)
}
return parser.toJson(root)
}
private fun backupMangaObject(manga: Manga, options: BackupFlags): JsonElement {
// Entry for this manga
val entry = JsonObject()
// Backup manga fields
entry[Backup.MANGA] = parser.toJsonTree(manga)
val mangaId = manga.id!!.toInt()
// Check if user wants chapter information in backup
if (options.includeChapters) {
// Backup all the chapters
val chapters = ChapterTable.select { ChapterTable.manga eq mangaId }.map { ChapterImpl.fromQuery(it) }
if (chapters.count() > 0) {
val chaptersJson = parser.toJsonTree(chapters)
if (chaptersJson.asJsonArray.size() > 0) {
entry[Backup.CHAPTERS] = chaptersJson
}
}
}
// Check if user wants category information in backup
if (options.includeCategories) {
// Backup categories for this manga
val categoriesForManga = getMangaCategories(mangaId)
if (categoriesForManga.isNotEmpty()) {
val categoriesNames = categoriesForManga.map { it.name }
entry[Backup.CATEGORIES] = parser.toJsonTree(categoriesNames)
}
}
// Check if user wants track information in backup
if (options.includeTracking) { // TODO
// val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
// if (tracks.isNotEmpty()) {
// entry[TRACK] = parser.toJsonTree(tracks)
// }
}
//
// // Check if user wants history information in backup
if (options.includeHistory) { // TODO
// val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
// if (historyForManga.isNotEmpty()) {
// val historyData = historyForManga.mapNotNull { history ->
// val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
// url?.let { DHistory(url, history.last_read) }
// }
// val historyJson = parser.toJsonTree(historyData)
// if (historyJson.asJsonArray.size() > 0) {
// entry[HISTORY] = historyJson
// }
// }
}
return entry
}
private fun backupCategories(root: JsonArray) {
val categories = getCategoryList().map {
CategoryImpl().apply {
name = it.name
order = it.order
}
}
categories.forEach { root.add(parser.toJsonTree(it)) }
}
private fun backupExtensionInfo(root: JsonArray, extensions: Set<String>) {
extensions.sorted().forEach {
root.add(it)
}
}
}
@@ -1,210 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import mu.KotlinLogging
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Category.createCategory
import suwayomi.tachidesk.manga.impl.Category.getCategoryList
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupValidator.ValidationResult
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupValidator.validate
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup
import suwayomi.tachidesk.manga.impl.backup.legacy.models.DHistory
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
import suwayomi.tachidesk.manga.impl.backup.models.Chapter
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
import suwayomi.tachidesk.manga.impl.backup.models.Track
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.io.InputStream
import java.util.Date
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
private val logger = KotlinLogging.logger {}
object LegacyBackupImport : LegacyBackupBase() {
suspend fun restoreLegacyBackup(sourceStream: InputStream): ValidationResult {
val reader = sourceStream.bufferedReader()
val json = JsonParser.parseReader(reader).asJsonObject
val validationResult = validate(json)
val mangasJson = json.get(Backup.MANGAS).asJsonArray
// Restore categories
json.get(Backup.CATEGORIES)?.let { restoreCategories(it) }
// Store source mapping for error messages
sourceMapping = LegacyBackupValidator.getSourceMapping(json)
// Restore individual manga
mangasJson.forEach {
restoreManga(it.asJsonObject)
}
logger.info {
"""
Restore Errors:
${ errors.joinToString("\n") { "${it.first} - ${it.second}" } }
Restore Summary:
- Missing Sources:
${validationResult.missingSources.joinToString("\n")}
- Missing Trackers:
${validationResult.missingTrackers.joinToString("\n")}
""".trimIndent()
}
return validationResult
}
private fun restoreCategories(jsonCategories: JsonElement) {
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
val dbCategories = getCategoryList()
// Iterate over them and create missing categories
backupCategories.forEach { category ->
if (dbCategories.none { it.name == category.name }) {
createCategory(category.name)
}
}
}
private suspend fun restoreManga(mangaJson: JsonObject) {
val manga = parser.fromJson<MangaImpl>(
mangaJson.get(
Backup.MANGA
)
)
val chapters = parser.fromJson<List<ChapterImpl>>(
mangaJson.get(Backup.CHAPTERS)
?: JsonArray()
)
val categories = parser.fromJson<List<String>>(
mangaJson.get(Backup.CATEGORIES)
?: JsonArray()
)
val history = parser.fromJson<List<DHistory>>(
mangaJson.get(Backup.HISTORY)
?: JsonArray()
)
val tracks = parser.fromJson<List<TrackImpl>>(
mangaJson.get(Backup.TRACK)
?: JsonArray()
)
val source = try {
getHttpSource(manga.source)
} catch (e: NullPointerException) {
null
} catch (e: NoSuchElementException) {
null
}
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
logger.debug("Restoring Manga: ${manga.title} from $sourceName")
try {
if (source != null) {
restoreMangaData(manga, source, chapters, categories, history, tracks)
} else {
errors.add(Date() to "${manga.title} [$sourceName]: Source not found: $sourceName (${manga.source})")
}
} catch (e: Exception) {
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
}
}
/**
* @param manga manga data from json
* @param source source to get manga data from
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
*/
private suspend fun restoreMangaData(
manga: Manga,
source: Source,
chapters: List<Chapter>,
categories: List<String>,
history: List<DHistory>,
tracks: List<Track>
) {
val fetchedManga = fetchManga(source, manga)
updateChapters(source, fetchedManga, chapters)
// TODO
// backupManager.restoreCategoriesForManga(manga, categories)
// backupManager.restoreHistoryForManga(history)
// backupManager.restoreTrackForManga(manga, tracks)
// updateTracking(fetchedManga, tracks)
}
/**
* Fetches manga information
*
* @param source source of manga
* @param manga manga that needs updating
* @return Updated manga.
*/
private suspend fun fetchManga(source: Source, manga: Manga): SManga {
// make sure we have the manga record in library
transaction {
if (MangaTable.select { (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }.firstOrNull() == null) {
MangaTable.insert {
it[url] = manga.url
it[title] = manga.title
it[sourceReference] = manga.source
}
}
MangaTable.update({ (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }) {
it[MangaTable.inLibrary] = true
}
}
// update manga details
val fetchedManga = source.fetchMangaDetails(manga).awaitSingle()
transaction {
MangaTable.update({ (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }) {
it[artist] = fetchedManga.artist
it[author] = fetchedManga.author
it[description] = fetchedManga.description
it[genre] = fetchedManga.genre
it[status] = fetchedManga.status
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url.orEmpty().isNotEmpty())
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
}
}
return fetchedManga
}
private fun updateChapters(source: Source, fetchedManga: SManga, chapters: List<Chapter>) {
// TODO("Not yet implemented")
}
}
@@ -1,71 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.google.gson.JsonObject
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup
import suwayomi.tachidesk.manga.model.table.SourceTable
object LegacyBackupValidator {
data class ValidationResult(val missingSources: List<String>, val missingTrackers: List<String>)
/**
* Checks for critical backup file data.
*
* @throws Exception if version or manga cannot be found.
* @return List of missing sources or missing trackers.
*/
fun validate(json: JsonObject): ValidationResult {
val version = json.get(Backup.VERSION)
val mangasJson = json.get(Backup.MANGAS)
if (version == null || mangasJson == null) {
throw Exception("File is missing data.")
}
val mangas = mangasJson.asJsonArray
if (mangas.size() == 0) {
throw Exception("Backup does not contain any manga.")
}
val sources = getSourceMapping(json)
val missingSources = transaction {
sources
.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
.map { "${it.value} (${it.key})" }
.sorted()
}
val trackers = mangas
.filter { it.asJsonObject.has("track") }
.flatMap { it.asJsonObject["track"].asJsonArray }
.map { it.asJsonObject["s"].asInt }
.distinct()
val missingTrackers = listOf("")
// val missingTrackers = trackers
// .mapNotNull { trackManager.getService(it) }
// .filter { !it.isLogged }
// .map { context.getString(it.nameRes()) }
// .sorted()
return ValidationResult(missingSources, missingTrackers)
}
fun getSourceMapping(json: JsonObject): Map<Long, String> {
val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap()
return extensionsMapping.asJsonArray
.map {
val items = it.asString.split(":")
items[0].toLong() to items[1]
}
.toMap()
}
}
@@ -1,25 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Json values
*/
object Backup {
const val CURRENT_VERSION = 2
const val MANGA = "manga"
const val MANGAS = "mangas"
const val TRACK = "track"
const val CHAPTERS = "chapters"
const val CATEGORIES = "categories"
const val EXTENSIONS = "extensions"
const val HISTORY = "history"
const val VERSION = "version"
fun getDefaultFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_$date.json"
}
}
@@ -1,3 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy.models
data class DHistory(val url: String, val lastRead: Long)
@@ -1,31 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
/**
* JSON Serializer used to write / read [CategoryImpl] to / from json
*/
object CategoryTypeAdapter {
fun build(): TypeAdapter<CategoryImpl> {
return typeAdapter {
write {
beginArray()
value(it.name)
value(it.order)
endArray()
}
read {
beginArray()
val category = CategoryImpl()
category.name = nextString()
category.order = nextInt()
endArray()
category
}
}
}
}
@@ -1,59 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonToken
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
/**
* JSON Serializer used to write / read [ChapterImpl] to / from json
*/
object ChapterTypeAdapter {
private const val URL = "u"
private const val READ = "r"
private const val BOOKMARK = "b"
private const val LAST_READ = "l"
fun build(): TypeAdapter<ChapterImpl> {
return typeAdapter {
write {
if (it.read || it.bookmark || it.last_page_read != 0) {
beginObject()
name(URL)
value(it.url)
if (it.read) {
name(READ)
value(1)
}
if (it.bookmark) {
name(BOOKMARK)
value(1)
}
if (it.last_page_read != 0) {
name(LAST_READ)
value(it.last_page_read)
}
endObject()
}
}
read {
val chapter = ChapterImpl()
beginObject()
while (hasNext()) {
if (peek() == JsonToken.NAME) {
when (nextName()) {
URL -> chapter.url = nextString()
READ -> chapter.read = nextInt() == 1
BOOKMARK -> chapter.bookmark = nextInt() == 1
LAST_READ -> chapter.last_page_read = nextInt()
}
}
}
endObject()
chapter
}
}
}
}
@@ -1,32 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import suwayomi.tachidesk.manga.impl.backup.legacy.models.DHistory
/**
* JSON Serializer used to write / read [DHistory] to / from json
*/
object HistoryTypeAdapter {
fun build(): TypeAdapter<DHistory> {
return typeAdapter {
write {
if (it.lastRead != 0L) {
beginArray()
value(it.url)
value(it.lastRead)
endArray()
}
}
read {
beginArray()
val url = nextString()
val lastRead = nextLong()
endArray()
DHistory(url, lastRead)
}
}
}
}
@@ -1,37 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
/**
* JSON Serializer used to write / read [MangaImpl] to / from json
*/
object MangaTypeAdapter {
fun build(): TypeAdapter<MangaImpl> {
return typeAdapter {
write {
beginArray()
value(it.url)
value(it.title)
value(it.source)
value(it.viewer)
value(it.chapter_flags)
endArray()
}
read {
beginArray()
val manga = MangaImpl()
manga.url = nextString()
manga.title = nextString()
manga.source = nextLong()
manga.viewer = nextInt()
manga.chapter_flags = nextInt()
endArray()
manga
}
}
}
}
@@ -1,59 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonToken
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
/**
* JSON Serializer used to write / read [TrackImpl] to / from json
*/
object TrackTypeAdapter {
private const val SYNC = "s"
private const val MEDIA = "r"
private const val LIBRARY = "ml"
private const val TITLE = "t"
private const val LAST_READ = "l"
private const val TRACKING_URL = "u"
fun build(): TypeAdapter<TrackImpl> {
return typeAdapter {
write {
beginObject()
name(TITLE)
value(it.title)
name(SYNC)
value(it.sync_id)
name(MEDIA)
value(it.media_id)
name(LIBRARY)
value(it.library_id)
name(LAST_READ)
value(it.last_chapter_read)
name(TRACKING_URL)
value(it.tracking_url)
endObject()
}
read {
val track = TrackImpl()
beginObject()
while (hasNext()) {
if (peek() == JsonToken.NAME) {
when (nextName()) {
TITLE -> track.title = nextString()
SYNC -> track.sync_id = nextInt()
MEDIA -> track.media_id = nextInt()
LIBRARY -> track.library_id = nextLong()
LAST_READ -> track.last_chapter_read = nextInt()
TRACKING_URL -> track.tracking_url = nextString()
}
}
}
endObject()
track
}
}
}
}
@@ -1,8 +1,18 @@
package suwayomi.tachidesk.manga.impl.backup.models package suwayomi.tachidesk.manga.impl.backup.models
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
// import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
// import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
// import tachiyomi.source.model.MangaInfo // substitute for eu.kanade.tachiyomi.ui.reader.setting.OrientationType
object OrientationType {
const val MASK = 0x00000038
}
// substitute for eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
object ReadingModeType {
const val MASK = 0x00000007
}
interface Manga : SManga { interface Manga : SManga {
@@ -10,85 +20,100 @@ interface Manga : SManga {
var source: Long var source: Long
/** is in library */
var favorite: Boolean var favorite: Boolean
// last time the chapter list changed in any way
var last_update: Long var last_update: Long
// predicted next update time based on latest (by date) 4 chapters' deltas
var next_update: Long
var date_added: Long var date_added: Long
var viewer: Int var viewer_flags: Int
var chapter_flags: Int var chapter_flags: Int
var cover_last_modified: Long var cover_last_modified: Long
fun setChapterOrder(order: Int) { fun setChapterOrder(order: Int) {
setFlags(order, SORT_MASK) setChapterFlags(order, CHAPTER_SORT_MASK)
} }
fun sortDescending(): Boolean { fun sortDescending(): Boolean {
return chapter_flags and SORT_MASK == SORT_DESC return chapter_flags and CHAPTER_SORT_MASK == CHAPTER_SORT_DESC
} }
fun getGenres(): List<String>? { fun getGenres(): List<String>? {
return genre?.split(", ")?.map { it.trim() } return genre?.split(", ")?.map { it.trim() }
} }
private fun setFlags(flag: Int, mask: Int) { private fun setChapterFlags(flag: Int, mask: Int) {
chapter_flags = chapter_flags and mask.inv() or (flag and mask) chapter_flags = chapter_flags and mask.inv() or (flag and mask)
} }
private fun setViewerFlags(flag: Int, mask: Int) {
viewer_flags = viewer_flags and mask.inv() or (flag and mask)
}
// Used to display the chapter's title one way or another // Used to display the chapter's title one way or another
var displayMode: Int var displayMode: Int
get() = chapter_flags and DISPLAY_MASK get() = chapter_flags and CHAPTER_DISPLAY_MASK
set(mode) = setFlags(mode, DISPLAY_MASK) set(mode) = setChapterFlags(mode, CHAPTER_DISPLAY_MASK)
var readFilter: Int var readFilter: Int
get() = chapter_flags and READ_MASK get() = chapter_flags and CHAPTER_READ_MASK
set(filter) = setFlags(filter, READ_MASK) set(filter) = setChapterFlags(filter, CHAPTER_READ_MASK)
var downloadedFilter: Int var downloadedFilter: Int
get() = chapter_flags and DOWNLOADED_MASK get() = chapter_flags and CHAPTER_DOWNLOADED_MASK
set(filter) = setFlags(filter, DOWNLOADED_MASK) set(filter) = setChapterFlags(filter, CHAPTER_DOWNLOADED_MASK)
var bookmarkedFilter: Int var bookmarkedFilter: Int
get() = chapter_flags and BOOKMARKED_MASK get() = chapter_flags and CHAPTER_BOOKMARKED_MASK
set(filter) = setFlags(filter, BOOKMARKED_MASK) set(filter) = setChapterFlags(filter, CHAPTER_BOOKMARKED_MASK)
var sorting: Int var sorting: Int
get() = chapter_flags and SORTING_MASK get() = chapter_flags and CHAPTER_SORTING_MASK
set(sort) = setFlags(sort, SORTING_MASK) set(sort) = setChapterFlags(sort, CHAPTER_SORTING_MASK)
var readingModeType: Int
get() = viewer_flags and ReadingModeType.MASK
set(readingMode) = setViewerFlags(readingMode, ReadingModeType.MASK)
var orientationType: Int
get() = viewer_flags and OrientationType.MASK
set(rotationType) = setViewerFlags(rotationType, OrientationType.MASK)
companion object { companion object {
const val SORT_DESC = 0x00000000
const val SORT_ASC = 0x00000001
const val SORT_MASK = 0x00000001
// Generic filter that does not filter anything // Generic filter that does not filter anything
const val SHOW_ALL = 0x00000000 const val SHOW_ALL = 0x00000000
const val SHOW_UNREAD = 0x00000002 const val CHAPTER_SORT_DESC = 0x00000000
const val SHOW_READ = 0x00000004 const val CHAPTER_SORT_ASC = 0x00000001
const val READ_MASK = 0x00000006 const val CHAPTER_SORT_MASK = 0x00000001
const val SHOW_DOWNLOADED = 0x00000008 const val CHAPTER_SHOW_UNREAD = 0x00000002
const val SHOW_NOT_DOWNLOADED = 0x00000010 const val CHAPTER_SHOW_READ = 0x00000004
const val DOWNLOADED_MASK = 0x00000018 const val CHAPTER_READ_MASK = 0x00000006
const val SHOW_BOOKMARKED = 0x00000020 const val CHAPTER_SHOW_DOWNLOADED = 0x00000008
const val SHOW_NOT_BOOKMARKED = 0x00000040 const val CHAPTER_SHOW_NOT_DOWNLOADED = 0x00000010
const val BOOKMARKED_MASK = 0x00000060 const val CHAPTER_DOWNLOADED_MASK = 0x00000018
const val SORTING_SOURCE = 0x00000000 const val CHAPTER_SHOW_BOOKMARKED = 0x00000020
const val SORTING_NUMBER = 0x00000100 const val CHAPTER_SHOW_NOT_BOOKMARKED = 0x00000040
const val SORTING_UPLOAD_DATE = 0x00000200 const val CHAPTER_BOOKMARKED_MASK = 0x00000060
const val SORTING_MASK = 0x00000300
const val DISPLAY_NAME = 0x00000000 const val CHAPTER_SORTING_SOURCE = 0x00000000
const val DISPLAY_NUMBER = 0x00100000 const val CHAPTER_SORTING_NUMBER = 0x00000100
const val DISPLAY_MASK = 0x00100000 const val CHAPTER_SORTING_UPLOAD_DATE = 0x00000200
const val CHAPTER_SORTING_MASK = 0x00000300
const val CHAPTER_DISPLAY_NAME = 0x00000000
const val CHAPTER_DISPLAY_NUMBER = 0x00100000
const val CHAPTER_DISPLAY_MASK = 0x00100000
fun create(source: Long): Manga = MangaImpl().apply { fun create(source: Long): Manga = MangaImpl().apply {
this.source = source this.source = source
@@ -5,7 +5,7 @@ import suwayomi.tachidesk.manga.model.table.MangaTable
open class MangaImpl : Manga { open class MangaImpl : Manga {
override var id: Long? = 0 override var id: Long? = null
override var source: Long = -1 override var source: Long = -1
@@ -29,6 +29,8 @@ open class MangaImpl : Manga {
override var last_update: Long = 0 override var last_update: Long = 0
override var next_update: Long = 0
override var date_added: Long = 0 override var date_added: Long = 0
override var initialized: Boolean = false override var initialized: Boolean = false
@@ -42,7 +44,7 @@ open class MangaImpl : Manga {
* 4 -> Webtoon * 4 -> Webtoon
* 5 -> Continues Vertical * 5 -> Continues Vertical
*/ */
override var viewer: Int = 0 override var viewer_flags: Int = 0
/** Contains some useful info about /** Contains some useful info about
*/ */
@@ -70,7 +72,7 @@ open class MangaImpl : Manga {
url = mangaRecord[MangaTable.url] url = mangaRecord[MangaTable.url]
title = mangaRecord[MangaTable.title] title = mangaRecord[MangaTable.title]
source = mangaRecord[MangaTable.sourceReference] source = mangaRecord[MangaTable.sourceReference]
viewer = 0 // TODO: implement viewer_flags = 0 // TODO: implement
chapter_flags = 0 // TODO: implement chapter_flags = 0 // TODO: implement
} }
} }
@@ -0,0 +1,16 @@
package suwayomi.tachidesk.manga.impl.backup.proto
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.serialization.protobuf.ProtoBuf
open class ProtoBackupBase {
var sourceMapping: Map<Long, String> = emptyMap()
val parser = ProtoBuf
}
@@ -0,0 +1,136 @@
package suwayomi.tachidesk.manga.impl.backup.proto
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.coroutines.runBlocking
import okio.buffer
import okio.gzip
import okio.sink
import org.jetbrains.exposed.sql.Query
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import java.io.ByteArrayOutputStream
import java.io.InputStream
object ProtoBackupExport : ProtoBackupBase() {
suspend fun createBackup(flags: BackupFlags): InputStream {
// Create root object
val databaseManga = transaction { MangaTable.select { MangaTable.inLibrary eq true } }
val backup: Backup = transaction {
Backup(
backupManga(databaseManga, flags),
backupCategories(),
backupExtensionInfo(databaseManga)
)
}
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
val byteStream = ByteArrayOutputStream()
byteStream.sink().gzip().buffer().use { it.write(byteArray) }
return byteStream.toByteArray().inputStream()
}
private fun backupManga(databaseManga: Query, flags: BackupFlags): List<BackupManga> {
return databaseManga.map { mangaRow ->
val backupManga = BackupManga(
mangaRow[MangaTable.sourceReference],
mangaRow[MangaTable.url],
mangaRow[MangaTable.title],
mangaRow[MangaTable.artist],
mangaRow[MangaTable.author],
mangaRow[MangaTable.description],
mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(),
MangaStatus.valueOf(mangaRow[MangaTable.status]).value,
mangaRow[MangaTable.thumbnail_url],
0, // not supported in Tachidesk
0, // not supported in Tachidesk
)
val mangaId = mangaRow[MangaTable.id].value
if (flags.includeChapters) {
val chapters = runBlocking { Chapter.getChapterList(mangaId) }
backupManga.chapters = chapters.map {
BackupChapter(
it.url,
it.name,
it.scanlator,
it.read,
it.bookmarked,
it.lastPageRead,
0, // not supported in Tachidesk
it.uploadDate,
it.chapterNumber,
it.index,
)
}
}
if (flags.includeCategories) {
backupManga.categories = CategoryManga.getMangaCategories(mangaId).map { it.order }
}
// if(flags.includeTracking) {
// backupManga.tracking = TODO()
// }
// if (flags.includeHistory) {
// backupManga.history = TODO()
// }
backupManga
}
}
private fun backupCategories(): List<BackupCategory> {
return CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map {
CategoryTable.toDataClass(it)
}.map {
BackupCategory(
it.name,
it.order,
0, // not supported in Tachidesk
)
}
}
private fun backupExtensionInfo(mangas: Query): List<BackupSource> {
return mangas
.asSequence()
.map { it[MangaTable.sourceReference] }
.distinct()
.map {
val sourceRow = SourceTable.select { SourceTable.id eq it }.firstOrNull()
BackupSource(
sourceRow?.get(SourceTable.name) ?: "",
it
)
}
.toList()
}
}
@@ -0,0 +1,182 @@
package suwayomi.tachidesk.manga.impl.backup.proto
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import mu.KotlinLogging
import okio.buffer
import okio.gzip
import okio.source
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.backup.AbstractBackupValidator.ValidationResult
import suwayomi.tachidesk.manga.impl.backup.models.Chapter
import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.models.Track
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.validate
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.io.InputStream
import java.util.Date
object ProtoBackupImport : ProtoBackupBase() {
private val logger = KotlinLogging.logger {}
private var restoreAmount = 0
private val errors = mutableListOf<Pair<Date, String>>()
suspend fun performRestore(sourceStream: InputStream): ValidationResult {
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
val backup = parser.decodeFromByteArray(BackupSerializer, backupString)
val validationResult = validate(backup)
restoreAmount = backup.backupManga.size + 1 // +1 for categories
// Restore categories
if (backup.backupCategories.isNotEmpty()) {
restoreCategories(backup.backupCategories)
}
val categoryMapping = transaction {
backup.backupCategories.associate {
it.order to CategoryTable.select { CategoryTable.name eq it.name }.first()[CategoryTable.id].value
}
}
// Store source mapping for error messages
sourceMapping = backup.backupSources.map { it.sourceId to it.name }.toMap()
// Restore individual manga
backup.backupManga.forEach {
restoreManga(it, backup.backupCategories, categoryMapping)
}
logger.info {
"""
Restore Errors:
${ errors.joinToString("\n") { "${it.first} - ${it.second}" } }
Restore Summary:
- Missing Sources:
${validationResult.missingSources.joinToString("\n ")}
- Missing Trackers:
${validationResult.missingTrackers.joinToString("\n ")}
""".trimIndent()
}
return validationResult
}
private fun restoreCategories(backupCategories: List<BackupCategory>) {
val dbCategories = Category.getCategoryList()
// Iterate over them and create missing categories
backupCategories.forEach { category ->
if (dbCategories.none { it.name == category.name }) {
Category.createCategory(category.name)
}
}
}
private fun restoreManga(
backupManga: BackupManga,
backupCategories: List<BackupCategory>,
categoryMapping: Map<Int, Int>
) { // TODO
val manga = backupManga.getMangaImpl()
val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories
val history = backupManga.history
val tracks = backupManga.getTrackingImpl()
try {
restoreMangaData(manga, chapters, categories, history, tracks, backupCategories, categoryMapping)
} catch (e: Exception) {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
}
}
private fun restoreMangaData(
manga: Manga,
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
categoryMapping: Map<Int, Int>
) {
val dbManga = transaction {
MangaTable.select { (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }
.firstOrNull()
}
if (dbManga == null) { // Manga not in database
transaction {
// insert manga to database
val mangaId = MangaTable.insertAndGetId {
it[url] = manga.url
it[title] = manga.title
it[artist] = manga.artist
it[author] = manga.author
it[description] = manga.description
it[genre] = manga.genre
it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url
it[sourceReference] = manga.source
it[initialized] = manga.description != null
it[inLibrary] = true
}.value
// insert chapter data
chapters.forEach { chapter ->
ChapterTable.insert {
it[url] = chapter.url
it[name] = chapter.name
it[date_upload] = chapter.date_upload
it[chapter_number] = chapter.chapter_number
it[scanlator] = chapter.scanlator
it[chapterIndex] = chapter.source_order
it[ChapterTable.manga] = mangaId
it[isRead] = chapter.read
it[lastPageRead] = chapter.last_page_read
it[isBookmarked] = chapter.bookmark
}
}
// insert categories
categories.forEach { backupCategoryOrder ->
CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!)
}
}
} else { // Manga in database
// merge chapter data
// merge categories
}
// TODO: insert/merge history
// TODO: insert/merge tracking
}
}
@@ -0,0 +1,57 @@
package suwayomi.tachidesk.manga.impl.backup.proto
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import okio.buffer
import okio.gzip
import okio.source
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.backup.AbstractBackupValidator
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.io.InputStream
object ProtoBackupValidator : AbstractBackupValidator() {
fun validate(backup: Backup): ValidationResult {
if (backup.backupManga.isEmpty()) {
throw Exception("Backup does not contain any manga.")
}
val sources = backup.backupSources.map { it.sourceId to it.name }.toMap()
val missingSources = transaction {
sources
.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
.map { "${it.value} (${it.key})" }
.sorted()
}
// val trackers = backup.backupManga
// .flatMap { it.tracking }
// .map { it.syncId }
// .distinct()
val missingTrackers = listOf("")
// val missingTrackers = trackers
// .mapNotNull { trackManager.getService(it) }
// .filter { !it.isLogged }
// .map { context.getString(it.nameRes()) }
// .sorted()
return ValidationResult(missingSources, missingTrackers)
}
suspend fun validate(sourceStream: InputStream): ValidationResult {
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
val backup = ProtoBackupImport.parser.decodeFromByteArray(BackupSerializer, backupString)
return validate(backup)
}
}
@@ -0,0 +1,12 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class Backup(
@ProtoNumber(1) val backupManga: List<BackupManga>,
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var backupSources: List<BackupSource> = emptyList(),
)
@@ -0,0 +1,33 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.impl.backup.models.Category
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
@Serializable
class BackupCategory(
@ProtoNumber(1) var name: String,
@ProtoNumber(2) var order: Int = 0,
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Int = 0,
) {
fun getCategoryImpl(): CategoryImpl {
return CategoryImpl().apply {
name = this@BackupCategory.name
flags = this@BackupCategory.flags
order = this@BackupCategory.order
}
}
companion object {
fun copyFrom(category: Category): BackupCategory {
return BackupCategory(
name = category.name,
order = category.order,
flags = category.flags
)
}
}
}
@@ -0,0 +1,56 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.impl.backup.models.Chapter
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
@Serializable
data class BackupChapter(
// in 1.x some of these values have different names
// url is called key in 1.x
@ProtoNumber(1) var url: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var scanlator: String? = null,
@ProtoNumber(4) var read: Boolean = false,
@ProtoNumber(5) var bookmark: Boolean = false,
// lastPageRead is called progress in 1.x
@ProtoNumber(6) var lastPageRead: Int = 0,
@ProtoNumber(7) var dateFetch: Long = 0,
@ProtoNumber(8) var dateUpload: Long = 0,
// chapterNumber is called number is 1.x
@ProtoNumber(9) var chapterNumber: Float = 0F,
@ProtoNumber(10) var sourceOrder: Int = 0,
) {
fun toChapterImpl(): ChapterImpl {
return ChapterImpl().apply {
url = this@BackupChapter.url
name = this@BackupChapter.name
chapter_number = this@BackupChapter.chapterNumber
scanlator = this@BackupChapter.scanlator
read = this@BackupChapter.read
bookmark = this@BackupChapter.bookmark
last_page_read = this@BackupChapter.lastPageRead
date_fetch = this@BackupChapter.dateFetch
date_upload = this@BackupChapter.dateUpload
source_order = this@BackupChapter.sourceOrder
}
}
companion object {
fun copyFrom(chapter: Chapter): BackupChapter {
return BackupChapter(
url = chapter.url,
name = chapter.name,
chapterNumber = chapter.chapter_number,
scanlator = chapter.scanlator,
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.last_page_read,
dateFetch = chapter.date_fetch,
dateUpload = chapter.date_upload,
sourceOrder = chapter.source_order
)
}
}
}
@@ -0,0 +1,12 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object BackupFull {
fun getDefaultFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_$date.proto.gz"
}
}
@@ -0,0 +1,10 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BackupHistory(
@ProtoNumber(0) var url: String,
@ProtoNumber(1) var lastRead: Long
)
@@ -0,0 +1,89 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
@Serializable
data class BackupManga(
// in 1.x some of these values have different names
@ProtoNumber(1) var source: Long,
// url is called key in 1.x
@ProtoNumber(2) var url: String,
@ProtoNumber(3) var title: String = "",
@ProtoNumber(4) var artist: String? = null,
@ProtoNumber(5) var author: String? = null,
@ProtoNumber(6) var description: String? = null,
@ProtoNumber(7) var genre: List<String> = emptyList(),
@ProtoNumber(8) var status: Int = 0,
// thumbnailUrl is called cover in 1.x
@ProtoNumber(9) var thumbnailUrl: String? = null,
// @ProtoNumber(10) val customCover: String = "", 1.x value, not used in 0.x
// @ProtoNumber(11) val lastUpdate: Long = 0, 1.x value, not used in 0.x
// @ProtoNumber(12) val lastInit: Long = 0, 1.x value, not used in 0.x
@ProtoNumber(13) var dateAdded: Long = 0,
@ProtoNumber(14) var viewer: Int = 0, // Replaced by viewer_flags
// @ProtoNumber(15) val flags: Int = 0, 1.x value, not used in 0.x
@ProtoNumber(16) var chapters: List<BackupChapter> = emptyList(),
@ProtoNumber(17) var categories: List<Int> = emptyList(),
@ProtoNumber(18) var tracking: List<BackupTracking> = emptyList(),
// Bump by 100 for values that are not saved/implemented in 1.x but are used in 0.x
@ProtoNumber(100) var favorite: Boolean = true,
@ProtoNumber(101) var chapterFlags: Int = 0,
@ProtoNumber(102) var history: List<BackupHistory> = emptyList(),
@ProtoNumber(103) var viewer_flags: Int? = null
) {
fun getMangaImpl(): MangaImpl {
return MangaImpl().apply {
url = this@BackupManga.url
title = this@BackupManga.title
artist = this@BackupManga.artist
author = this@BackupManga.author
description = this@BackupManga.description
genre = this@BackupManga.genre.joinToString()
status = this@BackupManga.status
thumbnail_url = this@BackupManga.thumbnailUrl
favorite = this@BackupManga.favorite
source = this@BackupManga.source
date_added = this@BackupManga.dateAdded
viewer_flags = this@BackupManga.viewer_flags ?: this@BackupManga.viewer
chapter_flags = this@BackupManga.chapterFlags
}
}
fun getChaptersImpl(): List<ChapterImpl> {
return chapters.map {
it.toChapterImpl()
}
}
fun getTrackingImpl(): List<TrackImpl> {
return tracking.map {
it.getTrackingImpl()
}
}
companion object {
fun copyFrom(manga: Manga): BackupManga {
return BackupManga(
url = manga.url,
title = manga.title,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.getGenres() ?: emptyList(),
status = manga.status,
thumbnailUrl = manga.thumbnail_url,
favorite = manga.favorite,
source = manga.source,
dateAdded = manga.date_added,
viewer = manga.readingModeType,
viewer_flags = manga.viewer_flags,
chapterFlags = manga.chapter_flags
)
}
}
}
@@ -0,0 +1,6 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializer
@Serializer(forClass = Backup::class)
object BackupSerializer
@@ -0,0 +1,20 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import eu.kanade.tachiyomi.source.Source
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BackupSource(
@ProtoNumber(0) var name: String = "",
@ProtoNumber(1) var sourceId: Long
) {
companion object {
fun copyFrom(source: Source): BackupSource {
return BackupSource(
name = source.name,
sourceId = source.id
)
}
}
}
@@ -0,0 +1,65 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.impl.backup.models.Track
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
@Serializable
data class BackupTracking(
// in 1.x some of these values have different types or names
// syncId is called siteId in 1,x
@ProtoNumber(1) var syncId: Int,
// LibraryId is not null in 1.x
@ProtoNumber(2) var libraryId: Long,
@ProtoNumber(3) var mediaId: Int = 0,
// trackingUrl is called mediaUrl in 1.x
@ProtoNumber(4) var trackingUrl: String = "",
@ProtoNumber(5) var title: String = "",
// lastChapterRead is called last read, and it has been changed to a float in 1.x
@ProtoNumber(6) var lastChapterRead: Float = 0F,
@ProtoNumber(7) var totalChapters: Int = 0,
@ProtoNumber(8) var score: Float = 0F,
@ProtoNumber(9) var status: Int = 0,
// startedReadingDate is called startReadTime in 1.x
@ProtoNumber(10) var startedReadingDate: Long = 0,
// finishedReadingDate is called endReadTime in 1.x
@ProtoNumber(11) var finishedReadingDate: Long = 0,
) {
fun getTrackingImpl(): TrackImpl {
return TrackImpl().apply {
sync_id = this@BackupTracking.syncId
media_id = this@BackupTracking.mediaId
library_id = this@BackupTracking.libraryId
title = this@BackupTracking.title
// convert from float to int because of 1.x types
last_chapter_read = this@BackupTracking.lastChapterRead.toInt()
total_chapters = this@BackupTracking.totalChapters
score = this@BackupTracking.score
status = this@BackupTracking.status
started_reading_date = this@BackupTracking.startedReadingDate
finished_reading_date = this@BackupTracking.finishedReadingDate
tracking_url = this@BackupTracking.trackingUrl
}
}
companion object {
fun copyFrom(track: Track): BackupTracking {
return BackupTracking(
syncId = track.sync_id,
mediaId = track.media_id,
// forced not null so its compatible with 1.x backup system
libraryId = track.library_id!!,
title = track.title,
// convert to float for 1.x
lastChapterRead = track.last_chapter_read.toFloat(),
totalChapters = track.total_chapters,
score = track.score,
status = track.status,
startedReadingDate = track.started_reading_date,
finishedReadingDate = track.finished_reading_date,
trackingUrl = track.tracking_url
)
}
}
}
@@ -85,7 +85,7 @@ object BytecodeEditor {
bytes[2], bytes[2],
bytes[3] bytes[3]
) )
if (cafebabe.toLowerCase() != "cafebabe") { if (cafebabe.lowercase() != "cafebabe") {
// Corrupted class // Corrupted class
return@use null return@use null
} }
@@ -32,7 +32,7 @@ object GetHttpSource {
} }
val sourceRecord = transaction { val sourceRecord = transaction {
SourceTable.select { SourceTable.id eq sourceId }.first() SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!!
} }
val extensionId = sourceRecord[SourceTable.extension] val extensionId = sourceRecord[SourceTable.extension]
@@ -0,0 +1,14 @@
package suwayomi.tachidesk.manga.impl.util.lang
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.Query
fun Query.isEmpty() = this.count() == 0L
fun Query.isNotEmpty() = !this.isEmpty()
@@ -81,7 +81,7 @@ object JavalinSetup {
app.routes { app.routes {
path("api/v1/") { path("api/v1/") {
GlobalAPI.defineEndpoints() GlobalAPI.defineEndpoints()
MangaAPI.defineEndpoints(app) MangaAPI.defineEndpoints()
AnimeAPI.defineEndpoints(app) // TODO: migrate Anime endpoints AnimeAPI.defineEndpoints(app) // TODO: migrate Anime endpoints
} }
} }
@@ -29,7 +29,7 @@ class ServerConfig(config: Config, moduleName: String = "") : ConfigModule(confi
// webUI // webUI
val webUIEnabled: Boolean by overridableWithSysProperty val webUIEnabled: Boolean by overridableWithSysProperty
val initialOpenInBrowserEnabled: Boolean by overridableWithSysProperty val initialOpenInBrowserEnabled: Boolean by overridableWithSysProperty
val webUIBrowser: String by overridableWithSysProperty val webUIInterface: String by overridableWithSysProperty
val electronPath: String by overridableWithSysProperty val electronPath: String by overridableWithSysProperty
companion object { companion object {
@@ -96,7 +96,7 @@ fun applicationSetup() {
logger.error("Exception while creating initial server.conf:\n", e) logger.error("Exception while creating initial server.conf:\n", e)
} }
// fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 // fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
Locale.setDefault(Locale.ENGLISH) Locale.setDefault(Locale.ENGLISH)
databaseUp() databaseUp()
@@ -18,7 +18,7 @@ object Browser {
fun openInBrowser() { fun openInBrowser() {
if (serverConfig.webUIEnabled) { if (serverConfig.webUIEnabled) {
if (serverConfig.webUIBrowser == ("electron")) { if (serverConfig.webUIInterface == ("electron")) {
try { try {
val electronPath = serverConfig.electronPath val electronPath = serverConfig.electronPath
electronInstances.add(ProcessBuilder(electronPath, appBaseUrl).start()) electronInstances.add(ProcessBuilder(electronPath, appBaseUrl).start())
@@ -14,8 +14,9 @@ import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import suwayomi.tachidesk.server.BuildConfig import suwayomi.tachidesk.server.BuildConfig
import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.MessageDigest import java.security.MessageDigest
@@ -58,9 +59,12 @@ fun setupWebUI() {
val webUIZipFile = File(webUIZipPath) val webUIZipFile = File(webUIZipPath)
// try with resources first // try with resources first
val resourceWebUI = try { val resourceWebUI: InputStream? = try {
BuildConfig::class.java.getResourceAsStream("/WebUI.zip") BuildConfig::class.java.getResourceAsStream("/WebUI.zip")
} catch (e: NullPointerException) { null } } catch (e: NullPointerException) {
logger.info { "No bundled WebUI.zip found!" }
null
}
if (resourceWebUI == null) { // is not bundled if (resourceWebUI == null) { // is not bundled
// download webUI zip // download webUI zip
@@ -71,18 +75,25 @@ fun setupWebUI() {
val data = ByteArray(1024) val data = ByteArray(1024)
webUIZipFile.outputStream().use { webUIZipFileOut -> webUIZipFile.outputStream().use { webUIZipFileOut ->
BufferedInputStream(URL(webUIZipURL).openStream()).use { inp ->
val connection = URL(webUIZipURL).openConnection() as HttpURLConnection
connection.connect()
val contentLength = connection.contentLength
connection.inputStream.buffered().use { inp ->
var totalCount = 0 var totalCount = 0
var tresh = 0
print("Download progress: % 00")
while (true) { while (true) {
val count = inp.read(data, 0, 1024) val count = inp.read(data, 0, 1024)
totalCount += count
if (totalCount > tresh + 10 * 1024) {
tresh = totalCount
print(" *")
}
if (count == -1) if (count == -1)
break break
totalCount += count
val percentage = (totalCount.toFloat() / contentLength * 100).toInt().toString().padStart(2, '0')
print("\b\b$percentage")
webUIZipFileOut.write(data, 0, count) webUIZipFileOut.write(data, 0, count)
} }
println() println()
@@ -14,5 +14,5 @@ server.systemTrayEnabled = true
# webUI # webUI
server.webUIEnabled = true server.webUIEnabled = true
server.initialOpenInBrowserEnabled = true server.initialOpenInBrowserEnabled = true
server.webUIBrowser = "browser" # "browser" or "electron" server.webUIInterface = "browser" # "browser" or "electron"
server.electronPath = "" server.electronPath = ""
@@ -72,7 +72,7 @@ class TestExtensions {
sources = getSourceList().map { getHttpSource(it.id.toLong()) } sources = getSourceList().map { getHttpSource(it.id.toLong()) }
} }
setLoggingEnabled(true) setLoggingEnabled(true)
File("tmp/TestDesk/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.toUpperCase()} - ${it.id}" }) File("tmp/TestDesk/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
} }
@Test @Test
@@ -99,7 +99,7 @@ class TestExtensions {
}.awaitAll() }.awaitAll()
File("tmp/TestDesk/failedToFetch.txt").writeText( File("tmp/TestDesk/failedToFetch.txt").writeText(
failedToFetch.joinToString("\n") { (source, exception) -> failedToFetch.joinToString("\n") { (source, exception) ->
"${source.name} (${source.lang.toUpperCase()}, ${source.id}):" + "${source.name} (${source.lang.uppercase()}, ${source.id}):" +
" ${exception.message}" " ${exception.message}"
} }
) )