Compare commits

..

94 Commits

Author SHA1 Message Date
Aria Moradi
464b9659fe queries 2023-06-10 04:00:49 +03:30
Aria Moradi
7a59d0d4dd it compiles 2023-06-09 22:56:14 +03:30
Mitchell Syer
7c3eff2ba7 Complete source mutations (#567) 2023-06-05 16:49:03 +03:30
Mitchell Syer
300c0a8f35 Category Mutations (#566)
* Complete Category mutations

* Remove TODO
2023-06-05 16:48:57 +03:30
schroda
51bfdc0947 Feature/make config settings changeable during runtime (#545)
* Add logic to update config during runtime

* Update ConfigModule to always use the latest config

* Make ServerConfig settings re-assignable
2023-06-05 16:48:18 +03:30
Aria Moradi
a64566c0f3 fill in the cover according to spec (#571) 2023-06-05 16:18:03 +03:30
Aria Moradi
dbb9a80ea6 use commons-compress everywhere (#570) 2023-06-05 16:16:27 +03:30
Aria Moradi
e930c54246 improve zip parsing (#569) 2023-06-05 14:31:14 +03:30
Mitchell Syer
dfff047cbf Fix cascade migration (#565) 2023-05-29 17:29:54 -04:00
Mitchell Syer
44fb2b02bc Fix global meta delete (#564) 2023-05-29 23:41:39 +03:30
Mitchell Syer
6a7efafd9f Improve database column references and default category handling (#563)
* Improve default category handling and add cascade to references where possible

* Minor fix for default category

* Make the default category always first in the normalization
2023-05-28 04:11:27 +03:30
Mitchell Syer
241abc3956 Add items that are related to the deleted meta (#562) 2023-05-27 21:39:47 +03:30
Mitchell Syer
1e82c879bf Add default category to the database (#561)
* Add default category to the database

* Fix getCategorySize
2023-05-27 03:20:21 +03:30
Mitchell Syer
a81d01d2e3 Don't use data fetchers in mutations (#559) 2023-05-27 03:09:31 +03:30
Mitchell Syer
2230796504 Extension mutations (#560) 2023-05-27 03:09:17 +03:30
Mitchell Syer
458ca7c7cf Fix update chapters (#557) 2023-05-26 13:45:25 +03:30
Mitchell Syer
3f91663ecf Rewrite meta and add meta mutations (#556) 2023-05-26 13:45:16 +03:30
Mitchell Syer
04a671382a Improve GQL Playground (#558) 2023-05-26 13:44:18 +03:30
Mitchell Syer
945ec818e5 Remove category filter (#551) 2023-05-24 14:01:21 +03:30
Mitchell Syer
ff7ac8a785 Fetch Manga and Chapters in GQL (#555) 2023-05-24 14:01:07 +03:30
Mitchell Syer
603105e2ea Fix StringFilter (#554) 2023-05-24 14:00:54 +03:30
Mitchell Syer
5475567b48 Cleanup download type (#553) 2023-05-24 14:00:29 +03:30
Mitchell Syer
2aec0adb08 Category mangas (#552) 2023-05-24 14:00:06 +03:30
Mitchell Syer
54fc3761bf Put graphql under api (#549) 2023-05-17 03:58:00 +03:30
Mitchell Syer
99e1912bfe Fix manga/source and manga/chapters for graphql (#548) 2023-05-17 03:57:20 +03:30
Aria Moradi
ecc1cabafd Merge pull request #547 from Suwayomi/graphql
add graphql
2023-05-13 23:00:13 +03:30
Aria Moradi
1a5b847b23 Update README.md 2023-05-01 19:16:32 +03:30
Aria Moradi
d3409e7133 Update README.md 2023-05-01 19:09:59 +03:30
Aria Moradi
4e553e3eb3 better description about the Tachiyomi extension 2023-05-01 18:44:28 +03:30
Syer10
4577bbc572 More mutations 2023-04-28 21:56:25 -04:00
Syer10
da8ca23496 Start working on mutations 2023-04-28 21:29:06 -04:00
Syer10
988853be63 Seems like this should return null if it errors 2023-04-28 21:28:18 -04:00
schroda
cde5dc5bfa Update "dex2jar" to v60 (#538) 2023-04-10 11:44:22 +03:30
Syer10
b617250eff Delete updates query since the chapters query can now mimic it 2023-04-08 22:55:56 -04:00
Syer10
313da99536 Add in library filter for chapters 2023-04-08 22:54:56 -04:00
Syer10
442e245216 Update TODO 2023-04-08 22:37:51 -04:00
Syer10
050ab17019 Complete SourceQuery 2023-04-08 21:05:05 -04:00
Syer10
c80f488a13 Complete ChapterQuery 2023-04-08 20:40:18 -04:00
Syer10
cf73804c71 Complete ExtensionQuery 2023-04-08 20:39:38 -04:00
Syer10
a90e5d13ea Complete MetaQuery 2023-04-08 15:47:10 -04:00
Syer10
891fb0b479 Simplify keyset pagination 2023-04-08 15:27:18 -04:00
Syer10
58a623d44d Fix keyset pagination for non-unique order by modes 2023-04-08 13:37:09 -04:00
Syer10
0e84b8a154 Lint 2023-04-08 13:36:28 -04:00
Syer10
a4dfcf80e4 Implement manga status filter 2023-04-07 21:30:20 -04:00
Syer10
d8567eadb2 Simplify queries 2023-04-07 21:10:38 -04:00
Syer10
0b88207ad5 Fix empty results errors 2023-04-07 00:02:00 -04:00
Syer10
671466a737 Complete CategoryQuery 2023-04-06 21:53:30 -04:00
Syer10
84881a0d52 Complete MangaQuery 2023-04-04 21:02:29 -04:00
Syer10
a589049cc7 Move things around and introduce Cursor type 2023-04-04 21:02:07 -04:00
Syer10
17877e0f17 Fix case insensitive 2023-04-03 22:12:38 -04:00
Syer10
1ed9bef2a1 Fix the playground explorer and add a updated default query 2023-04-03 22:07:10 -04:00
Syer10
a6dddf311c Basically finish MangaQuery, only paging left 2023-04-03 22:04:46 -04:00
Syer10
e8c2bad187 Handle missing objects in graphql 2023-04-02 20:39:56 -04:00
Syer10
52bda2c080 Start working on graphql paging 2023-04-02 20:15:09 -04:00
Syer10
607919f40f Implement more query parameters 2023-04-02 17:33:19 -04:00
Syer10
d830638ee6 Use actual MangaStatus enum 2023-04-02 16:47:00 -04:00
Syer10
106bda2097 Proper conversion Scalar for Long to String and back 2023-04-02 16:30:03 -04:00
Syer10
7debb27374 Might not need a updates query 2023-03-31 23:11:18 -04:00
Syer10
05b5a7f598 Add updates 2023-03-31 23:03:26 -04:00
Syer10
3bbda7ba54 More todos 2023-03-31 22:36:01 -04:00
Syer10
9312f5fd14 Add global meta 2023-03-31 22:30:14 -04:00
Syer10
399eb07e35 Fix imports 2023-03-31 22:21:09 -04:00
Syer10
eb197ebcee Switch database logger to SLF4J 2023-03-31 22:19:13 -04:00
Syer10
4c30d8ab05 Some TODOs with ideas 2023-03-31 22:00:01 -04:00
Syer10
3a67ddf0f6 Add Extensions to Graphql 2023-03-31 20:58:18 -04:00
Syer10
6541c7b5b7 Serialize Long as String in graphql 2023-03-31 20:30:24 -04:00
Syer10
37f41ade43 Directly use the database for sources in graphql 2023-03-31 20:29:55 -04:00
Syer10
007d20d417 Add Sources to Graphql 2023-03-31 19:44:21 -04:00
Syer10
00370a81fa Minor cleanup 2023-03-31 19:10:04 -04:00
Syer10
d4599c3331 Use Graphiql with the Explorer plugin for the query builder 2023-03-31 19:09:32 -04:00
Syer10
bce76bbcf3 Use Kotlin Coroutines Flow instead of Project reactor 2023-03-30 18:28:56 -04:00
Valter Martinek
847a5fe71b Subscriptions! 2023-03-30 17:05:41 -04:00
Valter Martinek
e2fa003239 Rewrite graphql controller execute as function without docs 2023-03-30 17:04:01 -04:00
Valter Martinek
0c555e88d3 Update graphql-playground endpoint 2023-03-30 17:04:01 -04:00
Valter Martinek
bf7f1a04b3 Add categories to graphql 2023-03-30 17:04:01 -04:00
Valter Martinek
623172af6d Add mutation for updating chapters 2023-03-30 17:04:01 -04:00
Valter Martinek
4fb689d9e4 Add chapter and manga meta field 2023-03-30 17:04:01 -04:00
Valter Martinek
6054c489c6 Add graphql playground 2023-03-30 17:04:01 -04:00
Valter Martinek
21719f4408 Add basic graphql implementation with manga and chapters loading with data loaders 2023-03-30 17:03:56 -04:00
Aria Moradi
f2a650ba02 fix typo 2023-03-27 21:27:10 +03:30
Aria Moradi
871c28b1ea cleanup notes 2023-03-27 21:26:37 +03:30
schroda
d3aa32147a Add logic to only update specific categories (#520)
Makes it possible to only update specific categories.

In case a manga is in an excluded category it will be excluded even if it is also in an included category.
2023-03-27 20:15:46 +03:30
schroda
9a50f2e408 Notify clients even if no manga gets updated (#531)
In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
2023-03-27 17:11:19 +03:30
schroda
dcde4947e8 Emit update to clients after adding all mangas to the queue (#521)
Emitting updates before all the mangas were added to the queue could lead to e.g. wrong progress calculation.
2023-03-25 22:08:42 +03:30
schroda
5b61bdc3a8 add size field to Category data class (#519)
Makes it possible to display the size of a category to the user
2023-03-25 22:07:50 +03:30
schroda
ec1d65f4c3 update library grouped by source (#511)
* Update mangas grouped by source

* Limit parallel update requests
2023-03-10 11:03:09 +03:30
akabhirav
a0081dec07 fix manga unread and download count (#509) 2023-02-23 22:04:03 +03:30
akabhirav
783787e514 Send last read chapter in Mangas in Category API (#507)
* Send last read chapter with manga

* optimize query

* introduce new field for better performance
2023-02-21 02:47:45 +03:30
akabhirav
ac99dd55a2 Fix random page sent when manga is downloaded (#508) 2023-02-21 02:40:38 +03:30
Mitchell Syer
c56f984952 Fix SharedPreferences.Editor.clear and SharedPreferences.Editor.remove (#505)
* Fix SharedPreferences.Editor.clear and SharedPreferences.Editor.remove

* UNCHECKED_CAST

* Support removing Set<String>

* Typo

* Remove unneeded OptIn
2023-02-19 07:54:24 +03:30
Aria Moradi
9269ca726e It's not us, I swear ;;; 2023-02-16 10:57:26 +03:30
DattatreyaReddy Panta
eca3205dcf Update winget.yml (#500) 2023-02-14 15:02:41 +03:30
akabhirav
13f5486d0b Fix CBZ download bug for newly added mangas in Library (#499) 2023-02-13 19:17:14 +03:30
Aria Moradi
d4e71274f9 update changelog 2023-02-12 23:33:06 +03:30
122 changed files with 7187 additions and 302 deletions

View File

@@ -1,12 +1,14 @@
name: Publish to WinGet
on:
release:
types: [released]
workflow_run:
workflows: ["CI Publish"]
types:
- completed
jobs:
publish:
runs-on: windows-latest # action can only be run on windows
steps:
- uses: vedantmgoyal2009/winget-releaser@v1
- uses: vedantmgoyal2009/winget-releaser@v2
with:
identifier: Suwayomi.Tachidesk-Server
installers-regex: '.*x64.msi$'

View File

@@ -11,6 +11,9 @@ import ch.qos.logback.classic.Level
import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory
import com.typesafe.config.ConfigRenderOptions
import com.typesafe.config.ConfigValue
import com.typesafe.config.ConfigValueFactory
import com.typesafe.config.parser.ConfigDocumentFactory
import mu.KotlinLogging
import java.io.File
@@ -18,15 +21,17 @@ import java.io.File
* Manages app config.
*/
open class ConfigManager {
val logger = KotlinLogging.logger {}
private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>()
val config by lazy { loadConfigs() }
private val userConfigFile = File(ApplicationRootDir, "server.conf")
private var internalConfig = loadConfigs()
val config: Config
get() = internalConfig
// Public read-only view of modules
val loadedModules: Map<Class<out ConfigModule>, ConfigModule>
get() = generatedModules
val logger = KotlinLogging.logger {}
/**
* Get a config module
*/
@@ -54,7 +59,7 @@ open class ConfigManager {
// Load user config
val userConfig =
File(ApplicationRootDir, "server.conf").let {
userConfigFile.let {
ConfigFactory.parseFile(it)
}
@@ -86,6 +91,20 @@ open class ConfigManager {
registerModule(it)
}
}
private fun updateUserConfigFile(path: String, value: ConfigValue) {
val userConfigDoc = ConfigDocumentFactory.parseFile(userConfigFile)
val updatedConfigDoc = userConfigDoc.withValue(path, value)
val newFileContent = updatedConfigDoc.render()
userConfigFile.writeText(newFileContent)
}
fun updateValue(path: String, value: Any) {
val configValue = ConfigValueFactory.fromAnyRef(value)
updateUserConfigFile(path, configValue)
internalConfig = internalConfig.withValue(path, configValue)
}
}
object GlobalConfigManager : ConfigManager()

View File

@@ -15,19 +15,23 @@ import kotlin.reflect.KProperty
* Abstract config module.
*/
@Suppress("UNUSED_PARAMETER")
abstract class ConfigModule(config: Config)
abstract class ConfigModule(getConfig: () -> Config)
/**
* Abstract jvm-commandline-argument-overridable config module.
*/
abstract class SystemPropertyOverridableConfigModule(config: Config, moduleName: String) : ConfigModule(config) {
val overridableConfig = SystemPropertyOverrideDelegate(config, moduleName)
abstract class SystemPropertyOverridableConfigModule(getConfig: () -> Config, moduleName: String) : ConfigModule(getConfig) {
val overridableConfig = SystemPropertyOverrideDelegate(getConfig, moduleName)
}
/** Defines a config property that is overridable with jvm `-D` commandline arguments prefixed with [CONFIG_PREFIX] */
class SystemPropertyOverrideDelegate(val config: Config, val moduleName: String) {
class SystemPropertyOverrideDelegate(val getConfig: () -> Config, val moduleName: String) {
operator fun <R> setValue(thisRef: R, property: KProperty<*>, value: Any) {
GlobalConfigManager.updateValue("$moduleName.${property.name}", value)
}
inline operator fun <R, reified T> getValue(thisRef: R, property: KProperty<*>): T {
val configValue: T = config.getValue(thisRef, property)
val configValue: T = getConfig().getValue(thisRef, property)
val combined = System.getProperty(
"$CONFIG_PREFIX.$moduleName.${property.name}",

View File

@@ -8,12 +8,12 @@ import xyz.nulldev.ts.config.ConfigModule
* Application info config.
*/
class ApplicationInfoConfigModule(config: Config) : ConfigModule(config) {
val packageName: String by config
val debug: Boolean by config
class ApplicationInfoConfigModule(getConfig: () -> Config) : ConfigModule(getConfig) {
val packageName: String by getConfig()
val debug: Boolean by getConfig()
companion object {
fun register(config: Config) =
ApplicationInfoConfigModule(config.getConfig("android.app"))
ApplicationInfoConfigModule { config.getConfig("android.app") }
}
}

View File

@@ -8,27 +8,27 @@ import xyz.nulldev.ts.config.ConfigModule
* Files configuration modules. Specifies where to store the Android files.
*/
class FilesConfigModule(config: Config) : ConfigModule(config) {
val dataDir: String by config
val filesDir: String by config
val noBackupFilesDir: String by config
val externalFilesDirs: MutableList<String> by config
val obbDirs: MutableList<String> by config
val cacheDir: String by config
val codeCacheDir: String by config
val externalCacheDirs: MutableList<String> by config
val externalMediaDirs: MutableList<String> by config
val rootDir: String by config
val externalStorageDir: String by config
val downloadCacheDir: String by config
val databasesDir: String by config
class FilesConfigModule(getConfig: () -> Config) : ConfigModule(getConfig) {
val dataDir: String by getConfig()
val filesDir: String by getConfig()
val noBackupFilesDir: String by getConfig()
val externalFilesDirs: MutableList<String> by getConfig()
val obbDirs: MutableList<String> by getConfig()
val cacheDir: String by getConfig()
val codeCacheDir: String by getConfig()
val externalCacheDirs: MutableList<String> by getConfig()
val externalMediaDirs: MutableList<String> by getConfig()
val rootDir: String by getConfig()
val externalStorageDir: String by getConfig()
val downloadCacheDir: String by getConfig()
val databasesDir: String by getConfig()
val prefsDir: String by config
val prefsDir: String by getConfig()
val packageDir: String by config
val packageDir: String by getConfig()
companion object {
fun register(config: Config) =
FilesConfigModule(config.getConfig("android.files"))
FilesConfigModule { config.getConfig("android.files") }
}
}

View File

@@ -4,19 +4,19 @@ import com.typesafe.config.Config
import io.github.config4k.getValue
import xyz.nulldev.ts.config.ConfigModule
class SystemConfigModule(val config: Config) : ConfigModule(config) {
val isDebuggable: Boolean by config
class SystemConfigModule(val getConfig: () -> Config) : ConfigModule(getConfig) {
val isDebuggable: Boolean by getConfig()
val propertyPrefix = "properties."
fun getStringProperty(property: String) = config.getString("$propertyPrefix$property")!!
fun getIntProperty(property: String) = config.getInt("$propertyPrefix$property")
fun getLongProperty(property: String) = config.getLong("$propertyPrefix$property")
fun getBooleanProperty(property: String) = config.getBoolean("$propertyPrefix$property")
fun hasProperty(property: String) = config.hasPath("$propertyPrefix$property")
fun getStringProperty(property: String) = getConfig().getString("$propertyPrefix$property")!!
fun getIntProperty(property: String) = getConfig().getInt("$propertyPrefix$property")
fun getLongProperty(property: String) = getConfig().getLong("$propertyPrefix$property")
fun getBooleanProperty(property: String) = getConfig().getBoolean("$propertyPrefix$property")
fun hasProperty(property: String) = getConfig().hasPath("$propertyPrefix$property")
companion object {
fun register(config: Config) =
SystemConfigModule(config.getConfig("android.system"))
SystemConfigModule { config.getConfig("android.system") }
}
}

View File

@@ -9,7 +9,6 @@ package xyz.nulldev.androidcompat.io.sharedprefs
import android.content.SharedPreferences
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.PreferencesSettings
import com.russhwolf.settings.serialization.decodeValue
import com.russhwolf.settings.serialization.decodeValueOrNull
@@ -21,7 +20,7 @@ import kotlinx.serialization.builtins.serializer
import java.util.prefs.PreferenceChangeListener
import java.util.prefs.Preferences
@OptIn(ExperimentalSettingsImplementation::class, ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
class JavaSharedPreferences(key: String) : SharedPreferences {
private val javaPreferences = Preferences.userRoot().node("suwayomi/tachidesk/$key")
private val preferences = PreferencesSettings(javaPreferences)
@@ -77,13 +76,19 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
}
class Editor(private val preferences: PreferencesSettings) : SharedPreferences.Editor {
val itemsToAdd = mutableMapOf<String, Any>()
private val actions = mutableListOf<Action>()
private sealed class Action {
data class Add(val key: String, val value: Any) : Action()
data class Remove(val key: String) : Action()
object Clear : Action()
}
override fun putString(key: String, value: String?): SharedPreferences.Editor {
if (value != null) {
itemsToAdd[key] = value
actions += Action.Add(key, value)
} else {
remove(key)
actions += Action.Remove(key)
}
return this
}
@@ -93,40 +98,40 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
values: MutableSet<String>?
): SharedPreferences.Editor {
if (values != null) {
itemsToAdd[key] = values
actions += Action.Add(key, values)
} else {
remove(key)
actions += Action.Remove(key)
}
return this
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor {
itemsToAdd[key] = value
actions += Action.Add(key, value)
return this
}
override fun putLong(key: String, value: Long): SharedPreferences.Editor {
itemsToAdd[key] = value
actions += Action.Add(key, value)
return this
}
override fun putFloat(key: String, value: Float): SharedPreferences.Editor {
itemsToAdd[key] = value
actions += Action.Add(key, value)
return this
}
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor {
itemsToAdd[key] = value
actions += Action.Add(key, value)
return this
}
override fun remove(key: String): SharedPreferences.Editor {
itemsToAdd.remove(key)
actions += Action.Remove(key)
return this
}
override fun clear(): SharedPreferences.Editor {
itemsToAdd.clear()
actions.add(Action.Clear)
return this
}
@@ -140,16 +145,33 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
}
private fun addToPreferences() {
itemsToAdd.forEach { (key, value) ->
actions.forEach {
@Suppress("UNCHECKED_CAST")
when (value) {
is Set<*> -> preferences.encodeValue(SetSerializer(String.serializer()), key, value as Set<String>)
is String -> preferences.putString(key, value)
is Int -> preferences.putInt(key, value)
is Long -> preferences.putLong(key, value)
is Float -> preferences.putFloat(key, value)
is Double -> preferences.putDouble(key, value)
is Boolean -> preferences.putBoolean(key, value)
when (it) {
is Action.Add -> when (val value = it.value) {
is Set<*> -> preferences.encodeValue(SetSerializer(String.serializer()), it.key, value as Set<String>)
is String -> preferences.putString(it.key, value)
is Int -> preferences.putInt(it.key, value)
is Long -> preferences.putLong(it.key, value)
is Float -> preferences.putFloat(it.key, value)
is Double -> preferences.putDouble(it.key, value)
is Boolean -> preferences.putBoolean(it.key, value)
}
is Action.Remove -> {
preferences.remove(it.key)
/**
* Set<String> are stored like
* key.0 = value1
* key.1 = value2
* key.size = 2
*/
preferences.keys.forEach { key ->
if (key.startsWith(it.key + ".")) {
preferences.remove(key)
}
}
}
Action.Clear -> preferences.clear()
}
}
}

View File

@@ -1,3 +1,76 @@
# Server: v0.7.0 + WebUI: r983
## TL;DR
- CBZ downloads support
- Webview implementation based on Microsoft playwright, disabled for this release
- Fixed compatibility with some chinese extensions
- Support for Tachiyomi extensions lib 1.4
- WebUI changes:
- Uhh, idk, find out yourself...
## Tachidesk-Server Changelog
- (r1159) v0.6.6 (by @AriaMoradi)
- (r1160) add Chagelog TL;DR (by @AriaMoradi)
- (r1161) fix Changelog typos (by @AriaMoradi)
- (r1162) WebView based cloudflare interceptor ([#456](https://github.com/Suwayomi/Tachidesk-Server/pull/456) by @AriaMoradi)
- (r1163) update issue mod (by @AriaMoradi)
- (r1164) better description (by @AriaMoradi)
- (r1165) fix regex (by @AriaMoradi)
- (r1166) get default User Agent from WebView ([#457](https://github.com/Suwayomi/Tachidesk-Server/pull/457) by @AriaMoradi)
- (r1167) implementation of android.graphics.BitmapFactory ([#460](https://github.com/Suwayomi/Tachidesk-Server/pull/460) by @animeavi)
- (r1168) Basic android.graphics Rect and Canvas implementation ([#461](https://github.com/Suwayomi/Tachidesk-Server/pull/461) by @animeavi)
- (r1169) Get Playwright working ([#462](https://github.com/Suwayomi/Tachidesk-Server/pull/462) by @Syer10)
- (r1170) disable deb release (by @AriaMoradi)
- (r1171) Fix debian release ([#463](https://github.com/Suwayomi/Tachidesk-Server/pull/463) by @mahor1221)
- (r1172) Add better manga thumbnail handling ([#465](https://github.com/Suwayomi/Tachidesk-Server/pull/465) by @Syer10)
- (r1173) Use extension list fallback if extensions fail to fetch ([#469](https://github.com/Suwayomi/Tachidesk-Server/pull/469) by @Syer10)
- (r1174) fix when playwright fails on providing a UA (by @AriaMoradi)
- (r1175) Update CategoryMetaTable.kt (by @AriaMoradi)
- (r1176) fix CategoryMetaTable reference to CategoryTable ([#473](https://github.com/Suwayomi/Tachidesk-Server/pull/473) by @AriaMoradi)
- (r1177) remove possibly misleading sentence (by @AriaMoradi)
- (r1178) Clarify and Update (by @AriaMoradi)
- (r1179) Clarify and Update (by @AriaMoradi)
- (r1180) link to Tachiyomi section (by @AriaMoradi)
- (r1181) fix typo (by @AriaMoradi)
- (r1182) Improve Gradle Configuration ([#478](https://github.com/Suwayomi/Tachidesk-Server/pull/478) by @Syer10)
- (r1183) Improve Playwright handling ([#479](https://github.com/Suwayomi/Tachidesk-Server/pull/479) by @Syer10)
- (r1184) fix ambiguous reference issue on JDK 13+ (by @AriaMoradi)
- (r1185) update gradle version (by @AriaMoradi)
- (r1186) upgrade dorkbox stuff (by @AriaMoradi)
- (r1187) Fixe Dex2Jar and dorkbox dependency issues ([#487](https://github.com/Suwayomi/Tachidesk-Server/pull/487) by @akabhirav)
- (r1188) Fix logging and update system try ([#488](https://github.com/Suwayomi/Tachidesk-Server/pull/488) by @Syer10)
- (r1189) add support for Extensions Lib 1.4 ([#496](https://github.com/Suwayomi/Tachidesk-Server/pull/496) by @Syer10)
- (r1190) disable playwright for v0.6.7 (by @AriaMoradi)
- (r1191) Decouple Cache and Download behaviour ([#493](https://github.com/Suwayomi/Tachidesk-Server/pull/493) by @akabhirav)
- (r1192) rethink image cache ([#498](https://github.com/Suwayomi/Tachidesk-Server/pull/498) by @AriaMoradi)
- (r1193) fix Page index issues for some providers ([#491](https://github.com/Suwayomi/Tachidesk-Server/pull/491) by @akabhirav)
- (r1194) Download as CBZ ([#490](https://github.com/Suwayomi/Tachidesk-Server/pull/490) by @akabhirav)
- (r1195) re-order config options (by @AriaMoradi)
- (r1196) stop using depricated API (by @AriaMoradi)
## Tachidesk-WebUI Changelog
- (r964) Created a GridLayout enum and updated all locations to use it. ([#208](https://github.com/Suwayomi/Tachidesk-WebUI/pull/208) by @infix)
- (r965) fix library update progress rendering ([#210](https://github.com/Suwayomi/Tachidesk-WebUI/pull/210) by @schroda)
- (r966) Save reader settings per manga in Meta ([#216](https://github.com/Suwayomi/Tachidesk-WebUI/pull/216) by @schroda)
- (r967) make default reader settings changeable ([#217](https://github.com/Suwayomi/Tachidesk-WebUI/pull/217) by @schroda)
- (r968) [#211] Refresh Library after a update ([#212](https://github.com/Suwayomi/Tachidesk-WebUI/pull/212) by @schroda)
- (r969) add logic for metadata migration ([#218](https://github.com/Suwayomi/Tachidesk-WebUI/pull/218) by @schroda)
- (r970) set browser tab title ([#220](https://github.com/Suwayomi/Tachidesk-WebUI/pull/220) by @schroda)
- (r971) Add tooltip containing full manga title to title of manga ([#221](https://github.com/Suwayomi/Tachidesk-WebUI/pull/221) by @schroda)
- (r972) show more detailed upload dates for today and yesterday ([#222](https://github.com/Suwayomi/Tachidesk-WebUI/pull/222) by @schroda)
- (r973) add GitHub action on pushing to run lint ([#224](https://github.com/Suwayomi/Tachidesk-WebUI/pull/224) by @schroda)
- (r974) Ignore filters while searching ([#226](https://github.com/Suwayomi/Tachidesk-WebUI/pull/226) by @schroda)
- (r975) force absolute import path ([#223](https://github.com/Suwayomi/Tachidesk-WebUI/pull/223) by @schroda)
- (r976) add prettier for auto formatting ([#231](https://github.com/Suwayomi/Tachidesk-WebUI/pull/231) by @schroda)
- (r977) Fix import path ([#228](https://github.com/Suwayomi/Tachidesk-WebUI/pull/228) by @schroda)
- (r978) increase prettier line length to 120 ([#233](https://github.com/Suwayomi/Tachidesk-WebUI/pull/233) by @schroda)
- (r979) Add chapter page dropdown ([#230](https://github.com/Suwayomi/Tachidesk-WebUI/pull/230) by @schroda)
- (r980) add chapter dropdown to reader nav bar ([#229](https://github.com/Suwayomi/Tachidesk-WebUI/pull/229) by @schroda)
- (r981) Fix lint error ([#235](https://github.com/Suwayomi/Tachidesk-WebUI/pull/235) by @schroda)
- (r982) Fix reader nav bar scroll to page ([#236](https://github.com/Suwayomi/Tachidesk-WebUI/pull/236) by @schroda)
- (r964) Created a GridLayout enum and updated all locations to use it. ([#208](https://github.com/Suwayomi/Tachidesk-WebUI/pull/208) by @infix)
# Server: v0.6.6 + WebUI: r963
## TL;DR
- Batch actions for chapters

View File

@@ -2,9 +2,10 @@
## Where should I start?
Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap.
**Note 1:** Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#tachidesk-server and #tachidesk-webui channels) 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 2:** Your pull request will be squashed into a single commit.
### Important notes
- Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#tachidesk-server and #tachidesk-webui channels) 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.
- Your pull request will be squashed into a single commit.
- We hate big pull requests, make them as small as possible, change one meaningful thing. Spam pull requests, we don't mind.
### Project goals and vision
- Porting Tachiyomi and covering it's features

View File

@@ -145,10 +145,12 @@ Check out [this wiki page](https://github.com/Suwayomi/Tachidesk-Server/wiki/Con
If you face issues with your setup then we are happy to provide help, just join our discord server(a discord badge is on the top of the page, you are just a click clack away!).
## Syncing With Tachiyomi
### The Tachidesk extension
- You can install the `Tachidesk` extension inside tachiyomi.
- The extension will load Tachidesk library.
- By manipulating filters you can browse your categories.
### The Suwayomi extension and tracker
- You can install the `Suwayomi` extension inside tachiyomi.
- The extension will load your Tachidesk library.
- By manipulating extension search filters you can browse your categories.
- You can enable the Suwayomi tracker to track reading progress with your Tachidesk server.
- Note: Tachiyomi [only allowes tracking one way](https://github.com/tachiyomiorg/tachiyomi/issues/1626), meaning that by reading chapters on other Tachidesk clients the last read chapter number will updated on the tracker but tachiyomi won't automatically mark them as read for you.
### Other methods
Checkout [this issue](https://github.com/Suwayomi/Tachidesk-Server/issues/159) for tracking progress.
@@ -177,3 +179,7 @@ Changes to both codebases is licensed under `MPL v. 2.0` as the rest of this pro
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 http://mozilla.org/MPL/2.0/.
## Disclaimer
The developer of this application does not have any affiliation with the content providers available.

View File

@@ -6,7 +6,7 @@ okhttp = "5.0.0-alpha.11" # Major version is locked by Tachiyomi extensions
javalin = "4.6.6" # Javalin 5.0.0+ requires Java 11
jackson = "2.13.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
exposed = "0.40.1"
dex2jar = "v59"
dex2jar = "v60"
rhino = "1.7.14"
settings = "1.0.0-RC"
twelvemonkeys = "3.9.4"
@@ -93,6 +93,7 @@ xmlpull = "xmlpull:xmlpull:1.1.3.4a"
# Disk & File
appdirs = "net.harawata:appdirs:1.2.1"
zip4j = "net.lingala.zip4j:zip4j:2.11.2"
commonscompress = "org.apache.commons:commons-compress:1.23.0"
junrar = "com.github.junrar:junrar:7.5.3"
# CloudflareInterceptor

View File

@@ -48,6 +48,7 @@ dependencies {
// Disk & File
implementation(libs.zip4j)
implementation(libs.commonscompress)
implementation(libs.junrar)
// CloudflareInterceptor
@@ -64,6 +65,10 @@ dependencies {
// implementation(fileTree("lib/"))
implementation(kotlin("script-runtime"))
implementation("com.expediagroup:graphql-kotlin-server:6.4.0")
implementation("com.expediagroup:graphql-kotlin-schema-generator:6.4.0")
implementation("com.graphql-java:graphql-java-extended-scalars:20.0")
testImplementation(libs.mockk)
}

View File

@@ -0,0 +1,33 @@
package eu.kanade.domain.track.service
import eu.kanade.tachiyomi.data.track.TrackService
// import eu.kanade.tachiyomi.data.track.anilist.Anilist
import tachiyomi.core.preference.PreferenceStore
class TrackPreferences(
private val preferenceStore: PreferenceStore
) {
fun trackUsername(sync: TrackService) = preferenceStore.getString(trackUsername(sync.id), "")
fun trackPassword(sync: TrackService) = preferenceStore.getString(trackPassword(sync.id), "")
fun setTrackCredentials(sync: TrackService, username: String, password: String) {
trackUsername(sync).set(username)
trackPassword(sync).set(password)
}
fun trackToken(sync: TrackService) = preferenceStore.getString(trackToken(sync.id), "")
// fun anilistScoreType() = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true)
companion object {
fun trackUsername(syncId: Long) = "pref_mangasync_username_$syncId"
private fun trackPassword(syncId: Long) = "pref_mangasync_password_$syncId"
private fun trackToken(syncId: Long) = "track_token_$syncId"
}
}

View File

@@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
interface Track : Serializable {
var id: Long?
var manga_id: Long
var sync_id: Int
var media_id: Long
var library_id: Long?
var title: String
var last_chapter_read: Float
var total_chapters: Int
var score: Float
var status: Int
var started_reading_date: Long
var finished_reading_date: Long
var tracking_url: String
fun copyPersonalFrom(other: Track) {
last_chapter_read = other.last_chapter_read
score = other.score
status = other.status
started_reading_date = other.started_reading_date
finished_reading_date = other.finished_reading_date
}
companion object {
fun create(serviceId: Long): Track = TrackImpl().apply {
sync_id = serviceId.toInt()
}
}
}

View File

@@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.data.database.models
class TrackImpl : Track {
override var id: Long? = null
override var manga_id: Long = 0
override var sync_id: Int = 0
override var media_id: Long = 0
override var library_id: Long? = null
override lateinit var title: String
override var last_chapter_read: Float = 0F
override var total_chapters: Int = 0
override var score: Float = 0f
override var status: Int = 0
override var started_reading_date: Long = 0
override var finished_reading_date: Long = 0
override var tracking_url: String = ""
}

View File

@@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.data.track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track
/**
* An Enhanced Track Service will never prompt the user to match a manga with the remote.
* It is expected that such Track Service can only work with specific sources and unique IDs.
*/
interface EnhancedTrackService {
/**
* This TrackService will only work with the sources that are accepted by this filter function.
*/
fun accept(source: Source): Boolean {
return source::class.qualifiedName in getAcceptedSources()
}
/**
* Fully qualified source classes that this track service is compatible with.
*/
fun getAcceptedSources(): List<String>
fun loginNoop()
/**
* match is similar to TrackService.search, but only return zero or one match.
*/
suspend fun match(manga: Manga): TrackSearch?
/**
* Checks whether the provided source/track/manga triplet is from this TrackService
*/
fun isTrackFrom(track: Track, manga: Manga, source: Source?): Boolean
/**
* Migrates the given track for the manga to the newSource, if possible
*/
fun migrateTrack(track: Track, manga: Manga, newSource: Source): Track?
}

View File

@@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.data.track
// import eu.kanade.tachiyomi.data.track.anilist.Anilist
// import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
// import eu.kanade.tachiyomi.data.track.kavita.Kavita
// import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
// import eu.kanade.tachiyomi.data.track.komga.Komga
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates
// import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList
// import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
// import eu.kanade.tachiyomi.data.track.suwayomi.Suwayomi
class TrackManager {
companion object {
const val MYANIMELIST = 1L
const val ANILIST = 2L
const val KITSU = 3L
const val SHIKIMORI = 4L
const val BANGUMI = 5L
const val KOMGA = 6L
const val MANGA_UPDATES = 7L
const val KAVITA = 8L
const val SUWAYOMI = 9L
}
// val myAnimeList = MyAnimeList(MYANIMELIST)
// val aniList = Anilist(ANILIST)
// val kitsu = Kitsu(KITSU)
// val shikimori = Shikimori(SHIKIMORI)
// val bangumi = Bangumi(BANGUMI)
// val komga = Komga(KOMGA)
val mangaUpdates = MangaUpdates(MANGA_UPDATES)
// val kavita = Kavita(KAVITA)
// val suwayomi = Suwayomi(SUWAYOMI)
// val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
val services = listOf(mangaUpdates)
fun getService(id: Long) = services.find { it.id == id }
fun hasLoggedServices() = services.any { it.isLogged }
}

View File

@@ -0,0 +1,188 @@
package eu.kanade.tachiyomi.data.track
// import androidx.annotation.CallSuper
// import androidx.annotation.ColorInt
// import androidx.annotation.DrawableRes
// import androidx.annotation.StringRes
// import eu.kanade.domain.base.BasePreferences
// import eu.kanade.domain.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
// import eu.kanade.domain.track.model.toDbTrack
// import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.NetworkHelper
// import eu.kanade.tachiyomi.util.system.toast
// import logcat.LogPriority
import okhttp3.OkHttpClient
// import tachiyomi.core.util.lang.withIOContext
// import tachiyomi.core.util.lang.withUIContext
// import tachiyomi.core.util.system.logcat
// import tachiyomi.domain.chapter.interactor.GetChapterByMangaId
// import tachiyomi.domain.track.interactor.InsertTrack
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
abstract class TrackService(val id: Long) {
// val preferences: BasePreferences by injectLazy()
val trackPreferences: TrackPreferences by injectLazy()
val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
// Application and remote support for reading dates
open val supportsReadingDates: Boolean = false
// @DrawableRes
// abstract fun getLogo(): Int
// @ColorInt
// abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<Int>
// @StringRes
abstract fun getStatus(status: Int): String?
abstract fun getReadingStatus(): Int
abstract fun getRereadingStatus(): Int
abstract fun getCompletionStatus(): Int
abstract fun getScoreList(): List<String>
// TODO: Store all scores as 10 point in the future maybe?
open fun get10PointScore(track: DomainTrack): Float {
return track.score
}
open fun indexToScore(index: Int): Float {
return index.toFloat()
}
abstract fun displayScore(track: Track): String
abstract suspend fun update(track: Track, didReadChapter: Boolean = false): Track
abstract suspend fun bind(track: Track, hasReadChapters: Boolean = false): Track
abstract suspend fun search(query: String): List<TrackSearch>
abstract suspend fun refresh(track: Track): Track
abstract suspend fun login(username: String, password: String)
// @CallSuper
open fun logout() {
trackPreferences.setTrackCredentials(this, "", "")
}
open val isLogged: Boolean
get() = getUsername().isNotEmpty() &&
getPassword().isNotEmpty()
fun getUsername() = trackPreferences.trackUsername(this).get()
fun getPassword() = trackPreferences.trackPassword(this).get()
fun saveCredentials(username: String, password: String) {
trackPreferences.setTrackCredentials(this, username, password)
}
fun withIOContext(body: () -> Unit) { body() }
fun withUIContext(body: () -> Unit) { body() }
fun registerTracking(item: Track, mangaId: Long) {
// item.manga_id = mangaId
// try {
// withIOContext {
// val allChapters = Injekt.get<GetChapterByMangaId>().await(mangaId)
// val hasReadChapters = allChapters.any { it.read }
// bind(item, hasReadChapters)
//
// val track = item.toDomainTrack(idRequired = false) ?: return@withIOContext
//
// Injekt.get<InsertTrack>().await(track)
//
// // Update chapter progress if newer chapters marked read locally
// if (hasReadChapters) {
// val latestLocalReadChapterNumber = allChapters
// .sortedBy { it.chapterNumber }
// .takeWhile { it.read }
// .lastOrNull()
// ?.chapterNumber?.toDouble() ?: -1.0
//
// if (latestLocalReadChapterNumber > track.lastChapterRead) {
// val updatedTrack = track.copy(
// lastChapterRead = latestLocalReadChapterNumber
// )
// setRemoteLastChapterRead(updatedTrack.toDbTrack(), latestLocalReadChapterNumber.toInt())
// }
// }
//
// if (this is EnhancedTrackService) {
// // Injekt.get<SyncChaptersWithTrackServiceTwoWay>().await(allChapters, track, this@TrackService)
// }
// }
// } catch (e: Throwable) {
// withUIContext {
// // Injekt.get<Application>().toast(e.message)
// }
// }
}
fun setRemoteStatus(track: Track, status: Int) {
track.status = status
if (track.status == getCompletionStatus() && track.total_chapters != 0) {
track.last_chapter_read = track.total_chapters.toFloat()
}
withIOContext { updateRemote(track) }
}
fun setRemoteLastChapterRead(track: Track, chapterNumber: Int) {
if (track.last_chapter_read == 0F && track.last_chapter_read < chapterNumber && track.status != getRereadingStatus()) {
track.status = getReadingStatus()
}
track.last_chapter_read = chapterNumber.toFloat()
if (track.total_chapters != 0 && track.last_chapter_read.toInt() == track.total_chapters) {
track.status = getCompletionStatus()
}
withIOContext { updateRemote(track) }
}
fun setRemoteScore(track: Track, scoreString: String) {
track.score = indexToScore(getScoreList().indexOf(scoreString))
withIOContext { updateRemote(track) }
}
fun setRemoteStartDate(track: Track, epochMillis: Long) {
track.started_reading_date = epochMillis
withIOContext { updateRemote(track) }
}
fun setRemoteFinishDate(track: Track, epochMillis: Long) {
track.finished_reading_date = epochMillis
withIOContext { updateRemote(track) }
}
private fun updateRemote(track: Track) {
// withIOContext {
// try {
// update(track)
// track.toDomainTrack(idRequired = false)?.let {
// Injekt.get<InsertTrack>().await(it)
// }
// } catch (e: Exception) {
// logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" }
// withUIContext { Injekt.get<Application>().toast(e.message) }
// }
// }
}
}

View File

@@ -0,0 +1,101 @@
package eu.kanade.tachiyomi.data.track.mangaupdates
// import android.graphics.Color
// import androidx.annotation.StringRes
// import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackSearch
class MangaUpdates(id: Long) : TrackService(id) {
companion object {
const val READING_LIST = 0
const val WISH_LIST = 1
const val COMPLETE_LIST = 2
const val UNFINISHED_LIST = 3
const val ON_HOLD_LIST = 4
}
private val interceptor by lazy { MangaUpdatesInterceptor(this) }
private val api by lazy { MangaUpdatesApi(interceptor, client) }
override val name: String = "MangaUpdates"
// override fun getLogo(): Int = R.drawable.ic_manga_updates
// override fun getLogoColor(): Int = Color.rgb(146, 160, 173)
override fun getStatusList(): List<Int> {
return listOf(READING_LIST, COMPLETE_LIST, ON_HOLD_LIST, UNFINISHED_LIST, WISH_LIST)
}
// @StringRes
override fun getStatus(status: Int): String? = when (status) {
READING_LIST -> "Reading List"
WISH_LIST -> "Wish List"
COMPLETE_LIST -> "Complete List"
ON_HOLD_LIST -> ">On Hold List"
UNFINISHED_LIST -> "Unfinished List"
else -> null
}
override fun getReadingStatus(): Int = READING_LIST
override fun getRereadingStatus(): Int = -1
override fun getCompletionStatus(): Int = COMPLETE_LIST
private val _scoreList = (0..9).flatMap { i -> (0..9).map { j -> "$i.$j" } } + listOf("10.0")
override fun getScoreList(): List<String> = _scoreList
override fun indexToScore(index: Int): Float = _scoreList[index].toFloat()
override fun displayScore(track: Track): String = track.score.toString()
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETE_LIST && didReadChapter) {
track.status = READING_LIST
}
api.updateSeriesListItem(track)
return track
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
return try {
val (series, rating) = api.getSeriesListItem(track)
series.copyTo(track)
rating?.copyTo(track) ?: track
} catch (e: Exception) {
api.addSeriesToList(track, hasReadChapters)
track
}
}
override suspend fun search(query: String): List<TrackSearch> {
return api.search(query)
.map {
it.toTrackSearch(id)
}
}
override suspend fun refresh(track: Track): Track {
val (series, rating) = api.getSeriesListItem(track)
series.copyTo(track)
return rating?.copyTo(track) ?: track
}
override suspend fun login(username: String, password: String) {
val authenticated = api.authenticate(username, password) ?: throw Throwable("Unable to login")
saveCredentials(authenticated.uid.toString(), authenticated.sessionToken)
interceptor.newAuth(authenticated.sessionToken)
}
fun restoreSession(): String? {
return trackPreferences.trackPassword(this).get()
}
}

View File

@@ -0,0 +1,196 @@
package eu.kanade.tachiyomi.data.track.mangaupdates
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.WISH_LIST
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Context
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Record
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
// import logcat.LogPriority
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
// import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy
class MangaUpdatesApi(
interceptor: MangaUpdatesInterceptor,
private val client: OkHttpClient
) {
private val json: Json by injectLazy()
private val baseUrl = "https://api.mangaupdates.com"
private val contentType = "application/vnd.api+json".toMediaType()
private val authClient by lazy {
client.newBuilder()
.addInterceptor(interceptor)
.build()
}
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
val listItem = with(json) {
authClient.newCall(GET("$baseUrl/v1/lists/series/${track.media_id}"))
.awaitSuccess()
.parseAs<ListItem>()
}
val rating = getSeriesRating(track)
return listItem to rating
}
suspend fun addSeriesToList(track: Track, hasReadChapters: Boolean) {
val status = if (hasReadChapters) READING_LIST else WISH_LIST
val body = buildJsonArray {
addJsonObject {
putJsonObject("series") {
put("id", track.media_id)
}
put("list_id", status)
}
}
authClient.newCall(
POST(
url = "$baseUrl/v1/lists/series",
body = body.toString().toRequestBody(contentType)
)
)
.awaitSuccess()
.let {
if (it.code == 200) {
track.status = status
track.last_chapter_read = 1f
}
}
}
suspend fun updateSeriesListItem(track: Track) {
val body = buildJsonArray {
addJsonObject {
putJsonObject("series") {
put("id", track.media_id)
}
put("list_id", track.status)
putJsonObject("status") {
put("chapter", track.last_chapter_read.toInt())
}
}
}
authClient.newCall(
POST(
url = "$baseUrl/v1/lists/series/update",
body = body.toString().toRequestBody(contentType)
)
)
.awaitSuccess()
updateSeriesRating(track)
}
private suspend fun getSeriesRating(track: Track): Rating? {
return try {
with(json) {
authClient.newCall(GET("$baseUrl/v1/series/${track.media_id}/rating"))
.awaitSuccess()
.parseAs<Rating>()
}
} catch (e: Exception) {
null
}
}
private suspend fun updateSeriesRating(track: Track) {
if (track.score != 0f) {
val body = buildJsonObject {
put("rating", track.score)
}
authClient.newCall(
PUT(
url = "$baseUrl/v1/series/${track.media_id}/rating",
body = body.toString().toRequestBody(contentType)
)
)
.awaitSuccess()
} else {
authClient.newCall(
DELETE(
url = "$baseUrl/v1/series/${track.media_id}/rating"
)
)
.awaitSuccess()
}
}
suspend fun search(query: String): List<Record> {
val body = buildJsonObject {
put("search", query)
put(
"filter_types",
buildJsonArray {
add("drama cd")
add("novel")
}
)
}
return with(json) {
client.newCall(
POST(
url = "$baseUrl/v1/series/search",
body = body.toString().toRequestBody(contentType)
)
)
.awaitSuccess()
.parseAs<JsonObject>()
.let { obj ->
obj["results"]?.jsonArray?.map { element ->
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
}
}
.orEmpty()
}
}
suspend fun authenticate(username: String, password: String): Context? {
val body = buildJsonObject {
put("username", username)
put("password", password)
}
return with(json) {
client.newCall(
PUT(
url = "$baseUrl/v1/account/login",
body = body.toString().toRequestBody(contentType)
)
)
.awaitSuccess()
.parseAs<JsonObject>()
.let { obj ->
try {
json.decodeFromJsonElement<Context>(obj["context"]!!)
} catch (e: Exception) {
// logcat(LogPriority.ERROR, e)
null
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.data.track.mangaupdates
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
class MangaUpdatesInterceptor(
mangaUpdates: MangaUpdates
) : Interceptor {
private var token: String? = mangaUpdates.restoreSession()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val token = token ?: throw IOException("Not authenticated with MangaUpdates")
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
return chain.proceed(authRequest)
}
fun newAuth(token: String?) {
this.token = token
}
}

View File

@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Context(
@SerialName("session_token")
val sessionToken: String,
val uid: Long
)

View File

@@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class Image(
val url: Url? = null,
val height: Int? = null,
val width: Int? = null
)

View File

@@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ListItem(
val series: Series? = null,
@SerialName("list_id")
val listId: Int? = null,
val status: Status? = null,
val priority: Int? = null
)
fun ListItem.copyTo(track: Track): Track {
return track.apply {
this.status = listId ?: READING_LIST
this.last_chapter_read = this@copyTo.status?.chapter?.toFloat() ?: 0f
}
}

View File

@@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import eu.kanade.tachiyomi.data.database.models.Track
import kotlinx.serialization.Serializable
@Serializable
data class Rating(
val rating: Float? = null
)
fun Rating.copyTo(track: Track): Track {
return track.apply {
this.score = rating ?: 0f
}
}

View File

@@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.lang.htmlDecode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Record(
@SerialName("series_id")
val seriesId: Long? = null,
val title: String? = null,
val url: String? = null,
val description: String? = null,
val image: Image? = null,
val type: String? = null,
val year: String? = null,
@SerialName("bayesian_rating")
val bayesianRating: Double? = null,
@SerialName("rating_votes")
val ratingVotes: Int? = null,
@SerialName("latest_chapter")
val latestChapter: Int? = null
)
fun Record.toTrackSearch(id: Long): TrackSearch {
return TrackSearch.create(id).apply {
media_id = this@toTrackSearch.seriesId ?: 0L
title = this@toTrackSearch.title?.htmlDecode() ?: ""
total_chapters = 0
cover_url = this@toTrackSearch.image?.url?.original ?: ""
summary = this@toTrackSearch.description?.htmlDecode() ?: ""
tracking_url = this@toTrackSearch.url ?: ""
publishing_status = ""
publishing_type = this@toTrackSearch.type.toString()
start_date = this@toTrackSearch.year.toString()
}
}

View File

@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class Series(
val id: Long? = null,
val title: String? = null
)

View File

@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class Status(
val volume: Int? = null,
val chapter: Int? = null
)

View File

@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class Url(
val original: String? = null,
val thumb: String? = null
)

View File

@@ -0,0 +1,68 @@
package eu.kanade.tachiyomi.data.track.model
import eu.kanade.tachiyomi.data.database.models.Track
class TrackSearch : Track {
override var id: Long? = null
override var manga_id: Long = 0
override var sync_id: Int = 0
override var media_id: Long = 0
override var library_id: Long? = null
override lateinit var title: String
override var last_chapter_read: Float = 0F
override var total_chapters: Int = 0
override var score: Float = 0f
override var status: Int = 0
override var started_reading_date: Long = 0
override var finished_reading_date: Long = 0
override lateinit var tracking_url: String
var cover_url: String = ""
var summary: String = ""
var publishing_status: String = ""
var publishing_type: String = ""
var start_date: String = ""
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TrackSearch
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
if (media_id != other.media_id) return false
return true
}
override fun hashCode(): Int {
var result = manga_id.hashCode()
result = 31 * result + sync_id
result = 31 * result + media_id.hashCode()
return result
}
companion object {
fun create(serviceId: Long): TrackSearch = TrackSearch().apply {
sync_id = serviceId.toInt()
}
}
}

View File

@@ -89,6 +89,11 @@ suspend fun Call.await(): Response {
}
}
suspend fun Call.awaitSuccess(): Response {
// awaitSuccess is a renamed version of our await, they added a new await that allows non-success error codes
return await()
}
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable()
.doOnNext { response ->

View File

@@ -4,6 +4,7 @@ import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.RequestBody
import java.util.concurrent.TimeUnit.MINUTES
@@ -17,11 +18,7 @@ fun GET(
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL
): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
return GET(url.toHttpUrl(), headers, cache)
}
/**
@@ -52,3 +49,31 @@ fun POST(
.cacheControl(cache)
.build()
}
fun PUT(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL
): Request {
return Request.Builder()
.url(url)
.put(body)
.headers(headers)
.cacheControl(cache)
.build()
}
fun DELETE(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL
): Request {
return Request.Builder()
.url(url)
.delete(body)
.headers(headers)
.cacheControl(cache)
.build()
}

View File

@@ -26,6 +26,7 @@ import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import mu.KotlinLogging
import org.apache.commons.compress.archivers.zip.ZipFile
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
@@ -45,7 +46,6 @@ import java.io.FileInputStream
import java.io.InputStream
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
class LocalSource : CatalogueSource {
companion object {
@@ -212,6 +212,12 @@ class LocalSource : CatalogueSource {
manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status
}
// update the cover
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/${manga.url}"))
if (cover != null && cover.exists()) {
manga.thumbnail_url = cover.absolutePath
}
return Observable.just(manga)
}
@@ -356,7 +362,7 @@ class LocalSource : CatalogueSource {
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
val entry = zip.entries.toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }

View File

@@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import org.apache.commons.compress.archivers.zip.ZipFile
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.util.zip.ZipFile
class ZipPageLoader(file: File) : PageLoader {
/**
@@ -16,7 +16,7 @@ class ZipPageLoader(file: File) : PageLoader {
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return zip.entries().toList()
return zip.entries.toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->

View File

@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.util.lang
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import org.jsoup.Jsoup
import org.jsoup.safety.Safelist
import kotlin.math.floor
/**
@@ -56,3 +58,10 @@ fun String.takeBytes(n: Int): String {
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
}
}
/**
* HTML-decode the string
*/
fun String.htmlDecode(): String {
return Jsoup.clean(this, Safelist.none()).toString()
}

View File

@@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.util.storage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipFile
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.io.Closeable
@@ -10,8 +12,6 @@ import java.io.InputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
@@ -38,14 +38,14 @@ class EpubFile(file: File) : Closeable {
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
fun getInputStream(entry: ZipArchiveEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
fun getEntry(name: String): ZipArchiveEntry? {
return zip.getEntry(name)
}

View File

@@ -0,0 +1,23 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql
import io.javalin.apibuilder.ApiBuilder.get
import io.javalin.apibuilder.ApiBuilder.post
import io.javalin.apibuilder.ApiBuilder.ws
import suwayomi.tachidesk.graphql.controller.GraphQLController
object GraphQL {
fun defineEndpoints() {
post("graphql", GraphQLController::execute)
ws("graphql", GraphQLController::webSocket)
// graphql playground
get("graphql", GraphQLController::playground)
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.controller
import io.javalin.http.ContentType
import io.javalin.http.Context
import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.graphql.server.TachideskGraphQLServer
import suwayomi.tachidesk.server.JavalinSetup.future
object GraphQLController {
private val server = TachideskGraphQLServer.create()
/** execute graphql query */
fun execute(ctx: Context) {
ctx.future(
future {
server.execute(ctx)
}
)
}
fun playground(ctx: Context) {
ctx.contentType(ContentType.TEXT_HTML)
ctx.result(javaClass.getResourceAsStream("/graphql-playground.html")!!)
}
fun webSocket(ws: WsConfig) {
ws.onMessage { ctx ->
server.handleSubscriptionMessage(ctx)
}
ws.onClose { ctx ->
server.handleSubscriptionDisconnect(ctx)
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.CategoryNodeList
import suwayomi.tachidesk.graphql.types.CategoryNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.CategoryType
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.server.JavalinSetup.future
class CategoryDataLoader : KotlinDataLoader<Int, CategoryType> {
override val dataLoaderName = "CategoryDataLoader"
override fun getDataLoader(): DataLoader<Int, CategoryType> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val categories = CategoryTable.select { CategoryTable.id inList ids }
.map { CategoryType(it) }
.associateBy { it.id }
ids.map { categories[it] }
}
}
}
}
class CategoriesForMangaDataLoader : KotlinDataLoader<Int, CategoryNodeList> {
override val dataLoaderName = "CategoriesForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, CategoryNodeList> = DataLoaderFactory.newDataLoader<Int, CategoryNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val itemsByRef = CategoryMangaTable.innerJoin(CategoryTable)
.select { CategoryMangaTable.manga inList ids }
.map { Pair(it[CategoryMangaTable.manga].value, CategoryType(it)) }
.groupBy { it.first }
.mapValues { it.value.map { pair -> pair.second } }
ids.map { (itemsByRef[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.ChapterNodeList
import suwayomi.tachidesk.graphql.types.ChapterNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.future
class ChapterDataLoader : KotlinDataLoader<Int, ChapterType?> {
override val dataLoaderName = "ChapterDataLoader"
override fun getDataLoader(): DataLoader<Int, ChapterType?> = DataLoaderFactory.newDataLoader<Int, ChapterType> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val chapters = ChapterTable.select { ChapterTable.id inList ids }
.map { ChapterType(it) }
.associateBy { it.id }
ids.map { chapters[it] }
}
}
}
}
class ChaptersForMangaDataLoader : KotlinDataLoader<Int, ChapterNodeList> {
override val dataLoaderName = "ChaptersForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, ChapterNodeList> = DataLoaderFactory.newDataLoader<Int, ChapterNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val chaptersByMangaId = ChapterTable.select { ChapterTable.manga inList ids }
.map { ChapterType(it) }
.groupBy { it.mangaId }
ids.map { (chaptersByMangaId[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.JavalinSetup.future
class ExtensionDataLoader : KotlinDataLoader<String, ExtensionType?> {
override val dataLoaderName = "ExtensionDataLoader"
override fun getDataLoader(): DataLoader<String, ExtensionType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val extensions = ExtensionTable.select { ExtensionTable.pkgName inList ids }
.map { ExtensionType(it) }
.associateBy { it.pkgName }
ids.map { extensions[it] }
}
}
}
}
class ExtensionForSourceDataLoader : KotlinDataLoader<Long, ExtensionType?> {
override val dataLoaderName = "ExtensionForSourceDataLoader"
override fun getDataLoader(): DataLoader<Long, ExtensionType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val extensions = ExtensionTable.innerJoin(SourceTable)
.select { SourceTable.id inList ids }
.toList()
.map { Triple(it[SourceTable.id].value, it[ExtensionTable.pkgName], it) }
.let { triples ->
val sources = buildMap {
triples.forEach {
if (!containsKey(it.second)) {
put(it.second, ExtensionType(it.third))
}
}
}
triples.associate {
it.first to sources[it.second]
}
}
ids.map { extensions[it] }
}
}
}
}

View File

@@ -0,0 +1,67 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.MangaNodeList
import suwayomi.tachidesk.graphql.types.MangaNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.JavalinSetup.future
class MangaDataLoader : KotlinDataLoader<Int, MangaType?> {
override val dataLoaderName = "MangaDataLoader"
override fun getDataLoader(): DataLoader<Int, MangaType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val manga = MangaTable.select { MangaTable.id inList ids }
.map { MangaType(it) }
.associateBy { it.id }
ids.map { manga[it] }
}
}
}
}
class MangaForCategoryDataLoader : KotlinDataLoader<Int, MangaNodeList> {
override val dataLoaderName = "MangaForCategoryDataLoader"
override fun getDataLoader(): DataLoader<Int, MangaNodeList> = DataLoaderFactory.newDataLoader<Int, MangaNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val itemsByRef = if (ids.contains(0)) {
MangaTable
.leftJoin(CategoryMangaTable)
.select { MangaTable.inLibrary eq true }
.andWhere { CategoryMangaTable.manga.isNull() }
.map { MangaType(it) }
.let {
mapOf(0 to it)
}
} else {
emptyMap()
} + CategoryMangaTable.innerJoin(MangaTable)
.select { CategoryMangaTable.category inList ids }
.map { Pair(it[CategoryMangaTable.category].value, MangaType(it)) }
.groupBy { it.first }
.mapValues { it.value.map { pair -> pair.second } }
ids.map { (itemsByRef[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,78 @@
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.types.CategoryMetaType
import suwayomi.tachidesk.graphql.types.ChapterMetaType
import suwayomi.tachidesk.graphql.types.GlobalMetaType
import suwayomi.tachidesk.graphql.types.MangaMetaType
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.server.JavalinSetup.future
class GlobalMetaDataLoader : KotlinDataLoader<String, GlobalMetaType?> {
override val dataLoaderName = "GlobalMetaDataLoader"
override fun getDataLoader(): DataLoader<String, GlobalMetaType?> = DataLoaderFactory.newDataLoader<String, GlobalMetaType?> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = GlobalMetaTable.select { GlobalMetaTable.key inList ids }
.map { GlobalMetaType(it) }
.associateBy { it.key }
ids.map { metasByRefId[it] }
}
}
}
}
class ChapterMetaDataLoader : KotlinDataLoader<Int, List<ChapterMetaType>> {
override val dataLoaderName = "ChapterMetaDataLoader"
override fun getDataLoader(): DataLoader<Int, List<ChapterMetaType>> = DataLoaderFactory.newDataLoader<Int, List<ChapterMetaType>> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = ChapterMetaTable.select { ChapterMetaTable.ref inList ids }
.map { ChapterMetaType(it) }
.groupBy { it.chapterId }
ids.map { metasByRefId[it].orEmpty() }
}
}
}
}
class MangaMetaDataLoader : KotlinDataLoader<Int, List<MangaMetaType>> {
override val dataLoaderName = "MangaMetaDataLoader"
override fun getDataLoader(): DataLoader<Int, List<MangaMetaType>> = DataLoaderFactory.newDataLoader<Int, List<MangaMetaType>> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = MangaMetaTable.select { MangaMetaTable.ref inList ids }
.map { MangaMetaType(it) }
.groupBy { it.mangaId }
ids.map { metasByRefId[it].orEmpty() }
}
}
}
}
class CategoryMetaDataLoader : KotlinDataLoader<Int, List<CategoryMetaType>> {
override val dataLoaderName = "CategoryMetaDataLoader"
override fun getDataLoader(): DataLoader<Int, List<CategoryMetaType>> = DataLoaderFactory.newDataLoader<Int, List<CategoryMetaType>> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = CategoryMetaTable.select { CategoryMetaTable.ref inList ids }
.map { CategoryMetaType(it) }
.groupBy { it.categoryId }
ids.map { metasByRefId[it].orEmpty() }
}
}
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.SourceNodeList
import suwayomi.tachidesk.graphql.types.SourceNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.SourceType
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.JavalinSetup.future
class SourceDataLoader : KotlinDataLoader<Long, SourceType?> {
override val dataLoaderName = "SourceDataLoader"
override fun getDataLoader(): DataLoader<Long, SourceType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val source = SourceTable.select { SourceTable.id inList ids }
.mapNotNull { SourceType(it) }
.associateBy { it.id }
ids.map { source[it] }
}
}
}
}
class SourcesForExtensionDataLoader : KotlinDataLoader<String, SourceNodeList> {
override val dataLoaderName = "SourcesForExtensionDataLoader"
override fun getDataLoader(): DataLoader<String, SourceNodeList> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val sourcesByExtensionPkg = SourceTable.innerJoin(ExtensionTable)
.select { ExtensionTable.pkgName inList ids }
.map { Pair(it[ExtensionTable.pkgName], SourceType(it)) }
.groupBy { it.first }
.mapValues { it.value.mapNotNull { pair -> pair.second } }
ids.map { (sourcesByExtensionPkg[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,400 @@
package suwayomi.tachidesk.graphql.mutations
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
import org.jetbrains.exposed.sql.SqlExpressionBuilder.minus
import org.jetbrains.exposed.sql.SqlExpressionBuilder.plus
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.types.CategoryMetaType
import suwayomi.tachidesk.graphql.types.CategoryType
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.util.lang.isEmpty
import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaTable
class CategoryMutation {
data class SetCategoryMetaInput(
val clientMutationId: String? = null,
val meta: CategoryMetaType
)
data class SetCategoryMetaPayload(
val clientMutationId: String?,
val meta: CategoryMetaType
)
fun setCategoryMeta(
input: SetCategoryMetaInput
): SetCategoryMetaPayload {
val (clientMutationId, meta) = input
Category.modifyMeta(meta.categoryId, meta.key, meta.value)
return SetCategoryMetaPayload(clientMutationId, meta)
}
data class DeleteCategoryMetaInput(
val clientMutationId: String? = null,
val categoryId: Int,
val key: String
)
data class DeleteCategoryMetaPayload(
val clientMutationId: String?,
val meta: CategoryMetaType?,
val category: CategoryType
)
fun deleteCategoryMeta(
input: DeleteCategoryMetaInput
): DeleteCategoryMetaPayload {
val (clientMutationId, categoryId, key) = input
val (meta, category) = transaction {
val meta = CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
.firstOrNull()
CategoryMetaTable.deleteWhere { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
val category = transaction {
CategoryType(CategoryTable.select { CategoryTable.id eq categoryId }.first())
}
if (meta != null) {
CategoryMetaType(meta)
} else {
null
} to category
}
return DeleteCategoryMetaPayload(clientMutationId, meta, category)
}
data class UpdateCategoryPatch(
val name: String? = null,
val default: Boolean? = null,
val includeInUpdate: IncludeInUpdate? = null
)
data class UpdateCategoryPayload(
val clientMutationId: String?,
val category: CategoryType
)
data class UpdateCategoryInput(
val clientMutationId: String? = null,
val id: Int,
val patch: UpdateCategoryPatch
)
data class UpdateCategoriesPayload(
val clientMutationId: String?,
val categories: List<CategoryType>
)
data class UpdateCategoriesInput(
val clientMutationId: String? = null,
val ids: List<Int>,
val patch: UpdateCategoryPatch
)
private fun updateCategories(ids: List<Int>, patch: UpdateCategoryPatch) {
transaction {
if (patch.name != null) {
CategoryTable.update({ CategoryTable.id inList ids }) { update ->
patch.name.also {
update[name] = it
}
}
}
if (patch.default != null) {
CategoryTable.update({ CategoryTable.id inList ids }) { update ->
patch.default.also {
update[isDefault] = it
}
}
}
if (patch.includeInUpdate != null) {
CategoryTable.update({ CategoryTable.id inList ids }) { update ->
patch.includeInUpdate.also {
update[includeInUpdate] = it.value
}
}
}
}
}
fun updateCategory(input: UpdateCategoryInput): UpdateCategoryPayload {
val (clientMutationId, id, patch) = input
updateCategories(listOf(id), patch)
val category = transaction {
CategoryType(CategoryTable.select { CategoryTable.id eq id }.first())
}
return UpdateCategoryPayload(
clientMutationId = clientMutationId,
category = category
)
}
fun updateCategories(input: UpdateCategoriesInput): UpdateCategoriesPayload {
val (clientMutationId, ids, patch) = input
updateCategories(ids, patch)
val categories = transaction {
CategoryTable.select { CategoryTable.id inList ids }.map { CategoryType(it) }
}
return UpdateCategoriesPayload(
clientMutationId = clientMutationId,
categories = categories
)
}
data class UpdateCategoryOrderPayload(
val clientMutationId: String?,
val categories: List<CategoryType>
)
data class UpdateCategoryOrderInput(
val clientMutationId: String? = null,
val id: Int,
val position: Int
)
fun updateCategoryOrder(input: UpdateCategoryOrderInput): UpdateCategoryOrderPayload {
val (clientMutationId, categoryId, position) = input
require(position > 0) {
"'order' must not be <= 0"
}
transaction {
val currentOrder = CategoryTable
.select { CategoryTable.id eq categoryId }
.first()[CategoryTable.order]
if (currentOrder != position) {
if (position < currentOrder) {
CategoryTable.update({ CategoryTable.order greaterEq position }) {
it[CategoryTable.order] = CategoryTable.order + 1
}
} else {
CategoryTable.update({ CategoryTable.order lessEq position }) {
it[CategoryTable.order] = CategoryTable.order - 1
}
}
CategoryTable.update({ CategoryTable.id eq categoryId }) {
it[CategoryTable.order] = position
}
}
}
Category.normalizeCategories()
val categories = transaction {
CategoryTable.selectAll().orderBy(CategoryTable.order).map { CategoryType(it) }
}
return UpdateCategoryOrderPayload(
clientMutationId = clientMutationId,
categories = categories
)
}
data class CreateCategoryInput(
val clientMutationId: String? = null,
val name: String,
val order: Int? = null,
val default: Boolean? = null,
val includeInUpdate: IncludeInUpdate? = null
)
data class CreateCategoryPayload(
val clientMutationId: String?,
val category: CategoryType
)
fun createCategory(
input: CreateCategoryInput
): CreateCategoryPayload {
val (clientMutationId, name, order, default, includeInUpdate) = input
transaction {
require(CategoryTable.select { CategoryTable.name eq input.name }.isEmpty()) {
"'name' must be unique"
}
}
require(!name.equals(Category.DEFAULT_CATEGORY_NAME, ignoreCase = true)) {
"'name' must not be ${Category.DEFAULT_CATEGORY_NAME}"
}
if (order != null) {
require(order > 0) {
"'order' must not be <= 0"
}
}
val category = transaction {
if (order != null) {
CategoryTable.update({ CategoryTable.order greaterEq order }) {
it[CategoryTable.order] = CategoryTable.order + 1
}
}
val id = CategoryTable.insertAndGetId {
it[CategoryTable.name] = input.name
it[CategoryTable.order] = order ?: Int.MAX_VALUE
if (default != null) {
it[CategoryTable.isDefault] = default
}
if (includeInUpdate != null) {
it[CategoryTable.includeInUpdate] = includeInUpdate.value
}
}
Category.normalizeCategories()
CategoryType(CategoryTable.select { CategoryTable.id eq id }.first())
}
return CreateCategoryPayload(clientMutationId, category)
}
data class DeleteCategoryInput(
val clientMutationId: String? = null,
val categoryId: Int
)
data class DeleteCategoryPayload(
val clientMutationId: String?,
val category: CategoryType?,
val mangas: List<MangaType>
)
fun deleteCategory(
input: DeleteCategoryInput
): DeleteCategoryPayload {
val (clientMutationId, categoryId) = input
if (categoryId == 0) { // Don't delete default category
return DeleteCategoryPayload(
clientMutationId,
null,
emptyList()
)
}
val (category, mangas) = transaction {
val category = CategoryTable.select { CategoryTable.id eq categoryId }
.firstOrNull()
val mangas = transaction {
MangaTable.innerJoin(CategoryMangaTable)
.select { CategoryMangaTable.category eq categoryId }
.map { MangaType(it) }
}
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
Category.normalizeCategories()
if (category != null) {
CategoryType(category)
} else {
null
} to mangas
}
return DeleteCategoryPayload(clientMutationId, category, mangas)
}
data class UpdateMangaCategoriesPatch(
val clearCategories: Boolean? = null,
val addToCategories: List<Int>? = null,
val removeFromCategories: List<Int>? = null
)
data class UpdateMangaCategoriesPayload(
val clientMutationId: String?,
val manga: MangaType
)
data class UpdateMangaCategoriesInput(
val clientMutationId: String? = null,
val id: Int,
val patch: UpdateMangaCategoriesPatch
)
data class UpdateMangasCategoriesPayload(
val clientMutationId: String?,
val mangas: List<MangaType>
)
data class UpdateMangasCategoriesInput(
val clientMutationId: String? = null,
val ids: List<Int>,
val patch: UpdateMangaCategoriesPatch
)
private fun updateMangas(ids: List<Int>, patch: UpdateMangaCategoriesPatch) {
transaction {
if (patch.clearCategories == true) {
CategoryMangaTable.deleteWhere { CategoryMangaTable.manga inList ids }
} else if (!patch.removeFromCategories.isNullOrEmpty()) {
CategoryMangaTable.deleteWhere {
(CategoryMangaTable.manga inList ids) and (CategoryMangaTable.category inList patch.removeFromCategories)
}
}
if (!patch.addToCategories.isNullOrEmpty()) {
val newCategories = buildList {
ids.forEach { mangaId ->
patch.addToCategories.forEach { categoryId ->
val existingMapping = CategoryMangaTable.select {
(CategoryMangaTable.manga eq mangaId) and (CategoryMangaTable.category eq categoryId)
}.isNotEmpty()
if (!existingMapping) {
add(mangaId to categoryId)
}
}
}
}
CategoryMangaTable.batchInsert(newCategories) { (manga, category) ->
this[CategoryMangaTable.manga] = manga
this[CategoryMangaTable.category] = category
}
}
}
}
fun updateMangaCategories(input: UpdateMangaCategoriesInput): UpdateMangaCategoriesPayload {
val (clientMutationId, id, patch) = input
updateMangas(listOf(id), patch)
val manga = transaction {
MangaType(MangaTable.select { MangaTable.id eq id }.first())
}
return UpdateMangaCategoriesPayload(
clientMutationId = clientMutationId,
manga = manga
)
}
fun updateMangasCategories(input: UpdateMangasCategoriesInput): UpdateMangasCategoriesPayload {
val (clientMutationId, ids, patch) = input
updateMangas(ids, patch)
val mangas = transaction {
MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) }
}
return UpdateMangasCategoriesPayload(
clientMutationId = clientMutationId,
mangas = mangas
)
}
}

View File

@@ -0,0 +1,186 @@
package suwayomi.tachidesk.graphql.mutations
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.types.ChapterMetaType
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.time.Instant
import java.util.concurrent.CompletableFuture
/**
* TODO Mutations
* - Download
* - Delete download
*/
class ChapterMutation {
data class UpdateChapterPatch(
val isBookmarked: Boolean? = null,
val isRead: Boolean? = null,
val lastPageRead: Int? = null
)
data class UpdateChapterPayload(
val clientMutationId: String?,
val chapter: ChapterType
)
data class UpdateChapterInput(
val clientMutationId: String? = null,
val id: Int,
val patch: UpdateChapterPatch
)
data class UpdateChaptersPayload(
val clientMutationId: String?,
val chapters: List<ChapterType>
)
data class UpdateChaptersInput(
val clientMutationId: String? = null,
val ids: List<Int>,
val patch: UpdateChapterPatch
)
private fun updateChapters(ids: List<Int>, patch: UpdateChapterPatch) {
transaction {
if (patch.isRead != null || patch.isBookmarked != null || patch.lastPageRead != null) {
val now = Instant.now().epochSecond
ChapterTable.update({ ChapterTable.id inList ids }) { update ->
patch.isRead?.also {
update[isRead] = it
}
patch.isBookmarked?.also {
update[isBookmarked] = it
}
patch.lastPageRead?.also {
update[lastPageRead] = it
update[lastReadAt] = now
}
}
}
}
}
fun updateChapter(
input: UpdateChapterInput
): UpdateChapterPayload {
val (clientMutationId, id, patch) = input
updateChapters(listOf(id), patch)
val chapter = transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq id }.first())
}
return UpdateChapterPayload(
clientMutationId = clientMutationId,
chapter = chapter
)
}
fun updateChapters(
input: UpdateChaptersInput
): UpdateChaptersPayload {
val (clientMutationId, ids, patch) = input
updateChapters(ids, patch)
val chapters = transaction {
ChapterTable.select { ChapterTable.id inList ids }.map { ChapterType(it) }
}
return UpdateChaptersPayload(
clientMutationId = clientMutationId,
chapters = chapters
)
}
data class FetchChaptersInput(
val clientMutationId: String? = null,
val mangaId: Int
)
data class FetchChaptersPayload(
val clientMutationId: String?,
val chapters: List<ChapterType>
)
fun fetchChapters(
input: FetchChaptersInput
): CompletableFuture<FetchChaptersPayload> {
val (clientMutationId, mangaId) = input
return future {
Chapter.fetchChapterList(mangaId)
}.thenApply {
val chapters = transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }
.orderBy(ChapterTable.sourceOrder)
.map { ChapterType(it) }
}
FetchChaptersPayload(
clientMutationId = clientMutationId,
chapters = chapters
)
}
}
data class SetChapterMetaInput(
val clientMutationId: String? = null,
val meta: ChapterMetaType
)
data class SetChapterMetaPayload(
val clientMutationId: String?,
val meta: ChapterMetaType
)
fun setChapterMeta(
input: SetChapterMetaInput
): SetChapterMetaPayload {
val (clientMutationId, meta) = input
Chapter.modifyChapterMeta(meta.chapterId, meta.key, meta.value)
return SetChapterMetaPayload(clientMutationId, meta)
}
data class DeleteChapterMetaInput(
val clientMutationId: String? = null,
val chapterId: Int,
val key: String
)
data class DeleteChapterMetaPayload(
val clientMutationId: String?,
val meta: ChapterMetaType?,
val chapter: ChapterType
)
fun deleteChapterMeta(
input: DeleteChapterMetaInput
): DeleteChapterMetaPayload {
val (clientMutationId, chapterId, key) = input
val (meta, chapter) = transaction {
val meta = ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
.firstOrNull()
ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
val chapter = transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq chapterId }.first())
}
if (meta != null) {
ChapterMetaType(meta)
} else {
null
} to chapter
}
return DeleteChapterMetaPayload(clientMutationId, meta, chapter)
}
}

View File

@@ -0,0 +1,127 @@
package suwayomi.tachidesk.graphql.mutations
import eu.kanade.tachiyomi.source.local.LocalSource
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
class ExtensionMutation {
data class UpdateExtensionPatch(
val install: Boolean? = null,
val update: Boolean? = null,
val uninstall: Boolean? = null
)
data class UpdateExtensionPayload(
val clientMutationId: String?,
val extension: ExtensionType
)
data class UpdateExtensionInput(
val clientMutationId: String? = null,
val id: String,
val patch: UpdateExtensionPatch
)
data class UpdateExtensionsPayload(
val clientMutationId: String?,
val extensions: List<ExtensionType>
)
data class UpdateExtensionsInput(
val clientMutationId: String? = null,
val ids: List<String>,
val patch: UpdateExtensionPatch
)
private suspend fun updateExtensions(ids: List<String>, patch: UpdateExtensionPatch) {
val extensions = transaction {
ExtensionTable.select { ExtensionTable.pkgName inList ids }
.map { ExtensionType(it) }
}
if (patch.update == true) {
extensions.filter { it.hasUpdate }.forEach {
Extension.updateExtension(it.pkgName)
}
}
if (patch.install == true) {
extensions.filterNot { it.isInstalled }.forEach {
Extension.installExtension(it.pkgName)
}
}
if (patch.uninstall == true) {
extensions.filter { it.isInstalled }.forEach {
Extension.uninstallExtension(it.pkgName)
}
}
}
fun updateExtension(input: UpdateExtensionInput): CompletableFuture<UpdateExtensionPayload> {
val (clientMutationId, id, patch) = input
return future {
updateExtensions(listOf(id), patch)
}.thenApply {
val extension = transaction {
ExtensionType(ExtensionTable.select { ExtensionTable.pkgName eq id }.first())
}
UpdateExtensionPayload(
clientMutationId = clientMutationId,
extension = extension
)
}
}
fun updateExtensions(input: UpdateExtensionsInput): CompletableFuture<UpdateExtensionsPayload> {
val (clientMutationId, ids, patch) = input
return future {
updateExtensions(ids, patch)
}.thenApply {
val extensions = transaction {
ExtensionTable.select { ExtensionTable.pkgName inList ids }
.map { ExtensionType(it) }
}
UpdateExtensionsPayload(
clientMutationId = clientMutationId,
extensions = extensions
)
}
}
data class FetchExtensionsInput(
val clientMutationId: String? = null
)
data class FetchExtensionsPayload(
val clientMutationId: String?,
val extensions: List<ExtensionType>
)
fun fetchExtensions(
input: FetchExtensionsInput
): CompletableFuture<FetchExtensionsPayload> {
val (clientMutationId) = input
return future {
ExtensionsList.fetchExtensions()
}.thenApply {
val extensions = transaction {
ExtensionTable.select { ExtensionTable.name neq LocalSource.EXTENSION_NAME }
.map { ExtensionType(it) }
}
FetchExtensionsPayload(
clientMutationId = clientMutationId,
extensions = extensions
)
}
}
}

View File

@@ -0,0 +1,168 @@
package suwayomi.tachidesk.graphql.mutations
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.types.MangaMetaType
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
/**
* TODO Mutations
* - Download x(all = -1) chapters
* - Delete read/all downloaded chapters
*/
class MangaMutation {
data class UpdateMangaPatch(
val inLibrary: Boolean? = null
)
data class UpdateMangaPayload(
val clientMutationId: String?,
val manga: MangaType
)
data class UpdateMangaInput(
val clientMutationId: String? = null,
val id: Int,
val patch: UpdateMangaPatch
)
data class UpdateMangasPayload(
val clientMutationId: String?,
val mangas: List<MangaType>
)
data class UpdateMangasInput(
val clientMutationId: String? = null,
val ids: List<Int>,
val patch: UpdateMangaPatch
)
private fun updateMangas(ids: List<Int>, patch: UpdateMangaPatch) {
transaction {
if (patch.inLibrary != null) {
MangaTable.update({ MangaTable.id inList ids }) { update ->
patch.inLibrary.also {
update[inLibrary] = it
}
}
}
}
}
fun updateManga(input: UpdateMangaInput): UpdateMangaPayload {
val (clientMutationId, id, patch) = input
updateMangas(listOf(id), patch)
val manga = transaction {
MangaType(MangaTable.select { MangaTable.id eq id }.first())
}
return UpdateMangaPayload(
clientMutationId = clientMutationId,
manga = manga
)
}
fun updateMangas(input: UpdateMangasInput): UpdateMangasPayload {
val (clientMutationId, ids, patch) = input
updateMangas(ids, patch)
val mangas = transaction {
MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) }
}
return UpdateMangasPayload(
clientMutationId = clientMutationId,
mangas = mangas
)
}
data class FetchMangaInput(
val clientMutationId: String? = null,
val id: Int
)
data class FetchMangaPayload(
val clientMutationId: String?,
val manga: MangaType
)
fun fetchManga(
input: FetchMangaInput
): CompletableFuture<FetchMangaPayload> {
val (clientMutationId, id) = input
return future {
Manga.fetchManga(id)
}.thenApply {
val manga = transaction {
MangaTable.select { MangaTable.id eq id }.first()
}
FetchMangaPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga)
)
}
}
data class SetMangaMetaInput(
val clientMutationId: String? = null,
val meta: MangaMetaType
)
data class SetMangaMetaPayload(
val clientMutationId: String?,
val meta: MangaMetaType
)
fun setMangaMeta(
input: SetMangaMetaInput
): SetMangaMetaPayload {
val (clientMutationId, meta) = input
Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value)
return SetMangaMetaPayload(clientMutationId, meta)
}
data class DeleteMangaMetaInput(
val clientMutationId: String? = null,
val mangaId: Int,
val key: String
)
data class DeleteMangaMetaPayload(
val clientMutationId: String?,
val meta: MangaMetaType?,
val manga: MangaType
)
fun deleteMangaMeta(
input: DeleteMangaMetaInput
): DeleteMangaMetaPayload {
val (clientMutationId, mangaId, key) = input
val (meta, manga) = transaction {
val meta = MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
.firstOrNull()
MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
val manga = transaction {
MangaType(MangaTable.select { MangaTable.id eq mangaId }.first())
}
if (meta != null) {
MangaMetaType(meta)
} else {
null
} to manga
}
return DeleteMangaMetaPayload(clientMutationId, meta, manga)
}
}

View File

@@ -0,0 +1,59 @@
package suwayomi.tachidesk.graphql.mutations
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.global.impl.GlobalMeta
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.types.GlobalMetaType
class MetaMutation {
data class SetGlobalMetaInput(
val clientMutationId: String? = null,
val meta: GlobalMetaType
)
data class SetGlobalMetaPayload(
val clientMutationId: String?,
val meta: GlobalMetaType
)
fun setGlobalMeta(
input: SetGlobalMetaInput
): SetGlobalMetaPayload {
val (clientMutationId, meta) = input
GlobalMeta.modifyMeta(meta.key, meta.value)
return SetGlobalMetaPayload(clientMutationId, meta)
}
data class DeleteGlobalMetaInput(
val clientMutationId: String? = null,
val key: String
)
data class DeleteGlobalMetaPayload(
val clientMutationId: String?,
val meta: GlobalMetaType?
)
fun deleteGlobalMeta(
input: DeleteGlobalMetaInput
): DeleteGlobalMetaPayload {
val (clientMutationId, key) = input
val meta = transaction {
val meta = GlobalMetaTable.select { GlobalMetaTable.key eq key }
.firstOrNull()
GlobalMetaTable.deleteWhere { GlobalMetaTable.key eq key }
if (meta != null) {
GlobalMetaType(meta)
} else {
null
}
}
return DeleteGlobalMetaPayload(clientMutationId, meta)
}
}

View File

@@ -0,0 +1,112 @@
package suwayomi.tachidesk.graphql.mutations
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.graphql.types.PreferenceObject
import suwayomi.tachidesk.manga.impl.MangaList.insertOrGet
import suwayomi.tachidesk.manga.impl.Search
import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
class SourceMutation {
enum class FetchSourceMangaType {
SEARCH,
POPULAR,
LATEST
}
data class FilterChange(
val position: Int,
val state: String
)
data class FetchSourceMangaInput(
val clientMutationId: String? = null,
val source: Long,
val type: FetchSourceMangaType,
val page: Int,
val query: String? = null,
val filters: List<FilterChange>? = null
)
data class FetchSourceMangaPayload(
val clientMutationId: String?,
val mangas: List<MangaType>,
val hasNextPage: Boolean
)
fun fetchSourceManga(
input: FetchSourceMangaInput
): CompletableFuture<FetchSourceMangaPayload> {
val (clientMutationId, sourceId, type, page, query, filters) = input
return future {
val source = GetCatalogueSource.getCatalogueSourceOrNull(sourceId)!!
val mangasPage = when (type) {
FetchSourceMangaType.SEARCH -> {
source.fetchSearchManga(
page = page,
query = query.orEmpty(),
filters = Search.buildFilterList(
sourceId = sourceId,
changes = filters?.map { Search.FilterChange(it.position, it.state) }
.orEmpty()
)
).awaitSingle()
}
FetchSourceMangaType.POPULAR -> {
source.fetchPopularManga(page).awaitSingle()
}
FetchSourceMangaType.LATEST -> {
if (!source.supportsLatest) throw Exception("Source does not support latest")
source.fetchLatestUpdates(page).awaitSingle()
}
}
val mangaIds = mangasPage.insertOrGet(sourceId)
val mangas = transaction {
MangaTable.select { MangaTable.id inList mangaIds }
.map { MangaType(it) }
}.sortedBy {
mangaIds.indexOf(it.id)
}
FetchSourceMangaPayload(
clientMutationId = clientMutationId,
mangas = mangas,
hasNextPage = mangasPage.hasNextPage
)
}
}
data class SourcePreferenceChange(
val position: Int,
val state: String
)
data class UpdateSourcePreferenceInput(
val clientMutationId: String? = null,
val source: Long,
val change: SourcePreferenceChange
)
data class UpdateSourcePreferencePayload(
val clientMutationId: String?,
val preferences: List<PreferenceObject>
)
fun updateSourcePreference(
input: UpdateSourcePreferenceInput
): UpdateSourcePreferencePayload {
val (clientMutationId, sourceId, change) = input
Source.setSourcePreference(sourceId, Source.SourcePreferenceChange(change.position, change.state))
return UpdateSourcePreferencePayload(
clientMutationId = clientMutationId,
preferences = Source.getSourcePreferences(sourceId).map { PreferenceObject(it.type, it.props) }
)
}
}

View File

@@ -0,0 +1,200 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.CategoryNodeList
import suwayomi.tachidesk.graphql.types.CategoryType
import suwayomi.tachidesk.manga.model.table.CategoryTable
import java.util.concurrent.CompletableFuture
class CategoryQuery {
fun category(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<CategoryType?> {
return dataFetchingEnvironment.getValueFromDataLoader("CategoryDataLoader", id)
}
enum class CategoryOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<CategoryType> {
ID(CategoryTable.id),
NAME(CategoryTable.name),
ORDER(CategoryTable.order);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> CategoryTable.id greater cursor.value.toInt()
NAME -> greaterNotUnique(CategoryTable.name, CategoryTable.id, cursor, String::toString)
ORDER -> greaterNotUnique(CategoryTable.order, CategoryTable.id, cursor, String::toInt)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> CategoryTable.id less cursor.value.toInt()
NAME -> lessNotUnique(CategoryTable.name, CategoryTable.id, cursor, String::toString)
ORDER -> lessNotUnique(CategoryTable.order, CategoryTable.id, cursor, String::toInt)
}
}
override fun asCursor(type: CategoryType): Cursor {
val value = when (this) {
ID -> type.id.toString()
NAME -> type.id.toString() + "-" + type.name
ORDER -> type.id.toString() + "-" + type.order
}
return Cursor(value)
}
}
data class CategoryCondition(
val id: Int? = null,
val order: Int? = null,
val name: String? = null,
val default: Boolean? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, CategoryTable.id)
opAnd.eq(order, CategoryTable.order)
opAnd.eq(name, CategoryTable.name)
opAnd.eq(default, CategoryTable.isDefault)
return opAnd.op
}
}
data class CategoryFilter(
val id: IntFilter? = null,
val order: IntFilter? = null,
val name: StringFilter? = null,
val default: BooleanFilter? = null,
override val and: List<CategoryFilter>? = null,
override val or: List<CategoryFilter>? = null,
override val not: CategoryFilter? = null
) : Filter<CategoryFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(CategoryTable.id, id),
andFilterWithCompare(CategoryTable.order, order),
andFilterWithCompareString(CategoryTable.name, name),
andFilterWithCompare(CategoryTable.isDefault, default)
)
}
}
fun categories(
condition: CategoryCondition? = null,
filter: CategoryFilter? = null,
orderBy: CategoryOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): CategoryNodeList {
val queryResults = transaction {
val res = CategoryTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: CategoryTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == CategoryOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
CategoryTable.id to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(CategoryTable.id)?.value
val lastResult = res.lastOrNull()?.get(CategoryTable.id)?.value
if (after != null) {
res.andWhere {
(orderBy ?: CategoryOrderBy.ID).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: CategoryOrderBy.ID).less(before)
}
}
if (first != null) {
res.limit(first, offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList())
}
val getAsCursor: (CategoryType) -> Cursor = (orderBy ?: CategoryOrderBy.ID)::asCursor
val resultsAsType = queryResults.results.map { CategoryType(it) }
return CategoryNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
CategoryNodeList.CategoryEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
CategoryNodeList.CategoryEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.id,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.id,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,283 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.FloatFilter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
import suwayomi.tachidesk.graphql.queries.filter.LongFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.ChapterNodeList
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.util.concurrent.CompletableFuture
/**
* TODO Queries
* - Filter in library
* - Get page list?
*/
class ChapterQuery {
fun chapter(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<ChapterType?> {
return dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id)
}
enum class ChapterOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<ChapterType> {
ID(ChapterTable.id),
SOURCE_ORDER(ChapterTable.sourceOrder),
NAME(ChapterTable.name),
UPLOAD_DATE(ChapterTable.date_upload),
CHAPTER_NUMBER(ChapterTable.chapter_number),
LAST_READ_AT(ChapterTable.lastReadAt),
FETCHED_AT(ChapterTable.fetchedAt);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> ChapterTable.id greater cursor.value.toInt()
SOURCE_ORDER -> greaterNotUnique(ChapterTable.sourceOrder, ChapterTable.id, cursor, String::toInt)
NAME -> greaterNotUnique(ChapterTable.name, ChapterTable.id, cursor, String::toString)
UPLOAD_DATE -> greaterNotUnique(ChapterTable.date_upload, ChapterTable.id, cursor, String::toLong)
CHAPTER_NUMBER -> greaterNotUnique(ChapterTable.chapter_number, ChapterTable.id, cursor, String::toFloat)
LAST_READ_AT -> greaterNotUnique(ChapterTable.lastReadAt, ChapterTable.id, cursor, String::toLong)
FETCHED_AT -> greaterNotUnique(ChapterTable.fetchedAt, ChapterTable.id, cursor, String::toLong)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> ChapterTable.id less cursor.value.toInt()
SOURCE_ORDER -> lessNotUnique(ChapterTable.sourceOrder, ChapterTable.id, cursor, String::toInt)
NAME -> lessNotUnique(ChapterTable.name, ChapterTable.id, cursor, String::toString)
UPLOAD_DATE -> lessNotUnique(ChapterTable.date_upload, ChapterTable.id, cursor, String::toLong)
CHAPTER_NUMBER -> lessNotUnique(ChapterTable.chapter_number, ChapterTable.id, cursor, String::toFloat)
LAST_READ_AT -> lessNotUnique(ChapterTable.lastReadAt, ChapterTable.id, cursor, String::toLong)
FETCHED_AT -> lessNotUnique(ChapterTable.fetchedAt, ChapterTable.id, cursor, String::toLong)
}
}
override fun asCursor(type: ChapterType): Cursor {
val value = when (this) {
ID -> type.id.toString()
SOURCE_ORDER -> type.id.toString() + "-" + type.sourceOrder
NAME -> type.id.toString() + "-" + type.name
UPLOAD_DATE -> type.id.toString() + "-" + type.uploadDate
CHAPTER_NUMBER -> type.id.toString() + "-" + type.chapterNumber
LAST_READ_AT -> type.id.toString() + "-" + type.lastReadAt
FETCHED_AT -> type.id.toString() + "-" + type.fetchedAt
}
return Cursor(value)
}
}
data class ChapterCondition(
val id: Int? = null,
val url: String? = null,
val name: String? = null,
val uploadDate: Long? = null,
val chapterNumber: Float? = null,
val scanlator: String? = null,
val mangaId: Int? = null,
val isRead: Boolean? = null,
val isBookmarked: Boolean? = null,
val lastPageRead: Int? = null,
val lastReadAt: Long? = null,
val sourceOrder: Int? = null,
val realUrl: String? = null,
val fetchedAt: Long? = null,
val isDownloaded: Boolean? = null,
val pageCount: Int? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, ChapterTable.id)
opAnd.eq(url, ChapterTable.url)
opAnd.eq(name, ChapterTable.name)
opAnd.eq(uploadDate, ChapterTable.date_upload)
opAnd.eq(chapterNumber, ChapterTable.chapter_number)
opAnd.eq(scanlator, ChapterTable.scanlator)
opAnd.eq(mangaId, ChapterTable.manga)
opAnd.eq(isRead, ChapterTable.isRead)
opAnd.eq(isBookmarked, ChapterTable.isBookmarked)
opAnd.eq(lastPageRead, ChapterTable.lastPageRead)
opAnd.eq(lastReadAt, ChapterTable.lastReadAt)
opAnd.eq(sourceOrder, ChapterTable.sourceOrder)
opAnd.eq(realUrl, ChapterTable.realUrl)
opAnd.eq(fetchedAt, ChapterTable.fetchedAt)
opAnd.eq(isDownloaded, ChapterTable.isDownloaded)
opAnd.eq(pageCount, ChapterTable.pageCount)
return opAnd.op
}
}
data class ChapterFilter(
val id: IntFilter? = null,
val url: StringFilter? = null,
val name: StringFilter? = null,
val uploadDate: LongFilter? = null,
val chapterNumber: FloatFilter? = null,
val scanlator: StringFilter? = null,
val mangaId: IntFilter? = null,
val isRead: BooleanFilter? = null,
val isBookmarked: BooleanFilter? = null,
val lastPageRead: IntFilter? = null,
val lastReadAt: LongFilter? = null,
val sourceOrder: IntFilter? = null,
val realUrl: StringFilter? = null,
val fetchedAt: LongFilter? = null,
val isDownloaded: BooleanFilter? = null,
val pageCount: IntFilter? = null,
val inLibrary: BooleanFilter? = null,
override val and: List<ChapterFilter>? = null,
override val or: List<ChapterFilter>? = null,
override val not: ChapterFilter? = null
) : Filter<ChapterFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(ChapterTable.id, id),
andFilterWithCompareString(ChapterTable.url, url),
andFilterWithCompareString(ChapterTable.name, name),
andFilterWithCompare(ChapterTable.date_upload, uploadDate),
andFilterWithCompare(ChapterTable.chapter_number, chapterNumber),
andFilterWithCompareString(ChapterTable.scanlator, scanlator),
andFilterWithCompareEntity(ChapterTable.manga, mangaId),
andFilterWithCompare(ChapterTable.isRead, isRead),
andFilterWithCompare(ChapterTable.isBookmarked, isBookmarked),
andFilterWithCompare(ChapterTable.lastPageRead, lastPageRead),
andFilterWithCompare(ChapterTable.lastReadAt, lastReadAt),
andFilterWithCompare(ChapterTable.sourceOrder, sourceOrder),
andFilterWithCompareString(ChapterTable.realUrl, realUrl),
andFilterWithCompare(ChapterTable.fetchedAt, fetchedAt),
andFilterWithCompare(ChapterTable.isDownloaded, isDownloaded),
andFilterWithCompare(ChapterTable.pageCount, pageCount)
)
}
fun getLibraryOp() = andFilterWithCompare(MangaTable.inLibrary, inLibrary)
}
fun chapters(
condition: ChapterCondition? = null,
filter: ChapterFilter? = null,
orderBy: ChapterOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): ChapterNodeList {
val queryResults = transaction {
val res = ChapterTable.selectAll()
val libraryOp = filter?.getLibraryOp()
if (libraryOp != null) {
res.adjustColumnSet {
innerJoin(MangaTable)
}
res.andWhere { libraryOp }
}
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: ChapterTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == ChapterOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
ChapterTable.id to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(ChapterTable.id)?.value
val lastResult = res.lastOrNull()?.get(ChapterTable.id)?.value
if (after != null) {
res.andWhere {
(orderBy ?: ChapterOrderBy.ID).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: ChapterOrderBy.ID).less(before)
}
}
if (first != null) {
res.limit(first, offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList())
}
val getAsCursor: (ChapterType) -> Cursor = (orderBy ?: ChapterOrderBy.ID)::asCursor
val resultsAsType = queryResults.results.map { ChapterType(it) }
return ChapterNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
ChapterNodeList.ChapterEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
ChapterNodeList.ChapterEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.id,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.id,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,230 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import eu.kanade.tachiyomi.source.local.LocalSource
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.SqlExpressionBuilder.neq
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.ExtensionNodeList
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.CompletableFuture
class ExtensionQuery {
fun extension(dataFetchingEnvironment: DataFetchingEnvironment, pkgName: String): CompletableFuture<ExtensionType?> {
return dataFetchingEnvironment.getValueFromDataLoader("ExtensionDataLoader", pkgName)
}
enum class ExtensionOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<ExtensionType> {
PKG_NAME(ExtensionTable.pkgName),
NAME(ExtensionTable.name),
APK_NAME(ExtensionTable.apkName);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
PKG_NAME -> ExtensionTable.pkgName greater cursor.value
NAME -> greaterNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> greaterNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
PKG_NAME -> ExtensionTable.pkgName less cursor.value
NAME -> lessNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> lessNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
}
}
override fun asCursor(type: ExtensionType): Cursor {
val value = when (this) {
PKG_NAME -> type.pkgName
NAME -> type.pkgName + "\\-" + type.name
APK_NAME -> type.pkgName + "\\-" + type.apkName
}
return Cursor(value)
}
}
data class ExtensionCondition(
val apkName: String? = null,
val iconUrl: String? = null,
val name: String? = null,
val pkgName: String? = null,
val versionName: String? = null,
val versionCode: Int? = null,
val lang: String? = null,
val isNsfw: Boolean? = null,
val isInstalled: Boolean? = null,
val hasUpdate: Boolean? = null,
val isObsolete: Boolean? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(apkName, ExtensionTable.apkName)
opAnd.eq(iconUrl, ExtensionTable.iconUrl)
opAnd.eq(name, ExtensionTable.name)
opAnd.eq(versionName, ExtensionTable.versionName)
opAnd.eq(versionCode, ExtensionTable.versionCode)
opAnd.eq(lang, ExtensionTable.lang)
opAnd.eq(isNsfw, ExtensionTable.isNsfw)
opAnd.eq(isInstalled, ExtensionTable.isInstalled)
opAnd.eq(hasUpdate, ExtensionTable.hasUpdate)
opAnd.eq(isObsolete, ExtensionTable.isObsolete)
return opAnd.op
}
}
data class ExtensionFilter(
val apkName: StringFilter? = null,
val iconUrl: StringFilter? = null,
val name: StringFilter? = null,
val pkgName: StringFilter? = null,
val versionName: StringFilter? = null,
val versionCode: IntFilter? = null,
val lang: StringFilter? = null,
val isNsfw: BooleanFilter? = null,
val isInstalled: BooleanFilter? = null,
val hasUpdate: BooleanFilter? = null,
val isObsolete: BooleanFilter? = null,
override val and: List<ExtensionFilter>? = null,
override val or: List<ExtensionFilter>? = null,
override val not: ExtensionFilter? = null
) : Filter<ExtensionFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareString(ExtensionTable.apkName, apkName),
andFilterWithCompareString(ExtensionTable.iconUrl, iconUrl),
andFilterWithCompareString(ExtensionTable.name, name),
andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCode),
andFilterWithCompareString(ExtensionTable.lang, lang),
andFilterWithCompare(ExtensionTable.isNsfw, isNsfw),
andFilterWithCompare(ExtensionTable.isInstalled, isInstalled),
andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate),
andFilterWithCompare(ExtensionTable.isObsolete, isObsolete)
)
}
}
fun extensions(
condition: ExtensionCondition? = null,
filter: ExtensionFilter? = null,
orderBy: ExtensionOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): ExtensionNodeList {
val queryResults = transaction {
val res = ExtensionTable.selectAll()
res.adjustWhere { ExtensionTable.name neq LocalSource.EXTENSION_NAME }
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: ExtensionTable.pkgName
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == ExtensionOrderBy.PKG_NAME || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
ExtensionTable.pkgName to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(ExtensionTable.pkgName)
val lastResult = res.lastOrNull()?.get(ExtensionTable.pkgName)
if (after != null) {
res.andWhere {
(orderBy ?: ExtensionOrderBy.PKG_NAME).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: ExtensionOrderBy.PKG_NAME).less(before)
}
}
if (first != null) {
res.limit(first, offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList())
}
val getAsCursor: (ExtensionType) -> Cursor = (orderBy ?: ExtensionOrderBy.PKG_NAME)::asCursor
val resultsAsType = queryResults.results.map { ExtensionType(it) }
return ExtensionNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
ExtensionNodeList.ExtensionEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
ExtensionNodeList.ExtensionEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.pkgName,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.pkgName,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,282 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.ComparableScalarFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
import suwayomi.tachidesk.graphql.queries.filter.LongFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.MangaNodeList
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.util.concurrent.CompletableFuture
class MangaQuery {
fun manga(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<MangaType?> {
return dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", id)
}
enum class MangaOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<MangaType> {
ID(MangaTable.id),
TITLE(MangaTable.title),
IN_LIBRARY_AT(MangaTable.inLibraryAt),
LAST_FETCHED_AT(MangaTable.lastFetchedAt);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> MangaTable.id greater cursor.value.toInt()
TITLE -> greaterNotUnique(MangaTable.title, MangaTable.id, cursor, String::toString)
IN_LIBRARY_AT -> greaterNotUnique(MangaTable.inLibraryAt, MangaTable.id, cursor, String::toLong)
LAST_FETCHED_AT -> greaterNotUnique(MangaTable.lastFetchedAt, MangaTable.id, cursor, String::toLong)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> MangaTable.id less cursor.value.toInt()
TITLE -> lessNotUnique(MangaTable.title, MangaTable.id, cursor, String::toString)
IN_LIBRARY_AT -> lessNotUnique(MangaTable.inLibraryAt, MangaTable.id, cursor, String::toLong)
LAST_FETCHED_AT -> lessNotUnique(MangaTable.lastFetchedAt, MangaTable.id, cursor, String::toLong)
}
}
override fun asCursor(type: MangaType): Cursor {
val value = when (this) {
ID -> type.id.toString()
TITLE -> type.id.toString() + "-" + type.title
IN_LIBRARY_AT -> type.id.toString() + "-" + type.inLibraryAt.toString()
LAST_FETCHED_AT -> type.id.toString() + "-" + type.lastFetchedAt.toString()
}
return Cursor(value)
}
}
data class MangaCondition(
val id: Int? = null,
val sourceId: Long? = null,
val url: String? = null,
val title: String? = null,
val thumbnailUrl: String? = null,
val initialized: Boolean? = null,
val artist: String? = null,
val author: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: MangaStatus? = null,
val inLibrary: Boolean? = null,
val inLibraryAt: Long? = null,
val realUrl: String? = null,
val lastFetchedAt: Long? = null,
val chaptersLastFetchedAt: Long? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, MangaTable.id)
opAnd.eq(sourceId, MangaTable.sourceReference)
opAnd.eq(url, MangaTable.url)
opAnd.eq(title, MangaTable.title)
opAnd.eq(thumbnailUrl, MangaTable.thumbnail_url)
opAnd.eq(initialized, MangaTable.initialized)
opAnd.eq(artist, MangaTable.artist)
opAnd.eq(author, MangaTable.author)
opAnd.eq(description, MangaTable.description)
opAnd.eq(genre?.joinToString(), MangaTable.genre)
opAnd.eq(status?.value, MangaTable.status)
opAnd.eq(inLibrary, MangaTable.inLibrary)
opAnd.eq(inLibraryAt, MangaTable.inLibraryAt)
opAnd.eq(realUrl, MangaTable.realUrl)
opAnd.eq(lastFetchedAt, MangaTable.lastFetchedAt)
opAnd.eq(chaptersLastFetchedAt, MangaTable.chaptersLastFetchedAt)
return opAnd.op
}
}
data class MangaStatusFilter(
override val isNull: Boolean? = null,
override val equalTo: MangaStatus? = null,
override val notEqualTo: MangaStatus? = null,
override val distinctFrom: MangaStatus? = null,
override val notDistinctFrom: MangaStatus? = null,
override val `in`: List<MangaStatus>? = null,
override val notIn: List<MangaStatus>? = null,
override val lessThan: MangaStatus? = null,
override val lessThanOrEqualTo: MangaStatus? = null,
override val greaterThan: MangaStatus? = null,
override val greaterThanOrEqualTo: MangaStatus? = null
) : ComparableScalarFilter<MangaStatus> {
fun asIntFilter() = IntFilter(
equalTo = equalTo?.value,
notEqualTo = notEqualTo?.value,
distinctFrom = distinctFrom?.value,
notDistinctFrom = notDistinctFrom?.value,
`in` = `in`?.map { it.value },
notIn = notIn?.map { it.value },
lessThan = lessThan?.value,
lessThanOrEqualTo = lessThanOrEqualTo?.value,
greaterThan = greaterThan?.value,
greaterThanOrEqualTo = greaterThanOrEqualTo?.value
)
}
data class MangaFilter(
val id: IntFilter? = null,
val sourceId: LongFilter? = null,
val url: StringFilter? = null,
val title: StringFilter? = null,
val thumbnailUrl: StringFilter? = null,
val initialized: BooleanFilter? = null,
val artist: StringFilter? = null,
val author: StringFilter? = null,
val description: StringFilter? = null,
// val genre: List<String>? = null, // todo
val status: MangaStatusFilter? = null,
val inLibrary: BooleanFilter? = null,
val inLibraryAt: LongFilter? = null,
val realUrl: StringFilter? = null,
val lastFetchedAt: LongFilter? = null,
val chaptersLastFetchedAt: LongFilter? = null,
override val and: List<MangaFilter>? = null,
override val or: List<MangaFilter>? = null,
override val not: MangaFilter? = null
) : Filter<MangaFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(MangaTable.id, id),
andFilterWithCompare(MangaTable.sourceReference, sourceId),
andFilterWithCompareString(MangaTable.url, url),
andFilterWithCompareString(MangaTable.title, title),
andFilterWithCompareString(MangaTable.thumbnail_url, thumbnailUrl),
andFilterWithCompare(MangaTable.initialized, initialized),
andFilterWithCompareString(MangaTable.artist, artist),
andFilterWithCompareString(MangaTable.author, author),
andFilterWithCompareString(MangaTable.description, description),
andFilterWithCompare(MangaTable.status, status?.asIntFilter()),
andFilterWithCompare(MangaTable.inLibrary, inLibrary),
andFilterWithCompare(MangaTable.inLibraryAt, inLibraryAt),
andFilterWithCompareString(MangaTable.realUrl, realUrl),
andFilterWithCompare(MangaTable.inLibraryAt, lastFetchedAt),
andFilterWithCompare(MangaTable.inLibraryAt, chaptersLastFetchedAt)
)
}
}
fun mangas(
condition: MangaCondition? = null,
filter: MangaFilter? = null,
orderBy: MangaOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): MangaNodeList {
val queryResults = transaction {
val res = MangaTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: MangaTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == MangaOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
MangaTable.id to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(MangaTable.id)?.value
val lastResult = res.lastOrNull()?.get(MangaTable.id)?.value
if (after != null) {
res.andWhere {
(orderBy ?: MangaOrderBy.ID).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: MangaOrderBy.ID).less(before)
}
}
if (first != null) {
res.limit(first, offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList())
}
val getAsCursor: (MangaType) -> Cursor = (orderBy ?: MangaOrderBy.ID)::asCursor
val resultsAsType = queryResults.results.map { MangaType(it) }
return MangaNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
MangaNodeList.MangaEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
MangaNodeList.MangaEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.id,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.id,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,184 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.GlobalMetaNodeList
import suwayomi.tachidesk.graphql.types.GlobalMetaType
import java.util.concurrent.CompletableFuture
class MetaQuery {
fun meta(dataFetchingEnvironment: DataFetchingEnvironment, key: String): CompletableFuture<GlobalMetaType?> {
return dataFetchingEnvironment.getValueFromDataLoader<String, GlobalMetaType?>("GlobalMetaDataLoader", key)
}
enum class MetaOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<GlobalMetaType> {
KEY(GlobalMetaTable.key),
VALUE(GlobalMetaTable.value);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
KEY -> GlobalMetaTable.key greater cursor.value
VALUE -> greaterNotUnique(GlobalMetaTable.value, GlobalMetaTable.key, cursor, String::toString)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
KEY -> GlobalMetaTable.key less cursor.value
VALUE -> lessNotUnique(GlobalMetaTable.value, GlobalMetaTable.key, cursor, String::toString)
}
}
override fun asCursor(type: GlobalMetaType): Cursor {
val value = when (this) {
KEY -> type.key
VALUE -> type.key + "\\-" + type.value
}
return Cursor(value)
}
}
data class MetaCondition(
val key: String? = null,
val value: String? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(key, GlobalMetaTable.key)
opAnd.eq(value, GlobalMetaTable.value)
return opAnd.op
}
}
data class MetaFilter(
val key: StringFilter? = null,
val value: StringFilter? = null,
override val and: List<MetaFilter>? = null,
override val or: List<MetaFilter>? = null,
override val not: MetaFilter? = null
) : Filter<MetaFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareString(GlobalMetaTable.key, key),
andFilterWithCompareString(GlobalMetaTable.value, value)
)
}
}
fun metas(
condition: MetaCondition? = null,
filter: MetaFilter? = null,
orderBy: MetaOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): GlobalMetaNodeList {
val queryResults = transaction {
val res = GlobalMetaTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: GlobalMetaTable.key
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == MetaOrderBy.KEY || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
GlobalMetaTable.key to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(GlobalMetaTable.key)
val lastResult = res.lastOrNull()?.get(GlobalMetaTable.key)
if (after != null) {
res.andWhere {
(orderBy ?: MetaOrderBy.KEY).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: MetaOrderBy.KEY).less(before)
}
}
if (first != null) {
res.limit(first, offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList())
}
val getAsCursor: (GlobalMetaType) -> Cursor = (orderBy ?: MetaOrderBy.KEY)::asCursor
val resultsAsType = queryResults.results.map { GlobalMetaType(it) }
return GlobalMetaNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
GlobalMetaNodeList.MetaEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
GlobalMetaNodeList.MetaEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.key,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.key,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,200 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.queries
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.LongFilter
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.SourceNodeList
import suwayomi.tachidesk.graphql.types.SourceType
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.util.concurrent.CompletableFuture
class SourceQuery {
fun source(dataFetchingEnvironment: DataFetchingEnvironment, id: Long): CompletableFuture<SourceType?> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, SourceType?>("SourceDataLoader", id)
}
enum class SourceOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<SourceType> {
ID(SourceTable.id),
NAME(SourceTable.name),
LANG(SourceTable.lang);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> SourceTable.id greater cursor.value.toLong()
NAME -> greaterNotUnique(SourceTable.name, SourceTable.id, cursor, String::toString)
LANG -> greaterNotUnique(SourceTable.lang, SourceTable.id, cursor, String::toString)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> SourceTable.id less cursor.value.toLong()
NAME -> lessNotUnique(SourceTable.name, SourceTable.id, cursor, String::toString)
LANG -> lessNotUnique(SourceTable.lang, SourceTable.id, cursor, String::toString)
}
}
override fun asCursor(type: SourceType): Cursor {
val value = when (this) {
ID -> type.id.toString()
NAME -> type.id.toString() + "-" + type.name
LANG -> type.id.toString() + "-" + type.lang
}
return Cursor(value)
}
}
data class SourceCondition(
val id: Long? = null,
val name: String? = null,
val lang: String? = null,
val isNsfw: Boolean? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, SourceTable.id)
opAnd.eq(name, SourceTable.name)
opAnd.eq(lang, SourceTable.lang)
opAnd.eq(isNsfw, SourceTable.isNsfw)
return opAnd.op
}
}
data class SourceFilter(
val id: LongFilter? = null,
val name: StringFilter? = null,
val lang: StringFilter? = null,
val isNsfw: BooleanFilter? = null,
override val and: List<SourceFilter>? = null,
override val or: List<SourceFilter>? = null,
override val not: SourceFilter? = null
) : Filter<SourceFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(SourceTable.id, id),
andFilterWithCompareString(SourceTable.name, name),
andFilterWithCompareString(SourceTable.lang, lang),
andFilterWithCompare(SourceTable.isNsfw, isNsfw)
)
}
}
fun sources(
condition: SourceCondition? = null,
filter: SourceFilter? = null,
orderBy: SourceOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): SourceNodeList {
val (queryResults, resultsAsType) = transaction {
val res = SourceTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: SourceTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == SourceOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
SourceTable.id to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(SourceTable.id)?.value
val lastResult = res.lastOrNull()?.get(SourceTable.id)?.value
if (after != null) {
res.andWhere {
(orderBy ?: SourceOrderBy.ID).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: SourceOrderBy.ID).less(before)
}
}
if (first != null) {
res.limit(first, offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList()).let {
it to it.results.mapNotNull { SourceType(it) }
}
}
val getAsCursor: (SourceType) -> Cursor = (orderBy ?: SourceOrderBy.ID)::asCursor
return SourceNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
SourceNodeList.SourceEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
SourceNodeList.SourceEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.id,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.id,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,22 @@
package suwayomi.tachidesk.graphql.queries
import suwayomi.tachidesk.graphql.types.TrackServiceType
import suwayomi.tachidesk.server.trackManager
/*
* 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/. */
class TrackQuery {
fun trackService(id: Long): TrackServiceType? =
trackManager.services.find { it.id == id }?.let {
TrackServiceType(it)
}
fun trackServices(): List<TrackServiceType> = trackManager.services.map {
TrackServiceType(it)
}
}

View File

@@ -0,0 +1,397 @@
package suwayomi.tachidesk.graphql.queries.filter
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.ComparisonOp
import org.jetbrains.exposed.sql.Expression
import org.jetbrains.exposed.sql.ExpressionWithColumnType
import org.jetbrains.exposed.sql.LikePattern
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.Query
import org.jetbrains.exposed.sql.QueryBuilder
import org.jetbrains.exposed.sql.SqlExpressionBuilder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.not
import org.jetbrains.exposed.sql.or
import org.jetbrains.exposed.sql.stringParam
import org.jetbrains.exposed.sql.upperCase
class ILikeEscapeOp(expr1: Expression<*>, expr2: Expression<*>, like: Boolean, val escapeChar: Char?) : ComparisonOp(expr1, expr2, if (like) "ILIKE" else "NOT ILIKE") {
override fun toQueryBuilder(queryBuilder: QueryBuilder) {
super.toQueryBuilder(queryBuilder)
if (escapeChar != null) {
with(queryBuilder) {
+" ESCAPE "
+stringParam(escapeChar.toString())
}
}
}
companion object {
fun <T : String?> iLike(expression: Expression<T>, pattern: String): ILikeEscapeOp = iLike(expression, LikePattern(pattern))
fun <T : String?> iNotLike(expression: Expression<T>, pattern: String): ILikeEscapeOp = iNotLike(expression, LikePattern(pattern))
fun <T : String?> iLike(expression: Expression<T>, pattern: LikePattern): ILikeEscapeOp = ILikeEscapeOp(expression, stringParam(pattern.pattern), true, pattern.escapeChar)
fun <T : String?> iNotLike(expression: Expression<T>, pattern: LikePattern): ILikeEscapeOp = ILikeEscapeOp(expression, stringParam(pattern.pattern), false, pattern.escapeChar)
}
}
class DistinctFromOp(expr1: Expression<*>, expr2: Expression<*>, not: Boolean) : ComparisonOp(expr1, expr2, if (not) "IS NOT DISTINCT FROM" else "IS DISTINCT FROM") {
companion object {
fun <T> distinctFrom(expression: ExpressionWithColumnType<T>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
false
)
fun <T> notDistinctFrom(expression: ExpressionWithColumnType<T>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
true
)
fun <T : Comparable<T>> distinctFrom(expression: ExpressionWithColumnType<EntityID<T>>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
false
)
fun <T : Comparable<T>> notDistinctFrom(expression: ExpressionWithColumnType<EntityID<T>>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
true
)
}
}
interface HasGetOp {
fun getOp(): Op<Boolean>?
}
fun Query.applyOps(vararg ops: HasGetOp?) {
ops.mapNotNull { it?.getOp() }.forEach {
andWhere { it }
}
}
interface Filter<T : Filter<T>> : HasGetOp {
val and: List<T>?
val or: List<T>?
val not: T?
fun getOpList(): List<Op<Boolean>>
override fun getOp(): Op<Boolean>? {
var op: Op<Boolean>? = null
fun newOp(
otherOp: Op<Boolean>?,
operator: (Op<Boolean>, Op<Boolean>) -> Op<Boolean>
) {
when {
op == null && otherOp == null -> Unit
op == null && otherOp != null -> op = otherOp
op != null && otherOp == null -> Unit
op != null && otherOp != null -> op = operator(op!!, otherOp)
}
}
fun andOp(andOp: Op<Boolean>?) {
newOp(andOp, Op<Boolean>::and)
}
fun orOp(orOp: Op<Boolean>?) {
newOp(orOp, Op<Boolean>::or)
}
getOpList().forEach {
andOp(it)
}
and?.forEach {
andOp(it.getOp())
}
or?.forEach {
orOp(it.getOp())
}
if (not != null) {
andOp(not!!.getOp()?.let(::not))
}
return op
}
}
interface ScalarFilter<T> {
val isNull: Boolean?
val equalTo: T?
val notEqualTo: T?
val distinctFrom: T?
val notDistinctFrom: T?
val `in`: List<T>?
val notIn: List<T>?
}
interface ComparableScalarFilter<T : Comparable<T>?> : ScalarFilter<T> {
val lessThan: T?
val lessThanOrEqualTo: T?
val greaterThan: T?
val greaterThanOrEqualTo: T?
}
interface ListScalarFilter<T, R : List<T>> : ScalarFilter<T> {
val hasAny: List<T>?
val hasAll: List<T>?
val hasNone: List<T>?
}
data class LongFilter(
override val isNull: Boolean? = null,
override val equalTo: Long? = null,
override val notEqualTo: Long? = null,
override val distinctFrom: Long? = null,
override val notDistinctFrom: Long? = null,
override val `in`: List<Long>? = null,
override val notIn: List<Long>? = null,
override val lessThan: Long? = null,
override val lessThanOrEqualTo: Long? = null,
override val greaterThan: Long? = null,
override val greaterThanOrEqualTo: Long? = null
) : ComparableScalarFilter<Long>
data class BooleanFilter(
override val isNull: Boolean? = null,
override val equalTo: Boolean? = null,
override val notEqualTo: Boolean? = null,
override val distinctFrom: Boolean? = null,
override val notDistinctFrom: Boolean? = null,
override val `in`: List<Boolean>? = null,
override val notIn: List<Boolean>? = null,
override val lessThan: Boolean? = null,
override val lessThanOrEqualTo: Boolean? = null,
override val greaterThan: Boolean? = null,
override val greaterThanOrEqualTo: Boolean? = null
) : ComparableScalarFilter<Boolean>
data class IntFilter(
override val isNull: Boolean? = null,
override val equalTo: Int? = null,
override val notEqualTo: Int? = null,
override val distinctFrom: Int? = null,
override val notDistinctFrom: Int? = null,
override val `in`: List<Int>? = null,
override val notIn: List<Int>? = null,
override val lessThan: Int? = null,
override val lessThanOrEqualTo: Int? = null,
override val greaterThan: Int? = null,
override val greaterThanOrEqualTo: Int? = null
) : ComparableScalarFilter<Int>
data class FloatFilter(
override val isNull: Boolean? = null,
override val equalTo: Float? = null,
override val notEqualTo: Float? = null,
override val distinctFrom: Float? = null,
override val notDistinctFrom: Float? = null,
override val `in`: List<Float>? = null,
override val notIn: List<Float>? = null,
override val lessThan: Float? = null,
override val lessThanOrEqualTo: Float? = null,
override val greaterThan: Float? = null,
override val greaterThanOrEqualTo: Float? = null
) : ComparableScalarFilter<Float>
data class StringFilter(
override val isNull: Boolean? = null,
override val equalTo: String? = null,
override val notEqualTo: String? = null,
override val distinctFrom: String? = null,
override val notDistinctFrom: String? = null,
override val `in`: List<String>? = null,
override val notIn: List<String>? = null,
override val lessThan: String? = null,
override val lessThanOrEqualTo: String? = null,
override val greaterThan: String? = null,
override val greaterThanOrEqualTo: String? = null,
val includes: String? = null,
val notIncludes: String? = null,
val includesInsensitive: String? = null,
val notIncludesInsensitive: String? = null,
val startsWith: String? = null,
val notStartsWith: String? = null,
val startsWithInsensitive: String? = null,
val notStartsWithInsensitive: String? = null,
val endsWith: String? = null,
val notEndsWith: String? = null,
val endsWithInsensitive: String? = null,
val notEndsWithInsensitive: String? = null,
val like: String? = null,
val notLike: String? = null,
val likeInsensitive: String? = null,
val notLikeInsensitive: String? = null,
val distinctFromInsensitive: String? = null,
val notDistinctFromInsensitive: String? = null,
val inInsensitive: List<String>? = null,
val notInInsensitive: List<String>? = null,
val lessThanInsensitive: String? = null,
val lessThanOrEqualToInsensitive: String? = null,
val greaterThanInsensitive: String? = null,
val greaterThanOrEqualToInsensitive: String? = null
) : ComparableScalarFilter<String>
data class StringListFilter(
override val isNull: Boolean? = null,
override val equalTo: String? = null,
override val notEqualTo: String? = null,
override val distinctFrom: String? = null,
override val notDistinctFrom: String? = null,
override val `in`: List<String>? = null,
override val notIn: List<String>? = null,
override val hasAny: List<String>? = null,
override val hasAll: List<String>? = null,
override val hasNone: List<String>? = null,
val hasAnyInsensitive: List<String>? = null,
val hasAllInsensitive: List<String>? = null,
val hasNoneInsensitive: List<String>? = null
) : ListScalarFilter<String, List<String>>
@Suppress("UNCHECKED_CAST")
fun <T : String, S : T?> andFilterWithCompareString(
column: Column<S>,
filter: StringFilter?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it as S }
opAnd.andWhere(filter.notEqualTo) { column neq it as S }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it as S) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList it as List<S> }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList it as List<S> }
}
opAnd.andWhere(filter.lessThan) { column less it }
opAnd.andWhere(filter.lessThanOrEqualTo) { column lessEq it }
opAnd.andWhere(filter.greaterThan) { column greater it }
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
opAnd.andWhere(filter.includes) { column like "%$it%" }
opAnd.andWhere(filter.notIncludes) { column notLike "%$it%" }
opAnd.andWhere(filter.includesInsensitive) { ILikeEscapeOp.iLike(column, "%$it%") }
opAnd.andWhere(filter.notIncludesInsensitive) { ILikeEscapeOp.iNotLike(column, "%$it%") }
opAnd.andWhere(filter.startsWith) { column like "$it%" }
opAnd.andWhere(filter.notStartsWith) { column notLike "$it%" }
opAnd.andWhere(filter.startsWithInsensitive) { ILikeEscapeOp.iLike(column, "$it%") }
opAnd.andWhere(filter.notStartsWithInsensitive) { ILikeEscapeOp.iNotLike(column, "$it%") }
opAnd.andWhere(filter.endsWith) { column like "%$it" }
opAnd.andWhere(filter.notEndsWith) { column notLike "%$it" }
opAnd.andWhere(filter.endsWithInsensitive) { ILikeEscapeOp.iLike(column, "%$it") }
opAnd.andWhere(filter.notEndsWithInsensitive) { ILikeEscapeOp.iNotLike(column, "%$it") }
opAnd.andWhere(filter.like) { column like it }
opAnd.andWhere(filter.notLike) { column notLike it }
opAnd.andWhere(filter.likeInsensitive) { ILikeEscapeOp.iLike(column, it) }
opAnd.andWhere(filter.notLikeInsensitive) { ILikeEscapeOp.iNotLike(column, it) }
opAnd.andWhere(filter.distinctFromInsensitive) { DistinctFromOp.distinctFrom(column.upperCase(), it.uppercase() as S) }
opAnd.andWhere(filter.notDistinctFromInsensitive) { DistinctFromOp.notDistinctFrom(column.upperCase(), it.uppercase() as S) }
opAnd.andWhere(filter.inInsensitive) { column.upperCase() inList (it.map { it.uppercase() } as List<S>) }
opAnd.andWhere(filter.notInInsensitive) { column.upperCase() notInList (it.map { it.uppercase() } as List<S>) }
opAnd.andWhere(filter.lessThanInsensitive) { column.upperCase() less it.uppercase() }
opAnd.andWhere(filter.lessThanOrEqualToInsensitive) { column.upperCase() lessEq it.uppercase() }
opAnd.andWhere(filter.greaterThanInsensitive) { column.upperCase() greater it.uppercase() }
opAnd.andWhere(filter.greaterThanOrEqualToInsensitive) { column.upperCase() greaterEq it.uppercase() }
return opAnd.op
}
class OpAnd(var op: Op<Boolean>? = null) {
fun <T> andWhere(value: T?, andPart: SqlExpressionBuilder.(T & Any) -> Op<Boolean>) {
value ?: return
val expr = Op.build { andPart(value) }
op = if (op == null) expr else (op!! and expr)
}
fun <T> eq(value: T?, column: Column<T>) = andWhere(value) { column eq it }
fun <T : Comparable<T>> eq(value: T?, column: Column<EntityID<T>>) = andWhere(value) { column eq it }
}
fun <T : Comparable<T>> andFilterWithCompare(
column: Column<T>,
filter: ComparableScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd(andFilter(column, filter))
opAnd.andWhere(filter.lessThan) { column less it }
opAnd.andWhere(filter.lessThanOrEqualTo) { column lessEq it }
opAnd.andWhere(filter.greaterThan) { column greater it }
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
return opAnd.op
}
fun <T : Comparable<T>> andFilterWithCompareEntity(
column: Column<EntityID<T>>,
filter: ComparableScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd(andFilterEntity(column, filter))
opAnd.andWhere(filter.lessThan) { column less it }
opAnd.andWhere(filter.lessThanOrEqualTo) { column lessEq it }
opAnd.andWhere(filter.greaterThan) { column greater it }
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
return opAnd.op
}
fun <T : Comparable<T>> andFilter(
column: Column<T>,
filter: ScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it }
opAnd.andWhere(filter.notEqualTo) { column neq it }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList it }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList it }
}
return opAnd.op
}
fun <T : Comparable<T>> andFilterEntity(
column: Column<EntityID<T>>,
filter: ScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.isNull) { if (filter.isNull!!) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq filter.equalTo!! }
opAnd.andWhere(filter.notEqualTo) { column neq filter.notEqualTo!! }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList filter.`in`!! }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList filter.notIn!! }
}
return opAnd.op
}

View File

@@ -0,0 +1,23 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.server.execution.GraphQLRequestParser
import com.expediagroup.graphql.server.types.GraphQLServerRequest
import io.javalin.http.Context
import java.io.IOException
class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
override suspend fun parseRequest(context: Context): GraphQLServerRequest? = try {
context.bodyAsClass(GraphQLServerRequest::class.java)
} catch (e: IOException) {
null
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
import suwayomi.tachidesk.graphql.dataLoaders.CategoriesForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.CategoryDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.CategoryMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaForCategoryDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourcesForExtensionDataLoader
class TachideskDataLoaderRegistryFactory {
companion object {
fun create(): KotlinDataLoaderRegistryFactory {
return KotlinDataLoaderRegistryFactory(
MangaDataLoader(),
ChapterDataLoader(),
ChaptersForMangaDataLoader(),
GlobalMetaDataLoader(),
ChapterMetaDataLoader(),
MangaMetaDataLoader(),
MangaForCategoryDataLoader(),
CategoryDataLoader(),
CategoryMetaDataLoader(),
CategoriesForMangaDataLoader(),
SourceDataLoader(),
SourcesForExtensionDataLoader(),
ExtensionDataLoader(),
ExtensionForSourceDataLoader()
)
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.generator.execution.GraphQLContext
import com.expediagroup.graphql.server.execution.GraphQLContextFactory
import io.javalin.http.Context
import io.javalin.websocket.WsContext
/**
* Custom logic for how Tachidesk should create its context given the [Context]
*/
class TachideskGraphQLContextFactory : GraphQLContextFactory<GraphQLContext, Context> {
override suspend fun generateContextMap(request: Context): Map<*, Any> = emptyMap<Any, Any>()
// mutableMapOf<Any, Any>(
// "user" to User(
// email = "fake@site.com",
// firstName = "Someone",
// lastName = "You Don't know",
// universityId = 4
// )
// ).also { map ->
// request.headers["my-custom-header"]?.let { customHeader ->
// map["customHeader"] = customHeader
// }
// }
fun generateContextMap(request: WsContext): Map<*, Any> = emptyMap<Any, Any>()
}
/**
* Create a [GraphQLContext] from [this] map
* @return a new [GraphQLContext]
*/
fun Map<*, Any?>.toGraphQLContext(): graphql.GraphQLContext =
graphql.GraphQLContext.of(this)

View File

@@ -0,0 +1,71 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.generator.SchemaGeneratorConfig
import com.expediagroup.graphql.generator.TopLevelObject
import com.expediagroup.graphql.generator.hooks.FlowSubscriptionSchemaGeneratorHooks
import com.expediagroup.graphql.generator.toSchema
import graphql.scalars.ExtendedScalars
import graphql.schema.GraphQLType
import suwayomi.tachidesk.graphql.mutations.CategoryMutation
import suwayomi.tachidesk.graphql.mutations.ChapterMutation
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
import suwayomi.tachidesk.graphql.mutations.MangaMutation
import suwayomi.tachidesk.graphql.mutations.MetaMutation
import suwayomi.tachidesk.graphql.mutations.SourceMutation
import suwayomi.tachidesk.graphql.queries.CategoryQuery
import suwayomi.tachidesk.graphql.queries.ChapterQuery
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
import suwayomi.tachidesk.graphql.queries.MangaQuery
import suwayomi.tachidesk.graphql.queries.MetaQuery
import suwayomi.tachidesk.graphql.queries.SourceQuery
import suwayomi.tachidesk.graphql.queries.TrackQuery
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
import kotlin.reflect.KClass
import kotlin.reflect.KType
class CustomSchemaGeneratorHooks : FlowSubscriptionSchemaGeneratorHooks() {
override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) {
Long::class -> GraphQLLongAsString // encode to string for JS
Cursor::class -> GraphQLCursor
Any::class -> ExtendedScalars.Json
else -> super.willGenerateGraphQLType(type)
}
}
val schema = toSchema(
config = SchemaGeneratorConfig(
supportedPackages = listOf("suwayomi.tachidesk.graphql"),
introspectionEnabled = true,
hooks = CustomSchemaGeneratorHooks()
),
queries = listOf(
TopLevelObject(CategoryQuery()),
TopLevelObject(ChapterQuery()),
TopLevelObject(ExtensionQuery()),
TopLevelObject(MangaQuery()),
TopLevelObject(MetaQuery()),
TopLevelObject(SourceQuery()),
TopLevelObject(TrackQuery())
),
mutations = listOf(
TopLevelObject(CategoryMutation()),
TopLevelObject(ChapterMutation()),
TopLevelObject(ExtensionMutation()),
TopLevelObject(MangaMutation()),
TopLevelObject(MetaMutation()),
TopLevelObject(SourceMutation())
),
subscriptions = listOf(
TopLevelObject(DownloadSubscription())
)
)

View File

@@ -0,0 +1,60 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.generator.execution.FlowSubscriptionExecutionStrategy
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
import com.expediagroup.graphql.server.execution.GraphQLServer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import graphql.GraphQL
import io.javalin.http.Context
import io.javalin.websocket.WsCloseContext
import io.javalin.websocket.WsMessageContext
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import suwayomi.tachidesk.graphql.server.subscriptions.ApolloSubscriptionProtocolHandler
import suwayomi.tachidesk.graphql.server.subscriptions.GraphQLSubscriptionHandler
class TachideskGraphQLServer(
requestParser: JavalinGraphQLRequestParser,
contextFactory: TachideskGraphQLContextFactory,
requestHandler: GraphQLRequestHandler,
subscriptionHandler: GraphQLSubscriptionHandler
) : GraphQLServer<Context>(requestParser, contextFactory, requestHandler) {
private val objectMapper = jacksonObjectMapper()
private val subscriptionProtocolHandler = ApolloSubscriptionProtocolHandler(contextFactory, subscriptionHandler, objectMapper)
fun handleSubscriptionMessage(context: WsMessageContext) {
subscriptionProtocolHandler.handleMessage(context)
.map { objectMapper.writeValueAsString(it) }
.map { context.send(it) }
.launchIn(GlobalScope)
}
fun handleSubscriptionDisconnect(context: WsCloseContext) {
subscriptionProtocolHandler.handleDisconnect(context)
}
companion object {
private fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(schema)
.subscriptionExecutionStrategy(FlowSubscriptionExecutionStrategy())
.build()
fun create(): TachideskGraphQLServer {
val graphQL = getGraphQLObject()
val requestParser = JavalinGraphQLRequestParser()
val contextFactory = TachideskGraphQLContextFactory()
val requestHandler = GraphQLRequestHandler(graphQL, TachideskDataLoaderRegistryFactory.create())
val subscriptionHandler = GraphQLSubscriptionHandler(graphQL, TachideskDataLoaderRegistryFactory.create())
return TachideskGraphQLServer(requestParser, contextFactory, requestHandler, subscriptionHandler)
}
}
}

View File

@@ -0,0 +1,119 @@
package suwayomi.tachidesk.graphql.server.primitives
import graphql.GraphQLContext
import graphql.execution.CoercedVariables
import graphql.language.StringValue
import graphql.language.Value
import graphql.scalar.CoercingUtil
import graphql.schema.Coercing
import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.CoercingSerializeException
import graphql.schema.GraphQLScalarType
import java.util.Locale
data class Cursor(val value: String)
val GraphQLCursor: GraphQLScalarType = GraphQLScalarType.newScalar()
.name("Cursor").description("A location in a connection that can be used for resuming pagination.").coercing(GraphqlCursorCoercing()).build()
private class GraphqlCursorCoercing : Coercing<Cursor, String> {
private fun toStringImpl(input: Any): String? {
return (input as? Cursor)?.value
}
private fun parseValueImpl(input: Any, locale: Locale): Cursor {
if (input !is String) {
throw CoercingParseValueException(
CoercingUtil.i18nMsg(
locale,
"String.unexpectedRawValueType",
CoercingUtil.typeName(input)
)
)
}
return Cursor(input)
}
private fun parseLiteralImpl(input: Any, locale: Locale): Cursor {
if (input !is StringValue) {
throw CoercingParseLiteralException(
CoercingUtil.i18nMsg(
locale,
"Scalar.unexpectedAstType",
"StringValue",
CoercingUtil.typeName(input)
)
)
}
return Cursor(input.value)
}
private fun valueToLiteralImpl(input: Any): StringValue {
return StringValue.newStringValue(input.toString()).build()
}
@Deprecated("")
override fun serialize(dataFetcherResult: Any): String {
return toStringImpl(dataFetcherResult) ?: throw CoercingSerializeException(
CoercingUtil.i18nMsg(
Locale.getDefault(),
"String.unexpectedRawValueType",
CoercingUtil.typeName(dataFetcherResult)
)
)
}
@Throws(CoercingSerializeException::class)
override fun serialize(
dataFetcherResult: Any,
graphQLContext: GraphQLContext,
locale: Locale
): String {
return toStringImpl(dataFetcherResult) ?: throw CoercingSerializeException(
CoercingUtil.i18nMsg(
locale,
"String.unexpectedRawValueType",
CoercingUtil.typeName(dataFetcherResult)
)
)
}
@Deprecated("")
override fun parseValue(input: Any): Cursor {
return parseValueImpl(input, Locale.getDefault())
}
@Throws(CoercingParseValueException::class)
override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): Cursor {
return parseValueImpl(input, locale)
}
@Deprecated("")
override fun parseLiteral(input: Any): Cursor {
return parseLiteralImpl(input, Locale.getDefault())
}
@Throws(CoercingParseLiteralException::class)
override fun parseLiteral(
input: Value<*>,
variables: CoercedVariables,
graphQLContext: GraphQLContext,
locale: Locale
): Cursor {
return parseLiteralImpl(input, locale)
}
@Deprecated("")
override fun valueToLiteral(input: Any): Value<*> {
return valueToLiteralImpl(input)
}
override fun valueToLiteral(
input: Any,
graphQLContext: GraphQLContext,
locale: Locale
): Value<*> {
return valueToLiteralImpl(input)
}
}

View File

@@ -0,0 +1,105 @@
package suwayomi.tachidesk.graphql.server.primitives
import graphql.GraphQLContext
import graphql.execution.CoercedVariables
import graphql.language.StringValue
import graphql.language.Value
import graphql.scalar.CoercingUtil
import graphql.schema.Coercing
import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.CoercingSerializeException
import graphql.schema.GraphQLScalarType
import java.util.Locale
val GraphQLLongAsString: GraphQLScalarType = GraphQLScalarType.newScalar()
.name("LongString").description("A 64-bit signed integer as a String").coercing(GraphqlLongAsStringCoercing()).build()
private class GraphqlLongAsStringCoercing : Coercing<Long, String> {
private fun toStringImpl(input: Any): String {
return input.toString()
}
private fun parseValueImpl(input: Any, locale: Locale): Long {
if (input !is String) {
throw CoercingParseValueException(
CoercingUtil.i18nMsg(
locale,
"String.unexpectedRawValueType",
CoercingUtil.typeName(input)
)
)
}
return input.toLong()
}
private fun parseLiteralImpl(input: Any, locale: Locale): Long {
if (input !is StringValue) {
throw CoercingParseLiteralException(
CoercingUtil.i18nMsg(
locale,
"Scalar.unexpectedAstType",
"StringValue",
CoercingUtil.typeName(input)
)
)
}
return input.value.toLong()
}
private fun valueToLiteralImpl(input: Any): StringValue {
return StringValue.newStringValue(input.toString()).build()
}
@Deprecated("")
override fun serialize(dataFetcherResult: Any): String {
return toStringImpl(dataFetcherResult)
}
@Throws(CoercingSerializeException::class)
override fun serialize(
dataFetcherResult: Any,
graphQLContext: GraphQLContext,
locale: Locale
): String {
return toStringImpl(dataFetcherResult)
}
@Deprecated("")
override fun parseValue(input: Any): Long {
return parseValueImpl(input, Locale.getDefault())
}
@Throws(CoercingParseValueException::class)
override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): Long {
return parseValueImpl(input, locale)
}
@Deprecated("")
override fun parseLiteral(input: Any): Long {
return parseLiteralImpl(input, Locale.getDefault())
}
@Throws(CoercingParseLiteralException::class)
override fun parseLiteral(
input: Value<*>,
variables: CoercedVariables,
graphQLContext: GraphQLContext,
locale: Locale
): Long {
return parseLiteralImpl(input, locale)
}
@Deprecated("")
override fun valueToLiteral(input: Any): Value<*> {
return valueToLiteralImpl(input)
}
override fun valueToLiteral(
input: Any,
graphQLContext: GraphQLContext,
locale: Locale
): Value<*> {
return valueToLiteralImpl(input)
}
}

View File

@@ -0,0 +1,38 @@
package suwayomi.tachidesk.graphql.server.primitives
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
interface Node
abstract class NodeList {
@GraphQLDescription("A list of [T] objects.")
abstract val nodes: List<Node>
@GraphQLDescription("A list of edges which contains the [T] and cursor to aid in pagination.")
abstract val edges: List<Edge>
@GraphQLDescription("Information to aid in pagination.")
abstract val pageInfo: PageInfo
@GraphQLDescription("The count of all nodes you could get from the connection.")
abstract val totalCount: Int
}
data class PageInfo(
@GraphQLDescription("When paginating forwards, are there more items?")
val hasNextPage: Boolean,
@GraphQLDescription("When paginating backwards, are there more items?")
val hasPreviousPage: Boolean,
@GraphQLDescription("When paginating backwards, the cursor to continue.")
val startCursor: Cursor?,
@GraphQLDescription("When paginating forwards, the cursor to continue.")
val endCursor: Cursor?
)
abstract class Edge {
@GraphQLDescription("A cursor for use in pagination.")
abstract val cursor: Cursor
@GraphQLDescription("The [T] at the end of the edge.")
abstract val node: Node
}

View File

@@ -0,0 +1,125 @@
package suwayomi.tachidesk.graphql.server.primitives
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.or
interface OrderBy<T> {
val column: Column<out Comparable<*>>
fun asCursor(type: T): Cursor
fun greater(cursor: Cursor): Op<Boolean>
fun less(cursor: Cursor): Op<Boolean>
}
fun SortOrder?.maybeSwap(value: Any?): SortOrder {
return if (value != null) {
when (this) {
SortOrder.ASC -> SortOrder.DESC
SortOrder.DESC -> SortOrder.ASC
SortOrder.ASC_NULLS_FIRST -> SortOrder.DESC_NULLS_LAST
SortOrder.DESC_NULLS_FIRST -> SortOrder.ASC_NULLS_LAST
SortOrder.ASC_NULLS_LAST -> SortOrder.DESC_NULLS_FIRST
SortOrder.DESC_NULLS_LAST -> SortOrder.ASC_NULLS_FIRST
null -> SortOrder.DESC
}
} else {
this ?: SortOrder.ASC
}
}
@JvmName("greaterNotUniqueIntKey")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Int>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return greaterNotUniqueImpl(column, idColumn, cursor, String::toInt, toValue)
}
@JvmName("greaterNotUniqueLongKey")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Long>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return greaterNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
}
private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
column: Column<V>,
idColumn: Column<EntityID<K>>,
cursor: Cursor,
toKey: (String) -> K,
toValue: (String) -> V
): Op<Boolean> {
val id = toKey(cursor.value.substringBefore('-'))
val value = toValue(cursor.value.substringAfter('-'))
return (column greater value) or ((column eq value) and (idColumn greater id))
}
@JvmName("greaterNotUniqueStringKey")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
idColumn: Column<String>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
val id = cursor.value.substringBefore("\\-")
val value = toValue(cursor.value.substringAfter("\\-"))
return (column greater value) or ((column eq value) and (idColumn greater id))
}
@JvmName("lessNotUniqueIntKey")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Int>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return lessNotUniqueImpl(column, idColumn, cursor, String::toInt, toValue)
}
@JvmName("lessNotUniqueLongKey")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Long>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return lessNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
}
private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
column: Column<V>,
idColumn: Column<EntityID<K>>,
cursor: Cursor,
toKey: (String) -> K,
toValue: (String) -> V
): Op<Boolean> {
val id = toKey(cursor.value.substringBefore('-'))
val value = toValue(cursor.value.substringAfter('-'))
return (column less value) or ((column eq value) and (idColumn less id))
}
@JvmName("lessNotUniqueStringKey")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,
idColumn: Column<String>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
val id = cursor.value.substringBefore("\\-")
val value = toValue(cursor.value.substringAfter("\\-"))
return (column less value) or ((column eq value) and (idColumn less id))
}

View File

@@ -0,0 +1,5 @@
package suwayomi.tachidesk.graphql.server.primitives
import org.jetbrains.exposed.sql.ResultRow
data class QueryResults<T>(val total: Long, val firstKey: T, val lastKey: T, val results: List<ResultRow>)

View File

@@ -0,0 +1,207 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import com.expediagroup.graphql.server.types.GraphQLRequest
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.convertValue
import com.fasterxml.jackson.module.kotlin.readValue
import io.javalin.websocket.WsContext
import io.javalin.websocket.WsMessageContext
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import suwayomi.tachidesk.graphql.server.TachideskGraphQLContextFactory
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_CONNECTION_INIT
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_CONNECTION_TERMINATE
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_START
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_STOP
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_CONNECTION_ACK
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_CONNECTION_ERROR
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_CONNECTION_KEEP_ALIVE
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_DATA
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_ERROR
import suwayomi.tachidesk.graphql.server.toGraphQLContext
/**
* Implementation of the `graphql-ws` protocol defined by Apollo
* https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md
* ported for Javalin
*/
class ApolloSubscriptionProtocolHandler(
private val contextFactory: TachideskGraphQLContextFactory,
private val subscriptionHandler: GraphQLSubscriptionHandler,
private val objectMapper: ObjectMapper
) {
private val sessionState = ApolloSubscriptionSessionState()
private val logger = KotlinLogging.logger {}
private val keepAliveMessage = SubscriptionOperationMessage(type = GQL_CONNECTION_KEEP_ALIVE.type)
private val basicConnectionErrorMessage = SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type)
private val acknowledgeMessage = SubscriptionOperationMessage(GQL_CONNECTION_ACK.type)
fun handleMessage(context: WsMessageContext): Flow<SubscriptionOperationMessage> {
val operationMessage = convertToMessageOrNull(context.message()) ?: return flowOf(basicConnectionErrorMessage)
logger.debug { "GraphQL subscription client message, sessionId=${context.sessionId} operationMessage=$operationMessage" }
return try {
when (operationMessage.type) {
GQL_CONNECTION_INIT.type -> onInit(operationMessage, context)
GQL_START.type -> startSubscription(operationMessage, context)
GQL_STOP.type -> onStop(operationMessage, context)
GQL_CONNECTION_TERMINATE.type -> onDisconnect(context)
else -> onUnknownOperation(operationMessage, context)
}
} catch (exception: Exception) {
onException(exception)
}
}
fun handleDisconnect(context: WsContext) {
onDisconnect(context)
}
private fun convertToMessageOrNull(payload: String): SubscriptionOperationMessage? {
return try {
objectMapper.readValue(payload)
} catch (exception: Exception) {
logger.error("Error parsing the subscription message", exception)
null
}
}
/**
* If the keep alive configuration is set, send a message back to client at every interval until the session is terminated.
* Otherwise just return empty flux to append to the acknowledge message.
*/
@OptIn(FlowPreview::class)
private fun getKeepAliveFlow(context: WsContext): Flow<SubscriptionOperationMessage> {
val keepAliveInterval: Long? = 2000
if (keepAliveInterval != null) {
return flowOf(keepAliveMessage).sample(keepAliveInterval)
.onStart {
sessionState.saveKeepAliveSubscription(context, currentCoroutineContext().job)
}
}
return emptyFlow()
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun startSubscription(
operationMessage: SubscriptionOperationMessage,
context: WsContext
): Flow<SubscriptionOperationMessage> {
val graphQLContext = sessionState.getGraphQLContext(context)
if (operationMessage.id == null) {
logger.error("GraphQL subscription operation id is required")
return flowOf(basicConnectionErrorMessage)
}
if (sessionState.doesOperationExist(context, operationMessage)) {
logger.info("Already subscribed to operation ${operationMessage.id} for session ${context.sessionId}")
return emptyFlow()
}
val payload = operationMessage.payload
if (payload == null) {
logger.error("GraphQL subscription payload was null instead of a GraphQLRequest object")
sessionState.stopOperation(context, operationMessage)
return flowOf(SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type, id = operationMessage.id))
}
try {
val request = objectMapper.convertValue<GraphQLRequest>(payload)
return subscriptionHandler.executeSubscription(request, graphQLContext)
.map {
if (it.errors?.isNotEmpty() == true) {
SubscriptionOperationMessage(type = GQL_ERROR.type, id = operationMessage.id, payload = it)
} else {
SubscriptionOperationMessage(type = GQL_DATA.type, id = operationMessage.id, payload = it)
}
}
.onCompletion { if (it == null) emitAll(onComplete(operationMessage, context)) }
.onStart { sessionState.saveOperation(context, operationMessage, currentCoroutineContext().job) }
} catch (exception: Exception) {
logger.error("Error running graphql subscription", exception)
// Do not terminate the session, just stop the operation messages
sessionState.stopOperation(context, operationMessage)
return flowOf(SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type, id = operationMessage.id))
}
}
private fun onInit(operationMessage: SubscriptionOperationMessage, context: WsContext): Flow<SubscriptionOperationMessage> {
saveContext(operationMessage, context)
val acknowledgeMessage = flowOf(acknowledgeMessage)
val keepAliveFlux = getKeepAliveFlow(context)
return acknowledgeMessage.onCompletion { if (it == null) emitAll(keepAliveFlux) }
.catch { emit(getConnectionErrorMessage(operationMessage)) }
}
/**
* Generate the context and save it for all future messages.
*/
private fun saveContext(operationMessage: SubscriptionOperationMessage, context: WsContext) {
runBlocking {
val graphQLContext = contextFactory.generateContextMap(context).toGraphQLContext()
sessionState.saveContext(context, graphQLContext)
}
}
/**
* Called with the publisher has completed on its own.
*/
private fun onComplete(
operationMessage: SubscriptionOperationMessage,
context: WsContext
): Flow<SubscriptionOperationMessage> {
return sessionState.completeOperation(context, operationMessage)
}
/**
* Called with the client has called stop manually, or on error, and we need to cancel the publisher
*/
private fun onStop(
operationMessage: SubscriptionOperationMessage,
context: WsContext
): Flow<SubscriptionOperationMessage> {
return sessionState.stopOperation(context, operationMessage)
}
private fun onDisconnect(context: WsContext): Flow<SubscriptionOperationMessage> {
sessionState.terminateSession(context)
return emptyFlow()
}
private fun onUnknownOperation(operationMessage: SubscriptionOperationMessage, context: WsContext): Flow<SubscriptionOperationMessage> {
logger.error("Unknown subscription operation $operationMessage")
sessionState.stopOperation(context, operationMessage)
return flowOf(getConnectionErrorMessage(operationMessage))
}
private fun onException(exception: Exception): Flow<SubscriptionOperationMessage> {
logger.error("Error parsing the subscription message", exception)
return flowOf(basicConnectionErrorMessage)
}
private fun getConnectionErrorMessage(operationMessage: SubscriptionOperationMessage): SubscriptionOperationMessage {
return SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type, id = operationMessage.id)
}
}

View File

@@ -0,0 +1,128 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import graphql.GraphQLContext
import io.javalin.websocket.WsContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onCompletion
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_COMPLETE
import suwayomi.tachidesk.graphql.server.toGraphQLContext
import java.util.concurrent.ConcurrentHashMap
internal class ApolloSubscriptionSessionState {
// Sessions are saved by web socket session id
internal val activeKeepAliveSessions = ConcurrentHashMap<String, Job>()
// Operations are saved by web socket session id, then operation id
internal val activeOperations = ConcurrentHashMap<String, ConcurrentHashMap<String, Job>>()
// The graphQL context is saved by web socket session id
private val cachedGraphQLContext = ConcurrentHashMap<String, GraphQLContext>()
/**
* Save the context created from the factory and possibly updated in the onConnect hook.
* This allows us to include some initial state to be used when handling all the messages.
* This will be removed in [terminateSession].
*/
fun saveContext(context: WsContext, graphQLContext: GraphQLContext) {
cachedGraphQLContext[context.sessionId] = graphQLContext
}
/**
* Return the graphQL context for this session.
*/
fun getGraphQLContext(context: WsContext): GraphQLContext = cachedGraphQLContext[context.sessionId] ?: emptyMap<Any, Any>().toGraphQLContext()
/**
* Save the session that is sending keep alive messages.
* This will override values without cancelling the subscription, so it is the responsibility of the consumer to cancel.
* These messages will be stopped on [terminateSession].
*/
fun saveKeepAliveSubscription(context: WsContext, subscription: Job) {
activeKeepAliveSessions[context.sessionId] = subscription
}
/**
* Save the operation that is sending data to the client.
* This will override values without cancelling the subscription so it is the responsibility of the consumer to cancel.
* These messages will be stopped on [stopOperation].
*/
fun saveOperation(context: WsContext, operationMessage: SubscriptionOperationMessage, subscription: Job) {
val id = operationMessage.id
if (id != null) {
val operationsForSession: ConcurrentHashMap<String, Job> = activeOperations.getOrPut(context.sessionId) { ConcurrentHashMap() }
operationsForSession[id] = subscription
}
}
/**
* Send the [GQL_COMPLETE] message.
* This can happen when the publisher finishes or if the client manually sends the stop message.
*/
fun completeOperation(context: WsContext, operationMessage: SubscriptionOperationMessage): Flow<SubscriptionOperationMessage> {
return getCompleteMessage(operationMessage)
.onCompletion { removeActiveOperation(context, operationMessage.id, cancelSubscription = false) }
}
/**
* Stop the subscription sending data and send the [GQL_COMPLETE] message.
* Does NOT terminate the session.
*/
fun stopOperation(context: WsContext, operationMessage: SubscriptionOperationMessage): Flow<SubscriptionOperationMessage> {
return getCompleteMessage(operationMessage)
.onCompletion { removeActiveOperation(context, operationMessage.id, cancelSubscription = true) }
}
private fun getCompleteMessage(operationMessage: SubscriptionOperationMessage): Flow<SubscriptionOperationMessage> {
val id = operationMessage.id
if (id != null) {
return flowOf(SubscriptionOperationMessage(type = GQL_COMPLETE.type, id = id))
}
return emptyFlow()
}
/**
* Remove active running subscription from the cache and cancel if needed
*/
private fun removeActiveOperation(context: WsContext, id: String?, cancelSubscription: Boolean) {
val operationsForSession = activeOperations[context.sessionId]
val subscription = operationsForSession?.get(id)
if (subscription != null) {
if (cancelSubscription) {
subscription.cancel()
}
operationsForSession.remove(id)
if (operationsForSession.isEmpty()) {
activeOperations.remove(context.sessionId)
}
}
}
/**
* Terminate the session, cancelling the keep alive messages and all operations active for this session.
*/
fun terminateSession(context: WsContext) {
activeOperations[context.sessionId]?.forEach { (_, subscription) -> subscription.cancel() }
activeOperations.remove(context.sessionId)
cachedGraphQLContext.remove(context.sessionId)
activeKeepAliveSessions[context.sessionId]?.cancel()
activeKeepAliveSessions.remove(context.sessionId)
context.closeSession()
}
/**
* Looks up the operation for the client, to check if it already exists
*/
fun doesOperationExist(context: WsContext, operationMessage: SubscriptionOperationMessage): Boolean =
activeOperations[context.sessionId]?.containsKey(operationMessage.id) ?: false
}

View File

@@ -0,0 +1,20 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
class FlowSubscriptionSource<T : Any> {
private val mutableSharedFlow = MutableSharedFlow<T>()
val emitter = mutableSharedFlow.asSharedFlow()
fun publish(value: T) {
mutableSharedFlow.tryEmit(value)
}
}

View File

@@ -0,0 +1,43 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
import com.expediagroup.graphql.server.extensions.toExecutionInput
import com.expediagroup.graphql.server.extensions.toGraphQLError
import com.expediagroup.graphql.server.extensions.toGraphQLKotlinType
import com.expediagroup.graphql.server.extensions.toGraphQLResponse
import com.expediagroup.graphql.server.types.GraphQLRequest
import com.expediagroup.graphql.server.types.GraphQLResponse
import graphql.ExecutionResult
import graphql.GraphQL
import graphql.GraphQLContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
open class GraphQLSubscriptionHandler(
private val graphQL: GraphQL,
private val dataLoaderRegistryFactory: KotlinDataLoaderRegistryFactory? = null
) {
open fun executeSubscription(
graphQLRequest: GraphQLRequest,
graphQLContext: GraphQLContext = GraphQLContext.of(emptyMap<Any, Any>())
): Flow<GraphQLResponse<*>> {
val dataLoaderRegistry = dataLoaderRegistryFactory?.generate()
val input = graphQLRequest.toExecutionInput(dataLoaderRegistry, graphQLContext)
val res = graphQL.execute(input)
val data = res.getData<Flow<ExecutionResult>>()
val mapped = data.map { result -> result.toGraphQLResponse() }
return mapped.catch { throwable ->
val error = throwable.toGraphQLError()
emit(GraphQLResponse<Any?>(errors = listOf(error.toGraphQLKotlinType())))
}
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
/**
* The `graphql-ws` protocol from Apollo Client has some special text messages to signal events.
* Along with the HTTP WebSocket event handling we need to have some extra logic
*
* https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class SubscriptionOperationMessage(
val type: String,
val id: String? = null,
val payload: Any? = null
) {
enum class ClientMessages(val type: String) {
GQL_CONNECTION_INIT("connection_init"),
GQL_START("start"),
GQL_STOP("stop"),
GQL_CONNECTION_TERMINATE("connection_terminate")
}
enum class ServerMessages(val type: String) {
GQL_CONNECTION_ACK("connection_ack"),
GQL_CONNECTION_ERROR("connection_error"),
GQL_DATA("data"),
GQL_ERROR("error"),
GQL_COMPLETE("complete"),
GQL_CONNECTION_KEEP_ALIVE("ka")
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.subscriptions
import graphql.schema.DataFetchingEnvironment
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import suwayomi.tachidesk.graphql.server.subscriptions.FlowSubscriptionSource
import suwayomi.tachidesk.graphql.types.DownloadType
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
val downloadSubscriptionSource = FlowSubscriptionSource<DownloadChapter>()
class DownloadSubscription {
fun downloadChanged(dataFetchingEnvironment: DataFetchingEnvironment): Flow<DownloadType> {
return downloadSubscriptionSource.emitter.map { downloadChapter ->
DownloadType(downloadChapter)
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
import suwayomi.tachidesk.manga.model.table.CategoryTable
import java.util.concurrent.CompletableFuture
class CategoryType(
val id: Int,
val order: Int,
val name: String,
val default: Boolean,
val includeInUpdate: IncludeInUpdate
) : Node {
constructor(row: ResultRow) : this(
row[CategoryTable.id].value,
row[CategoryTable.order],
row[CategoryTable.name],
row[CategoryTable.isDefault],
IncludeInUpdate.fromValue(row[CategoryTable.includeInUpdate])
)
fun mangas(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaNodeList>("MangaForCategoryDataLoader", id)
}
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<CategoryMetaType>> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, List<CategoryMetaType>>("CategoryMetaDataLoader", id)
}
}
data class CategoryNodeList(
override val nodes: List<CategoryType>,
override val edges: List<CategoryEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class CategoryEdge(
override val cursor: Cursor,
override val node: CategoryType
) : Edge()
companion object {
fun List<CategoryType>.toNodeList(): CategoryNodeList {
return CategoryNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<CategoryType>.getEdges(): List<CategoryEdge> {
if (isEmpty()) return emptyList()
return listOf(
CategoryEdge(
cursor = Cursor("0"),
node = first()
),
CategoryEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,129 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.util.concurrent.CompletableFuture
class ChapterType(
val id: Int,
val url: String,
val name: String,
val uploadDate: Long,
val chapterNumber: Float,
val scanlator: String?,
val mangaId: Int,
val isRead: Boolean,
val isBookmarked: Boolean,
val lastPageRead: Int,
val lastReadAt: Long,
val sourceOrder: Int,
val realUrl: String?,
val fetchedAt: Long,
val isDownloaded: Boolean,
val pageCount: Int
// val chapterCount: Int?,
) : Node {
constructor(row: ResultRow) : this(
row[ChapterTable.id].value,
row[ChapterTable.url],
row[ChapterTable.name],
row[ChapterTable.date_upload],
row[ChapterTable.chapter_number],
row[ChapterTable.scanlator],
row[ChapterTable.manga].value,
row[ChapterTable.isRead],
row[ChapterTable.isBookmarked],
row[ChapterTable.lastPageRead],
row[ChapterTable.lastReadAt],
row[ChapterTable.sourceOrder],
row[ChapterTable.realUrl],
row[ChapterTable.fetchedAt],
row[ChapterTable.isDownloaded],
row[ChapterTable.pageCount]
// transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() },
)
constructor(dataClass: ChapterDataClass) : this(
dataClass.id,
dataClass.url,
dataClass.name,
dataClass.uploadDate,
dataClass.chapterNumber,
dataClass.scanlator,
dataClass.mangaId,
dataClass.read,
dataClass.bookmarked,
dataClass.lastPageRead,
dataClass.lastReadAt,
dataClass.index,
dataClass.realUrl,
dataClass.fetchedAt,
dataClass.downloaded,
dataClass.pageCount
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", mangaId)
}
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<ChapterMetaType>> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, List<ChapterMetaType>>("ChapterMetaDataLoader", id)
}
}
data class ChapterNodeList(
override val nodes: List<ChapterType>,
override val edges: List<ChapterEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class ChapterEdge(
override val cursor: Cursor,
override val node: ChapterType
) : Edge()
companion object {
fun List<ChapterType>.toNodeList(): ChapterNodeList {
return ChapterNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<ChapterType>.getEdges(): List<ChapterEdge> {
if (isEmpty()) return emptyList()
return listOf(
ChapterEdge(
cursor = Cursor("0"),
node = first()
),
ChapterEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,85 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState
import java.util.concurrent.CompletableFuture
class DownloadType(
val chapterId: Int,
val mangaId: Int,
var state: DownloadState = DownloadState.Queued,
var progress: Float = 0f,
var tries: Int = 0
) : Node {
constructor(downloadChapter: DownloadChapter) : this(
downloadChapter.chapter.id,
downloadChapter.mangaId,
downloadChapter.state,
downloadChapter.progress,
downloadChapter.tries
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", mangaId)
}
fun chapter(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ChapterType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, ChapterType>("ChapterDataLoader", chapterId)
}
}
data class DownloadNodeList(
override val nodes: List<DownloadType>,
override val edges: List<DownloadEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class DownloadEdge(
override val cursor: Cursor,
override val node: DownloadType
) : Edge()
companion object {
fun List<DownloadType>.toNodeList(): DownloadNodeList {
return DownloadNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<DownloadType>.getEdges(): List<DownloadEdge> {
if (isEmpty()) return emptyList()
return listOf(
DownloadEdge(
cursor = Cursor("0"),
node = first()
),
DownloadEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.CompletableFuture
class ExtensionType(
val apkName: String,
val iconUrl: String,
val name: String,
val pkgName: String,
val versionName: String,
val versionCode: Int,
val lang: String,
val isNsfw: Boolean,
val isInstalled: Boolean,
val hasUpdate: Boolean,
val isObsolete: Boolean
) : Node {
constructor(row: ResultRow) : this(
apkName = row[ExtensionTable.apkName],
iconUrl = row[ExtensionTable.iconUrl],
name = row[ExtensionTable.name],
pkgName = row[ExtensionTable.pkgName],
versionName = row[ExtensionTable.versionName],
versionCode = row[ExtensionTable.versionCode],
lang = row[ExtensionTable.lang],
isNsfw = row[ExtensionTable.isNsfw],
isInstalled = row[ExtensionTable.isInstalled],
hasUpdate = row[ExtensionTable.hasUpdate],
isObsolete = row[ExtensionTable.isObsolete]
)
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
}
}
data class ExtensionNodeList(
override val nodes: List<ExtensionType>,
override val edges: List<ExtensionEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class ExtensionEdge(
override val cursor: Cursor,
override val node: ExtensionType
) : Edge()
companion object {
fun List<ExtensionType>.toNodeList(): ExtensionNodeList {
return ExtensionNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<ExtensionType>.getEdges(): List<ExtensionEdge> {
if (isEmpty()) return emptyList()
return listOf(
ExtensionEdge(
cursor = Cursor("0"),
node = first()
),
ExtensionEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,149 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.time.Instant
import java.util.concurrent.CompletableFuture
class MangaType(
val id: Int,
val sourceId: Long,
val url: String,
val title: String,
val thumbnailUrl: String?,
val initialized: Boolean,
val artist: String?,
val author: String?,
val description: String?,
val genre: List<String>,
val status: MangaStatus,
val inLibrary: Boolean,
val inLibraryAt: Long,
val realUrl: String?,
var lastFetchedAt: Long?, // todo
var chaptersLastFetchedAt: Long? // todo
) : Node {
constructor(row: ResultRow) : this(
row[MangaTable.id].value,
row[MangaTable.sourceReference],
row[MangaTable.url],
row[MangaTable.title],
row[MangaTable.thumbnail_url],
row[MangaTable.initialized],
row[MangaTable.artist],
row[MangaTable.author],
row[MangaTable.description],
row[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(row[MangaTable.status]),
row[MangaTable.inLibrary],
row[MangaTable.inLibraryAt],
row[MangaTable.realUrl],
row[MangaTable.lastFetchedAt],
row[MangaTable.chaptersLastFetchedAt]
)
constructor(dataClass: MangaDataClass) : this(
dataClass.id,
dataClass.sourceId.toLong(),
dataClass.url,
dataClass.title,
dataClass.thumbnailUrl,
dataClass.initialized,
dataClass.artist,
dataClass.author,
dataClass.description,
dataClass.genre,
MangaStatus.valueOf(dataClass.status),
dataClass.inLibrary,
dataClass.inLibraryAt,
dataClass.realUrl,
dataClass.lastFetchedAt,
dataClass.chaptersLastFetchedAt
)
fun chapters(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ChapterNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, ChapterNodeList>("ChaptersForMangaDataLoader", id)
}
fun age(): Long? {
if (lastFetchedAt == null) return null
return Instant.now().epochSecond.minus(lastFetchedAt!!)
}
fun chaptersAge(): Long? {
if (chaptersLastFetchedAt == null) return null
return Instant.now().epochSecond.minus(chaptersLastFetchedAt!!)
}
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<MangaMetaType>> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, List<MangaMetaType>>("MangaMetaDataLoader", id)
}
fun categories(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<CategoryNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, CategoryNodeList>("CategoriesForMangaDataLoader", id)
}
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceType?> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, SourceType?>("SourceDataLoader", sourceId)
}
}
data class MangaNodeList(
override val nodes: List<MangaType>,
override val edges: List<MangaEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class MangaEdge(
override val cursor: Cursor,
override val node: MangaType
) : Edge()
companion object {
fun List<MangaType>.toNodeList(): MangaNodeList {
return MangaNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<MangaType>.getEdges(): List<MangaEdge> {
if (isEmpty()) return emptyList()
return listOf(
MangaEdge(
cursor = Cursor("0"),
node = first()
),
MangaEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,120 @@
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import java.util.concurrent.CompletableFuture
interface MetaType : Node {
val key: String
val value: String
}
class ChapterMetaType(
override val key: String,
override val value: String,
val chapterId: Int
) : MetaType {
constructor(row: ResultRow) : this(
key = row[ChapterMetaTable.key],
value = row[ChapterMetaTable.value],
chapterId = row[ChapterMetaTable.ref].value
)
fun chapter(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ChapterType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, ChapterType>("ChapterDataLoader", chapterId)
}
}
class MangaMetaType(
override val key: String,
override val value: String,
val mangaId: Int
) : MetaType {
constructor(row: ResultRow) : this(
key = row[MangaMetaTable.key],
value = row[MangaMetaTable.value],
mangaId = row[MangaMetaTable.ref].value
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", mangaId)
}
}
class CategoryMetaType(
override val key: String,
override val value: String,
val categoryId: Int
) : MetaType {
constructor(row: ResultRow) : this(
key = row[CategoryMetaTable.key],
value = row[CategoryMetaTable.value],
categoryId = row[CategoryMetaTable.ref].value
)
fun category(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<CategoryType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, CategoryType>("CategoryDataLoader", categoryId)
}
}
class GlobalMetaType(
override val key: String,
override val value: String
) : MetaType {
constructor(row: ResultRow) : this(
key = row[GlobalMetaTable.key],
value = row[GlobalMetaTable.value]
)
}
data class GlobalMetaNodeList(
override val nodes: List<GlobalMetaType>,
override val edges: List<MetaEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class MetaEdge(
override val cursor: Cursor,
override val node: GlobalMetaType
) : Edge()
companion object {
fun List<GlobalMetaType>.toNodeList(): GlobalMetaNodeList {
return GlobalMetaNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<GlobalMetaType>.getEdges(): List<MetaEdge> {
if (isEmpty()) return emptyList()
return listOf(
MetaEdge(
cursor = Cursor("0"),
node = first()
),
MetaEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,144 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.ConfigurableSource
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.select
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.impl.Search
import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.util.concurrent.CompletableFuture
class SourceType(
val id: Long,
val name: String,
val lang: String,
val iconUrl: String,
val supportsLatest: Boolean,
val isConfigurable: Boolean,
val isNsfw: Boolean,
val displayName: String
) : Node {
constructor(source: SourceDataClass) : this(
id = source.id.toLong(),
name = source.name,
lang = source.lang,
iconUrl = source.iconUrl,
supportsLatest = source.supportsLatest,
isConfigurable = source.isConfigurable,
isNsfw = source.isNsfw,
displayName = source.displayName
)
constructor(row: ResultRow, sourceExtension: ResultRow, catalogueSource: CatalogueSource) : this(
id = row[SourceTable.id].value,
name = row[SourceTable.name],
lang = row[SourceTable.lang],
iconUrl = Extension.getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = row[SourceTable.isNsfw],
displayName = catalogueSource.toString()
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, MangaNodeList>("MangaForSourceDataLoader", id)
}
fun extension(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionType> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, ExtensionType>("ExtensionForSourceDataLoader", id)
}
fun preferences(): List<PreferenceObject> {
return Source.getSourcePreferences(id).map { PreferenceObject(it.type, it.props) }
}
fun filters(): List<FilterObject> {
return Search.getFilterList(id, false).map { FilterObject(it.type, it.filter) }
}
}
fun SourceType(row: ResultRow): SourceType? {
val catalogueSource = GetCatalogueSource
.getCatalogueSourceOrNull(row[SourceTable.id].value)
?: return null
val sourceExtension = if (row.hasValue(ExtensionTable.id)) {
row
} else {
ExtensionTable
.select { ExtensionTable.id eq row[SourceTable.extension] }
.first()
}
return SourceType(row, sourceExtension, catalogueSource)
}
data class SourceNodeList(
override val nodes: List<SourceType>,
override val edges: List<SourceEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class SourceEdge(
override val cursor: Cursor,
override val node: SourceType
) : Edge()
companion object {
fun List<SourceType>.toNodeList(): SourceNodeList {
return SourceNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<SourceType>.getEdges(): List<SourceEdge> {
if (isEmpty()) return emptyList()
return listOf(
SourceEdge(
cursor = Cursor("0"),
node = first()
),
SourceEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}
data class PreferenceObject(
val type: String,
val props: Any
)
data class FilterObject(
val type: String,
val filter: Any
)

View File

@@ -0,0 +1,37 @@
package suwayomi.tachidesk.graphql.types
/*
* 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 eu.kanade.tachiyomi.data.track.TrackService
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
data class TrackServiceType(
val id: Long,
val name: String
) : Node {
constructor(trackService: TrackService) : this(
trackService.id,
trackService.name
)
}
data class TrackServiceNodeList(
override val nodes: List<TrackServiceType>,
override val edges: List<TrackServiceEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class TrackServiceEdge(
override val cursor: Cursor,
override val node: TrackServiceType
) : Edge()
}

View File

@@ -61,14 +61,15 @@ object CategoryController {
pathParam<Int>("categoryId"),
formParam<String?>("name"),
formParam<Boolean?>("default"),
formParam<Int?>("includeInUpdate"),
documentWith = {
withOperation {
summary("Category modify")
description("Modify a category")
}
},
behaviorOf = { ctx, categoryId, name, isDefault ->
Category.updateCategory(categoryId, name, isDefault)
behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate ->
Category.updateCategory(categoryId, name, isDefault, includeInUpdate)
ctx.status(200)
},
withResults = {

View File

@@ -14,6 +14,7 @@ import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.update.UpdateStatus
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
@@ -93,14 +94,33 @@ object UpdateController {
if (clear) {
updater.reset()
}
categories
val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate }
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty()
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty()
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty()
val categoriesToUpdate = includedCategories.ifEmpty { unsetCategories }
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
val categoriesToUpdateMangas = categoriesToUpdate
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
.distinctBy { it.id }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id })
val mangasToUpdate = categoriesToUpdateMangas
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
.forEach { manga ->
updater.addMangaToQueue(manga)
}
.filter { !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
// In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
if (mangasToUpdate.isEmpty()) {
UpdaterSocket.notifyAllClients(UpdateStatus())
return
}
updater.addMangasToQueue(
mangasToUpdate
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
)
}
fun categoryUpdateWS(ws: WsConfig) {

View File

@@ -10,6 +10,7 @@ package suwayomi.tachidesk.manga.impl
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
@@ -17,8 +18,6 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
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.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
@@ -50,11 +49,12 @@ object Category {
}
}
fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?) {
fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?, includeInUpdate: Int?) {
transaction {
CategoryTable.update({ CategoryTable.id eq categoryId }) {
if (name != null && !name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) it[CategoryTable.name] = name
if (isDefault != null) it[CategoryTable.isDefault] = isDefault
if (categoryId != DEFAULT_CATEGORY_ID && name != null && !name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) it[CategoryTable.name] = name
if (categoryId != DEFAULT_CATEGORY_ID && isDefault != null) it[CategoryTable.isDefault] = isDefault
if (includeInUpdate != null) it[CategoryTable.includeInUpdate] = includeInUpdate
}
}
}
@@ -63,6 +63,7 @@ object Category {
* Move the category from order number `from` to `to`
*/
fun reorderCategory(from: Int, to: Int) {
if (from == 0 || to == 0) return
transaction {
val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).toMutableList()
categories.add(to - 1, categories.removeAt(from - 1))
@@ -75,43 +76,53 @@ object Category {
}
fun removeCategory(categoryId: Int) {
if (categoryId == DEFAULT_CATEGORY_ID) return
transaction {
CategoryMangaTable.select { CategoryMangaTable.category eq categoryId }.forEach {
removeMangaFromCategory(it[CategoryMangaTable.manga].value, categoryId)
}
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
normalizeCategories()
}
}
/** make sure category order numbers starts from 1 and is consecutive */
private fun normalizeCategories() {
fun normalizeCategories() {
transaction {
val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC)
categories.forEachIndexed { index, cat ->
CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value }) {
it[CategoryTable.order] = index + 1
CategoryTable.selectAll()
.orderBy(CategoryTable.order to SortOrder.ASC)
.sortedWith(compareBy({ it[CategoryTable.id].value != 0 }, { it[CategoryTable.order] }))
.forEachIndexed { index, cat ->
CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value }) {
it[CategoryTable.order] = index
}
}
}
}
}
private fun needsDefaultCategory() = transaction {
MangaTable
.leftJoin(CategoryMangaTable)
.select { MangaTable.inLibrary eq true }
.andWhere { CategoryMangaTable.manga.isNull() }
.empty()
.not()
}
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> {
return transaction {
val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map {
CategoryTable.toDataClass(it)
}
addDefaultIfNecessary(categories)
CategoryTable.selectAll()
.orderBy(CategoryTable.order to SortOrder.ASC)
.let {
if (needsDefaultCategory()) {
it
} else {
it.andWhere { CategoryTable.id neq DEFAULT_CATEGORY_ID }
}
}
.map {
CategoryTable.toDataClass(it)
}
}
}
@@ -123,6 +134,21 @@ object Category {
}
}
fun getCategorySize(categoryId: Int): Int {
return transaction {
if (categoryId == DEFAULT_CATEGORY_ID) {
MangaTable
.leftJoin(CategoryMangaTable)
.select { MangaTable.inLibrary eq true }
.andWhere { CategoryMangaTable.manga.isNull() }
} else {
CategoryMangaTable.select {
CategoryMangaTable.category eq categoryId
}
}.count().toInt()
}
}
fun getCategoryMetaMap(categoryId: Int): Map<String, String> {
return transaction {
CategoryMetaTable.select { CategoryMetaTable.ref eq categoryId }

View File

@@ -10,13 +10,15 @@ package suwayomi.tachidesk.manga.impl
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.alias
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.count
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.leftJoin
import org.jetbrains.exposed.sql.max
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import org.jetbrains.exposed.sql.wrapAsExpression
import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID
import suwayomi.tachidesk.manga.impl.util.lang.isEmpty
@@ -30,6 +32,7 @@ import suwayomi.tachidesk.manga.model.table.toDataClass
object CategoryManga {
fun addMangaToCategory(mangaId: Int, categoryId: Int) {
if (categoryId == DEFAULT_CATEGORY_ID) return
fun notAlreadyInCategory() = CategoryMangaTable.select { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }.isEmpty()
transaction {
@@ -38,22 +41,14 @@ object CategoryManga {
it[CategoryMangaTable.category] = categoryId
it[CategoryMangaTable.manga] = mangaId
}
MangaTable.update({ MangaTable.id eq mangaId }) {
it[MangaTable.defaultCategory] = false
}
}
}
}
fun removeMangaFromCategory(mangaId: Int, categoryId: Int) {
if (categoryId == DEFAULT_CATEGORY_ID) return
transaction {
CategoryMangaTable.deleteWhere { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }
if (CategoryMangaTable.select { CategoryMangaTable.manga eq mangaId }.count() == 0L) {
MangaTable.update({ MangaTable.id eq mangaId }) {
it[MangaTable.defaultCategory] = true
}
}
}
}
@@ -61,46 +56,46 @@ object CategoryManga {
* list of mangas that belong to a category
*/
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
val unreadExpression = wrapAsExpression<Long>(
ChapterTable
.slice(ChapterTable.id.count())
.select { (MangaTable.id eq ChapterTable.manga) and (ChapterTable.isRead eq false) }
// Select the required columns from the MangaTable and add the aggregate functions to compute unread, download, and chapter counts
val unreadCount = wrapAsExpression<Long>(
ChapterTable.slice(ChapterTable.id.count()).select((ChapterTable.isRead eq false) and (ChapterTable.manga eq MangaTable.id))
)
val downloadExpression = wrapAsExpression<Long>(
ChapterTable
.slice(ChapterTable.id.count())
.select { (MangaTable.id eq ChapterTable.manga) and (ChapterTable.isDownloaded eq true) }
)
val chapterCountExpression = wrapAsExpression<Long>(
ChapterTable
.slice(ChapterTable.id.count())
.select { (MangaTable.id eq ChapterTable.manga) }
val downloadedCount = wrapAsExpression<Long>(
ChapterTable.slice(ChapterTable.id.count()).select((ChapterTable.isDownloaded eq true) and (ChapterTable.manga eq MangaTable.id))
)
val selectedColumns = MangaTable.columns + unreadExpression + downloadExpression + chapterCountExpression
val chapterCount = ChapterTable.id.count().alias("chapter_count")
val lastReadAt = ChapterTable.lastReadAt.max().alias("last_read_at")
val selectedColumns = MangaTable.columns + unreadCount + downloadedCount + chapterCount + lastReadAt
val transform: (ResultRow) -> MangaDataClass = {
// Map the data from the result row to the MangaDataClass
val dataClass = MangaTable.toDataClass(it)
dataClass.unreadCount = it[unreadExpression]
dataClass.downloadCount = it[downloadExpression]
dataClass.chapterCount = it[chapterCountExpression]
dataClass.lastReadAt = it[lastReadAt]
dataClass.unreadCount = it[unreadCount]
dataClass.downloadCount = it[downloadedCount]
dataClass.chapterCount = it[chapterCount]
dataClass
}
if (categoryId == DEFAULT_CATEGORY_ID) {
return transaction {
MangaTable
.slice(selectedColumns)
.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }
.map(transform)
}
}
return transaction {
CategoryMangaTable.innerJoin(MangaTable)
.slice(selectedColumns)
.select { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) }
.map(transform)
// Fetch data from the MangaTable and join with the CategoryMangaTable, if a category is specified
val query = if (categoryId == DEFAULT_CATEGORY_ID) {
MangaTable
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
.leftJoin(CategoryMangaTable)
.slice(columns = selectedColumns)
.select { (MangaTable.inLibrary eq true) and CategoryMangaTable.category.isNull() }
} else {
MangaTable
.innerJoin(CategoryMangaTable)
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
.slice(columns = selectedColumns)
.select { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) }
}
// Join with the ChapterTable to fetch the last read chapter for each manga
query.groupBy(*MangaTable.columns.toTypedArray()).map(transform)
}
}
@@ -114,4 +109,20 @@ object CategoryManga {
}
}
}
fun getMangasCategories(mangaIDs: List<Int>): Map<Int, List<CategoryDataClass>> {
return buildMap {
transaction {
CategoryMangaTable.innerJoin(CategoryTable)
.select { CategoryMangaTable.manga inList mangaIDs }
.groupBy { it[CategoryMangaTable.manga] }
.forEach {
val mangaId = it.key.value
val categories = it.value
set(mangaId, categories.map { category -> CategoryTable.toDataClass(category) })
}
}
}
}
}

View File

@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl
* 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 eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
@@ -31,7 +32,6 @@ import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.ChapterTable.scanlator
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass
@@ -56,6 +56,48 @@ object Chapter {
}
private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> {
val chapterList = fetchChapterList(mangaId)
val dbChapterMap = transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }
.associateBy({ it[ChapterTable.url] }, { it })
}
val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] }
val chapterMetas = getChaptersMetaMaps(chapterIds)
return chapterList.mapIndexed { index, it ->
val dbChapter = dbChapterMap.getValue(it.url)
ChapterDataClass(
id = dbChapter[ChapterTable.id].value,
url = it.url,
name = it.name,
uploadDate = it.date_upload,
chapterNumber = it.chapter_number,
scanlator = it.scanlator,
mangaId = mangaId,
read = dbChapter[ChapterTable.isRead],
bookmarked = dbChapter[ChapterTable.isBookmarked],
lastPageRead = dbChapter[ChapterTable.lastPageRead],
lastReadAt = dbChapter[ChapterTable.lastReadAt],
index = chapterList.size - index,
fetchedAt = dbChapter[ChapterTable.fetchedAt],
realUrl = dbChapter[ChapterTable.realUrl],
downloaded = dbChapter[ChapterTable.isDownloaded],
pageCount = dbChapter[ChapterTable.pageCount],
chapterCount = chapterList.size,
meta = chapterMetas.getValue(dbChapter[ChapterTable.id])
)
}
}
suspend fun fetchChapterList(mangaId: Int): List<SChapter> {
val manga = getManga(mangaId)
val source = getCatalogueSourceOrStub(manga.sourceId.toLong())
@@ -72,7 +114,6 @@ object Chapter {
ChapterRecognition.parseChapterNumber(it, sManga)
}
val chapterCount = chapterList.count()
var now = Instant.now().epochSecond
transaction {
@@ -118,9 +159,10 @@ object Chapter {
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
val dbChapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() }
if (dbChapterCount > chapterCount) { // we got some clean up due
if (dbChapterCount > chapterList.size) { // we got some clean up due
val dbChapterList = transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }.orderBy(ChapterTable.url to ASC).toList()
ChapterTable.select { ChapterTable.manga eq mangaId }
.orderBy(ChapterTable.url to ASC).toList()
}
val chapterUrls = chapterList.map { it.url }.toSet()
@@ -137,43 +179,7 @@ object Chapter {
}
}
val dbChapterMap = transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }
.associateBy({ it[ChapterTable.url] }, { it })
}
val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] }
val chapterMetas = getChaptersMetaMaps(chapterIds)
return chapterList.mapIndexed { index, it ->
val dbChapter = dbChapterMap.getValue(it.url)
ChapterDataClass(
id = dbChapter[ChapterTable.id].value,
url = it.url,
name = it.name,
uploadDate = it.date_upload,
chapterNumber = it.chapter_number,
scanlator = it.scanlator,
mangaId = mangaId,
read = dbChapter[ChapterTable.isRead],
bookmarked = dbChapter[ChapterTable.isBookmarked],
lastPageRead = dbChapter[ChapterTable.lastPageRead],
lastReadAt = dbChapter[ChapterTable.lastReadAt],
index = chapterCount - index,
fetchedAt = dbChapter[ChapterTable.fetchedAt],
realUrl = dbChapter[ChapterTable.realUrl],
downloaded = dbChapter[ChapterTable.isDownloaded],
pageCount = dbChapter[ChapterTable.pageCount],
chapterCount = chapterList.size,
meta = chapterMetas.getValue(dbChapter[ChapterTable.id])
)
}
return chapterList
}
fun modifyChapter(
@@ -301,6 +307,12 @@ object Chapter {
val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first()[ChapterTable.id].value
modifyChapterMeta(chapterId, key, value)
}
}
fun modifyChapterMeta(chapterId: Int, key: String, value: String) {
transaction {
val meta =
ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
.firstOrNull()

View File

@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl
* 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.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
@@ -22,13 +23,14 @@ object Library {
val manga = getManga(mangaId)
if (!manga.inLibrary) {
transaction {
val defaultCategories = CategoryTable.select { CategoryTable.isDefault eq true }.toList()
val defaultCategories = CategoryTable.select {
(CategoryTable.isDefault eq true) and (CategoryTable.id neq Category.DEFAULT_CATEGORY_ID)
}.toList()
val existingCategories = CategoryMangaTable.select { CategoryMangaTable.manga eq mangaId }.toList()
MangaTable.update({ MangaTable.id eq manga.id }) {
it[inLibrary] = true
it[inLibraryAt] = Instant.now().epochSecond
it[defaultCategory] = defaultCategories.isEmpty() && existingCategories.isEmpty()
}
if (existingCategories.isEmpty()) {

View File

@@ -60,49 +60,10 @@ object Manga {
suspend fun getManga(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
var mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
return if (mangaEntry[MangaTable.initialized] && !onlineFetch) {
return if (!onlineFetch && mangaEntry[MangaTable.initialized]) {
getMangaDataClass(mangaId, mangaEntry)
} else { // initialize manga
val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference])
?: return getMangaDataClass(mangaId, mangaEntry)
val sManga = SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
}
val networkManga = source.fetchMangaDetails(sManga).awaitSingle()
sManga.copyFrom(networkManga)
transaction {
MangaTable.update({ MangaTable.id eq mangaId }) {
if (sManga.title != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaId, sManga.title)
if (canUpdateTitle) {
it[MangaTable.title] = sManga.title
}
}
it[MangaTable.initialized] = true
it[MangaTable.artist] = sManga.artist
it[MangaTable.author] = sManga.author
it[MangaTable.description] = truncate(sManga.description, 4096)
it[MangaTable.genre] = sManga.genre
it[MangaTable.status] = sManga.status
if (!sManga.thumbnail_url.isNullOrEmpty() && sManga.thumbnail_url != mangaEntry[MangaTable.thumbnail_url]) {
it[MangaTable.thumbnail_url] = sManga.thumbnail_url
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
clearMangaThumbnailCache(mangaId)
}
it[MangaTable.realUrl] = runCatching {
(source as? HttpSource)?.getMangaUrl(sManga)
}.getOrNull()
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
it[MangaTable.updateStrategy] = sManga.update_strategy.name
}
}
val sManga = fetchManga(mangaId) ?: return getMangaDataClass(mangaId, mangaEntry)
mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
@@ -135,6 +96,53 @@ object Manga {
}
}
suspend fun fetchManga(mangaId: Int): SManga? {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference])
?: return null
val sManga = SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
}
val networkManga = source.fetchMangaDetails(sManga).awaitSingle()
sManga.copyFrom(networkManga)
transaction {
MangaTable.update({ MangaTable.id eq mangaId }) {
if (sManga.title != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaId, sManga.title)
if (canUpdateTitle) {
it[MangaTable.title] = sManga.title
}
}
it[MangaTable.initialized] = true
it[MangaTable.artist] = sManga.artist
it[MangaTable.author] = sManga.author
it[MangaTable.description] = truncate(sManga.description, 4096)
it[MangaTable.genre] = sManga.genre
it[MangaTable.status] = sManga.status
if (!sManga.thumbnail_url.isNullOrEmpty() && sManga.thumbnail_url != mangaEntry[MangaTable.thumbnail_url]) {
it[MangaTable.thumbnail_url] = sManga.thumbnail_url
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
clearMangaThumbnailCache(mangaId)
}
it[MangaTable.realUrl] = runCatching {
(source as? HttpSource)?.getMangaUrl(sManga)
}.getOrNull()
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
it[MangaTable.updateStrategy] = sManga.update_strategy.name
}
}
return sManga
}
suspend fun getMangaFull(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
val mangaDaaClass = getManga(mangaId, onlineFetch)

View File

@@ -44,6 +44,34 @@ object MangaList {
return mangasPage.processEntries(sourceId)
}
fun MangasPage.insertOrGet(sourceId: Long): List<Int> {
return transaction {
mangas.map { manga ->
val mangaEntry = MangaTable.select {
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
}.firstOrNull()
if (mangaEntry == null) { // create manga entry
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[updateStrategy] = manga.update_strategy.name
it[sourceReference] = sourceId
}.value
} else {
mangaEntry[MangaTable.id].value
}
}
}
}
fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
val mangasPage = this
val mangaList = transaction {

View File

@@ -125,7 +125,7 @@ object Search {
return filterList
}
private fun buildFilterList(sourceId: Long, changes: List<FilterChange>): FilterList {
fun buildFilterList(sourceId: Long, changes: List<FilterChange>): FilterList {
val source = getCatalogueSourceOrStub(sourceId)
val filterList = source.getFilterList()
return updateFilterList(filterList, changes)

View File

@@ -3,21 +3,21 @@ package suwayomi.tachidesk.manga.impl.download
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.apache.commons.compress.archivers.zip.ZipFile
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class ArchiveProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(mangaId, chapterId) {
override fun getImage(index: Int): Pair<InputStream, String> {
val cbzPath = getChapterCbzPath(mangaId, chapterId)
val zipFile = ZipFile(cbzPath)
val zipEntry = zipFile.entries().toList().sortedWith(compareBy({ it.name }, { it.name }))[index]
val zipEntry = zipFile.entries.toList().sortedWith(compareBy({ it.name }, { it.name }))[index]
val inputStream = zipFile.getInputStream(zipEntry)
val fileType = zipEntry.name.substringAfterLast(".")
return Pair(inputStream.buffered(), "image/$fileType")
@@ -33,23 +33,23 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(ma
val chapterFolder = File(chapterDir)
if (outputFile.exists()) handleExistingCbzFile(outputFile, chapterFolder)
FolderProvider(mangaId, chapterId).download(download, scope, step)
withContext(Dispatchers.IO) {
outputFile.createNewFile()
}
FolderProvider(mangaId, chapterId).download(download, scope, step)
ZipOutputStream(outputFile.outputStream()).use { zipOut ->
ZipArchiveOutputStream(outputFile.outputStream()).use { zipOut ->
if (chapterFolder.isDirectory) {
chapterFolder.listFiles()?.sortedBy { it.name }?.forEach {
val entry = ZipEntry(it.name)
val entry = ZipArchiveEntry(it.name)
try {
zipOut.putNextEntry(entry)
zipOut.putArchiveEntry(entry)
it.inputStream().use { inputStream ->
inputStream.copyTo(zipOut)
}
} finally {
zipOut.closeEntry()
zipOut.closeArchiveEntry()
}
}
}
@@ -70,7 +70,7 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(ma
private fun handleExistingCbzFile(cbzFile: File, chapterFolder: File) {
if (!chapterFolder.exists()) chapterFolder.mkdirs()
ZipInputStream(cbzFile.inputStream()).use { zipInputStream ->
ZipArchiveInputStream(cbzFile.inputStream()).use { zipInputStream ->
var zipEntry = zipInputStream.nextEntry
while (zipEntry != null) {
val file = File(chapterFolder, zipEntry.name)

View File

@@ -24,6 +24,7 @@ import mu.KotlinLogging
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.subscriptions.downloadSubscriptionSource
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading
import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus
@@ -100,6 +101,9 @@ object DownloadManager {
notifyFlow.emit(Unit)
}
}
/*if (downloadChapter != null) { TODO GRAPHQL
downloadSubscriptionSource.publish(downloadChapter)
}*/
}
private fun getStatus(): DownloadStatus {
@@ -234,6 +238,7 @@ object DownloadManager {
manga
)
downloadQueue.add(downloadChapter)
downloadSubscriptionSource.publish(downloadChapter)
logger.debug { "Added chapter ${chapter.id} to download queue (${manga.title} | ${chapter.name})" }
return downloadChapter
}

View File

@@ -24,7 +24,7 @@ class FolderProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(man
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
val folder = File(chapterDir)
folder.mkdirs()
val file = folder.listFiles()?.get(index)
val file = folder.listFiles()?.sortedBy { it.name }?.get(index)
val fileType = file!!.name.substringAfterLast(".")
return Pair(FileInputStream(file).buffered(), "image/$fileType")
}

View File

@@ -30,7 +30,7 @@ object ExtensionsList {
var lastUpdateCheck: Long = 0
var updateMap = ConcurrentHashMap<String, OnlineExtension>()
suspend fun getExtensionList(): List<ExtensionDataClass> {
suspend fun fetchExtensions() {
// update if 60 seconds has passed or requested offline and database is empty
if (lastUpdateCheck + 60.seconds.inWholeMilliseconds < System.currentTimeMillis()) {
logger.debug("Getting extensions list from the internet")
@@ -41,7 +41,10 @@ object ExtensionsList {
} else {
logger.debug("used cached extension list")
}
}
suspend fun getExtensionList(): List<ExtensionDataClass> {
fetchExtensions()
return extensionTableAsDataClass()
}

View File

@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.StateFlow
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
interface IUpdater {
fun addMangaToQueue(manga: MangaDataClass)
fun addMangasToQueue(mangas: List<MangaDataClass>)
val status: StateFlow<UpdateStatus>
fun reset()
}

View File

@@ -14,9 +14,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import mu.KotlinLogging
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.server.serverConfig
import java.util.concurrent.ConcurrentHashMap
class Updater : IUpdater {
@@ -27,18 +30,29 @@ class Updater : IUpdater {
override val status = _status.asStateFlow()
private val tracker = ConcurrentHashMap<Int, UpdateJob>()
private var updateChannel = createUpdateChannel()
private val updateChannels = ConcurrentHashMap<String, Channel<UpdateJob>>()
private val semaphore = Semaphore(serverConfig.maxParallelUpdateRequests)
private fun getOrCreateUpdateChannelFor(source: String): Channel<UpdateJob> {
return updateChannels.getOrPut(source) {
logger.debug { "getOrCreateUpdateChannelFor: created channel for $source - channels: ${updateChannels.size + 1}" }
createUpdateChannel()
}
}
private fun createUpdateChannel(): Channel<UpdateJob> {
val channel = Channel<UpdateJob>(Channel.UNLIMITED)
channel.consumeAsFlow()
.onEach { job ->
_status.value = UpdateStatus(
process(job),
tracker.any { (_, job) ->
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
}
)
semaphore.withPermit {
_status.value = UpdateStatus(
process(job),
tracker.any { (_, job) ->
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
}
)
}
}
.catch { logger.error(it) { "Error during updates" } }
.launchIn(scope)
@@ -49,7 +63,7 @@ class Updater : IUpdater {
tracker[job.manga.id] = job.copy(status = JobStatus.RUNNING)
_status.update { UpdateStatus(tracker.values.toList(), true) }
tracker[job.manga.id] = try {
logger.info { "Updating ${job.manga.title}" }
logger.info { "Updating \"${job.manga.title}\" (source: ${job.manga.sourceId})" }
Chapter.getChapterList(job.manga.id, true)
job.copy(status = JobStatus.COMPLETE)
} catch (e: Exception) {
@@ -60,19 +74,24 @@ class Updater : IUpdater {
return tracker.values.toList()
}
override fun addMangaToQueue(manga: MangaDataClass) {
override fun addMangasToQueue(mangas: List<MangaDataClass>) {
mangas.forEach { tracker[it.id] = UpdateJob(it) }
_status.update { UpdateStatus(tracker.values.toList(), mangas.isNotEmpty()) }
mangas.forEach { addMangaToQueue(it) }
}
private fun addMangaToQueue(manga: MangaDataClass) {
val updateChannel = getOrCreateUpdateChannelFor(manga.sourceId)
scope.launch {
updateChannel.send(UpdateJob(manga))
}
tracker[manga.id] = UpdateJob(manga)
_status.update { UpdateStatus(tracker.values.toList(), true) }
}
override fun reset() {
scope.coroutineContext.cancelChildren()
tracker.clear()
_status.update { UpdateStatus() }
updateChannel.cancel()
updateChannel = createUpdateChannel()
updateChannels.forEach { (_, channel) -> channel.cancel() }
updateChannels.clear()
}
}

View File

@@ -9,6 +9,6 @@ package suwayomi.tachidesk.manga.impl.util.lang
import org.jetbrains.exposed.sql.Query
fun Query.isEmpty() = this.count() == 0L
fun Query.isEmpty() = this.empty()
fun Query.isNotEmpty() = !this.isEmpty()

View File

@@ -1,5 +1,7 @@
package suwayomi.tachidesk.manga.model.dataclass
import com.fasterxml.jackson.annotation.JsonValue
/*
* Copyright (C) Contributors to the Suwayomi project
*
@@ -7,10 +9,20 @@ package suwayomi.tachidesk.manga.model.dataclass
* 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/. */
enum class IncludeInUpdate(@JsonValue val value: Int) {
EXCLUDE(0), INCLUDE(1), UNSET(-1);
companion object {
fun fromValue(value: Int) = IncludeInUpdate.values().find { it.value == value } ?: UNSET
}
}
data class CategoryDataClass(
val id: Int,
val order: Int,
val name: String,
val default: Boolean,
val size: Int,
val includeInUpdate: IncludeInUpdate,
val meta: Map<String, String> = emptyMap()
)

View File

@@ -45,6 +45,7 @@ data class MangaDataClass(
var unreadCount: Long? = null,
var downloadCount: Long? = null,
var chapterCount: Long? = null,
var lastReadAt: Long? = null,
var lastChapterRead: ChapterDataClass? = null,
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),

Some files were not shown because too many files have changed in this diff Show More