diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 57f3ffbb0..65591dc39 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -64,6 +64,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) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/GraphQL.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/GraphQL.kt new file mode 100644 index 000000000..d7343cd0e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/GraphQL.kt @@ -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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/controller/GraphQLController.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/controller/GraphQLController.kt new file mode 100644 index 000000000..3c0c4e037 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/controller/GraphQLController.kt @@ -0,0 +1,42 @@ +/* + * 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.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) { + val body = javaClass.getResourceAsStream("/graphql-playground.html")!!.bufferedReader().use { reader -> + reader.readText() + } + ctx.html(body) + } + + fun webSocket(ws: WsConfig) { + ws.onMessage { ctx -> + server.handleSubscriptionMessage(ctx) + } + ws.onClose { ctx -> + server.handleSubscriptionDisconnect(ctx) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt new file mode 100644 index 000000000..a4c5df839 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt @@ -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 { + override val dataLoaderName = "CategoryDataLoader" + override fun getDataLoader(): DataLoader = 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 { + override val dataLoaderName = "CategoriesForMangaDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { 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() } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt new file mode 100644 index 000000000..89a2af0a2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt @@ -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 { + override val dataLoaderName = "ChapterDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { 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 { + override val dataLoaderName = "ChaptersForMangaDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { 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() } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ExtensionDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ExtensionDataLoader.kt new file mode 100644 index 000000000..7e8c0763c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ExtensionDataLoader.kt @@ -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 { + override val dataLoaderName = "ExtensionDataLoader" + override fun getDataLoader(): DataLoader = 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 { + override val dataLoaderName = "ExtensionForSourceDataLoader" + override fun getDataLoader(): DataLoader = 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] } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt new file mode 100644 index 000000000..8788739d4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt @@ -0,0 +1,53 @@ +/* + * 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.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 { + override val dataLoaderName = "MangaDataLoader" + override fun getDataLoader(): DataLoader = 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 { + override val dataLoaderName = "MangaForCategoryDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val itemsByRef = 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() } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt new file mode 100644 index 000000000..6a7aca0a2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt @@ -0,0 +1,80 @@ +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.CategoryMetaItem +import suwayomi.tachidesk.graphql.types.ChapterMetaItem +import suwayomi.tachidesk.graphql.types.GlobalMetaItem +import suwayomi.tachidesk.graphql.types.MangaMetaItem +import suwayomi.tachidesk.graphql.types.MetaItem +import suwayomi.tachidesk.graphql.types.MetaNodeList +import suwayomi.tachidesk.graphql.types.MetaNodeList.Companion.toNodeList +import suwayomi.tachidesk.manga.model.table.ChapterMetaTable +import suwayomi.tachidesk.manga.model.table.MangaMetaTable +import suwayomi.tachidesk.server.JavalinSetup.future + +class GlobalMetaDataLoader : KotlinDataLoader { + override val dataLoaderName = "GlobalMetaDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val metasByRefId = GlobalMetaTable.select { GlobalMetaTable.key inList ids } + .map { GlobalMetaItem(it) } + .associateBy { it.key } + ids.map { metasByRefId[it] } + } + } + } +} + +class ChapterMetaDataLoader : KotlinDataLoader { + override val dataLoaderName = "ChapterMetaDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val metasByRefId = ChapterMetaTable.select { ChapterMetaTable.ref inList ids } + .map { ChapterMetaItem(it) } + .groupBy { it.ref } + ids.map { (metasByRefId[it] ?: emptyList()).toNodeList() } + } + } + } +} + +class MangaMetaDataLoader : KotlinDataLoader { + override val dataLoaderName = "MangaMetaDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val metasByRefId = MangaMetaTable.select { MangaMetaTable.ref inList ids } + .map { MangaMetaItem(it) } + .groupBy { it.ref } + ids.map { (metasByRefId[it] ?: emptyList()).toNodeList() } + } + } + } +} + +class CategoryMetaDataLoader : KotlinDataLoader { + override val dataLoaderName = "CategoryMetaDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val metasByRefId = MangaMetaTable.select { MangaMetaTable.ref inList ids } + .map { CategoryMetaItem(it) } + .groupBy { it.ref } + ids.map { (metasByRefId[it] ?: emptyList()).toNodeList() } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/SourceDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/SourceDataLoader.kt new file mode 100644 index 000000000..18eacbcf5 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/SourceDataLoader.kt @@ -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.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.MangaTable +import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.server.JavalinSetup.future + +class SourceDataLoader : KotlinDataLoader { + override val dataLoaderName = "SourceDataLoader" + override fun getDataLoader(): DataLoader = 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 SourceForMangaDataLoader : KotlinDataLoader { + override val dataLoaderName = "SourceForMangaDataLoader" + override fun getDataLoader(): DataLoader = DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + + val itemsByRef = MangaTable.innerJoin(SourceTable) + .select { MangaTable.id inList ids } + .map { Triple(it[MangaTable.id].value, it[MangaTable.sourceReference], it) } + .let { triples -> + val sources = buildMap { + triples.forEach { + if (!containsKey(it.second)) { + put(it.second, SourceType(it.third)) + } + } + } + triples.associate { + it.first to sources[it.second] + } + } + + ids.map { itemsByRef[it] } + } + } + } +} + +class SourcesForExtensionDataLoader : KotlinDataLoader { + override val dataLoaderName = "SourcesForExtensionDataLoader" + override fun getDataLoader(): DataLoader = 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() } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt new file mode 100644 index 000000000..808f41881 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt @@ -0,0 +1,91 @@ +package suwayomi.tachidesk.graphql.mutations + +import com.expediagroup.graphql.server.extensions.getValueFromDataLoader +import com.expediagroup.graphql.server.extensions.getValuesFromDataLoader +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update +import suwayomi.tachidesk.graphql.types.ChapterType +import suwayomi.tachidesk.manga.model.table.ChapterTable +import java.time.Instant +import java.util.concurrent.CompletableFuture + +/** + * TODO Mutations + * - Check for updates? + * - 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 + ) + data class UpdateChaptersInput( + val clientMutationId: String? = null, + val ids: List, + val patch: UpdateChapterPatch + ) + + private fun updateChapters(ids: List, 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(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateChapterInput): CompletableFuture { + val (clientMutationId, id, patch) = input + + updateChapters(listOf(id), patch) + + return dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id).thenApply { chapter -> + UpdateChapterPayload( + clientMutationId = clientMutationId, + chapter = chapter + ) + } + } + + fun updateChapters(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateChaptersInput): CompletableFuture { + val (clientMutationId, ids, patch) = input + + updateChapters(ids, patch) + + return dataFetchingEnvironment.getValuesFromDataLoader("ChapterDataLoader", ids).thenApply { chapters -> + UpdateChaptersPayload( + clientMutationId = clientMutationId, + chapters = chapters + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt new file mode 100644 index 000000000..86612e149 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt @@ -0,0 +1,84 @@ +package suwayomi.tachidesk.graphql.mutations + +import com.expediagroup.graphql.server.extensions.getValueFromDataLoader +import com.expediagroup.graphql.server.extensions.getValuesFromDataLoader +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update +import suwayomi.tachidesk.graphql.types.MangaType +import suwayomi.tachidesk.manga.model.table.MangaTable +import java.util.concurrent.CompletableFuture + +/** + * TODO Mutations + * - Add to category + * - Remove from category + * - Check for updates + * - Download x(all = -1) chapters + * - Delete read/all downloaded chapters + * - Add/update meta + * - Delete meta + */ +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 + ) + data class UpdateMangasInput( + val clientMutationId: String?? = null, + val ids: List, + val patch: UpdateMangaPatch + ) + + private fun updateMangas(ids: List, patch: UpdateMangaPatch) { + transaction { + if (patch.inLibrary != null) { + MangaTable.update({ MangaTable.id inList ids }) { update -> + patch.inLibrary.also { + update[inLibrary] = it + } + } + } + } + } + + fun updateManga(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateMangaInput): CompletableFuture { + val (clientMutationId, id, patch) = input + + updateMangas(listOf(id), patch) + + return dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", id).thenApply { manga -> + UpdateMangaPayload( + clientMutationId = clientMutationId, + manga = manga + ) + } + } + + fun updateMangas(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateMangasInput): CompletableFuture { + val (clientMutationId, ids, patch) = input + + updateMangas(ids, patch) + + return dataFetchingEnvironment.getValuesFromDataLoader("MangaDataLoader", ids).thenApply { mangas -> + UpdateMangasPayload( + clientMutationId = clientMutationId, + mangas = mangas + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt new file mode 100644 index 000000000..94d5cfd78 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt @@ -0,0 +1,212 @@ +/* + * 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 + +/** + * TODO Queries + * + * TODO Mutations + * - Name + * - Order + * - Default + * - Create + * - Delete + * - Add/update meta + * - Delete meta + */ +class CategoryQuery { + fun category(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("CategoryDataLoader", id) + } + + enum class CategoryOrderBy(override val column: Column>) : OrderBy { + ID(CategoryTable.id), + NAME(CategoryTable.name), + ORDER(CategoryTable.order); + + override fun greater(cursor: Cursor): Op { + 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 { + 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? { + 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? = null, + override val or: List? = null, + override val not: CategoryFilter? = null + ) : Filter { + override fun getOpList(): List> { + 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() + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt new file mode 100644 index 000000000..f3aedcfd5 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt @@ -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 { + return dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id) + } + + enum class ChapterOrderBy(override val column: Column>) : OrderBy { + 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 { + 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 { + 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? { + 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? = null, + override val or: List? = null, + override val not: ChapterFilter? = null + ) : Filter { + override fun getOpList(): List> { + 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() + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt new file mode 100644 index 000000000..4a87a983f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt @@ -0,0 +1,235 @@ +/* + * 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.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 + +/** + * TODO Queries + * + * TODO Mutations + * - Install + * - Update + * - Uninstall + * - Check for updates (global mutation?) + */ +class ExtensionQuery { + fun extension(dataFetchingEnvironment: DataFetchingEnvironment, pkgName: String): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("ExtensionDataLoader", pkgName) + } + + enum class ExtensionOrderBy(override val column: Column>) : OrderBy { + PKG_NAME(ExtensionTable.pkgName), + NAME(ExtensionTable.name), + APK_NAME(ExtensionTable.apkName); + + override fun greater(cursor: Cursor): Op { + 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 { + 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? { + 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? = null, + override val or: List? = null, + override val not: ExtensionFilter? = null + ) : Filter { + override fun getOpList(): List> { + 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.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() + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt new file mode 100644 index 000000000..b8bf61405 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt @@ -0,0 +1,297 @@ +/* + * 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.CategoryMangaTable +import suwayomi.tachidesk.manga.model.table.MangaStatus +import suwayomi.tachidesk.manga.model.table.MangaTable +import java.util.concurrent.CompletableFuture + +/** + * TODO Queries + */ +class MangaQuery { + fun manga(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", id) + } + + enum class MangaOrderBy(override val column: Column>) : OrderBy { + ID(MangaTable.id), + TITLE(MangaTable.title), + IN_LIBRARY_AT(MangaTable.inLibraryAt), + LAST_FETCHED_AT(MangaTable.lastFetchedAt); + + override fun greater(cursor: Cursor): Op { + 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 { + 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? = 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? { + 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? = null, + override val notIn: List? = null, + override val lessThan: MangaStatus? = null, + override val lessThanOrEqualTo: MangaStatus? = null, + override val greaterThan: MangaStatus? = null, + override val greaterThanOrEqualTo: MangaStatus? = null + ) : ComparableScalarFilter { + 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? = 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, + val category: IntFilter? = null, + override val and: List? = null, + override val or: List? = null, + override val not: MangaFilter? = null + ) : Filter { + override fun getOpList(): List> { + 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 getCategoryOp() = andFilterWithCompareEntity(CategoryMangaTable.category, category) + } + + 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() + + val categoryOp = filter?.getCategoryOp() + if (categoryOp != null) { + res.adjustColumnSet { + innerJoin(CategoryMangaTable) + } + res.andWhere { categoryOp } + } + + 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() + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt new file mode 100644 index 000000000..f934ce2eb --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt @@ -0,0 +1,193 @@ +/* + * 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.GlobalMetaItem +import suwayomi.tachidesk.graphql.types.MetaItem +import suwayomi.tachidesk.graphql.types.MetaNodeList +import java.util.concurrent.CompletableFuture + +/** + * TODO Queries + * + * TODO Mutations + * - Add/update meta + * - Delete meta + * + */ +class MetaQuery { + fun meta(dataFetchingEnvironment: DataFetchingEnvironment, key: String): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("GlobalMetaDataLoader", key) + } + + enum class MetaOrderBy(override val column: Column>) : OrderBy { + KEY(GlobalMetaTable.key), + VALUE(GlobalMetaTable.value); + + override fun greater(cursor: Cursor): Op { + return when (this) { + KEY -> GlobalMetaTable.key greater cursor.value + VALUE -> greaterNotUnique(GlobalMetaTable.value, GlobalMetaTable.key, cursor, String::toString) + } + } + + override fun less(cursor: Cursor): Op { + return when (this) { + KEY -> GlobalMetaTable.key less cursor.value + VALUE -> lessNotUnique(GlobalMetaTable.value, GlobalMetaTable.key, cursor, String::toString) + } + } + + override fun asCursor(type: MetaItem): 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? { + 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? = null, + override val or: List? = null, + override val not: MetaFilter? = null + ) : Filter { + override fun getOpList(): List> { + 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 + ): MetaNodeList { + 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: (MetaItem) -> Cursor = (orderBy ?: MetaOrderBy.KEY)::asCursor + + val resultsAsType = queryResults.results.map { GlobalMetaItem(it) } + + return MetaNodeList( + resultsAsType, + if (resultsAsType.isEmpty()) { + emptyList() + } else { + listOfNotNull( + resultsAsType.firstOrNull()?.let { + MetaNodeList.MetaEdge( + getAsCursor(it), + it + ) + }, + resultsAsType.lastOrNull()?.let { + MetaNodeList.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() + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt new file mode 100644 index 000000000..841143897 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt @@ -0,0 +1,208 @@ +/* + * 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 + +/** + * TODO Queries + * + * TODO Mutations + * - Browse with filters + * - Configure settings + * + */ +class SourceQuery { + fun source(dataFetchingEnvironment: DataFetchingEnvironment, id: Long): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("SourceDataLoader", id) + } + + enum class SourceOrderBy(override val column: Column>) : OrderBy { + ID(SourceTable.id), + NAME(SourceTable.name), + LANG(SourceTable.lang); + + override fun greater(cursor: Cursor): Op { + 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 { + 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? { + 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? = null, + override val or: List? = null, + override val not: SourceFilter? = null + ) : Filter { + override fun getOpList(): List> { + 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() + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt new file mode 100644 index 000000000..964583939 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt @@ -0,0 +1,379 @@ +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 iLike(expression: Expression, pattern: String): ILikeEscapeOp = iLike(expression, LikePattern(pattern)) + fun iNotLike(expression: Expression, pattern: String): ILikeEscapeOp = iNotLike(expression, LikePattern(pattern)) + fun iLike(expression: Expression, pattern: LikePattern): ILikeEscapeOp = ILikeEscapeOp(expression, stringParam(pattern.pattern), true, pattern.escapeChar) + fun iNotLike(expression: Expression, 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 distinctFrom(expression: ExpressionWithColumnType, t: T): DistinctFromOp = DistinctFromOp( + expression, + with(SqlExpressionBuilder) { + expression.wrap(t) + }, + false + ) + fun notDistinctFrom(expression: ExpressionWithColumnType, t: T): DistinctFromOp = DistinctFromOp( + expression, + with(SqlExpressionBuilder) { + expression.wrap(t) + }, + true + ) + fun > distinctFrom(expression: ExpressionWithColumnType>, t: T): DistinctFromOp = DistinctFromOp( + expression, + with(SqlExpressionBuilder) { + expression.wrap(t) + }, + false + ) + fun > notDistinctFrom(expression: ExpressionWithColumnType>, t: T): DistinctFromOp = DistinctFromOp( + expression, + with(SqlExpressionBuilder) { + expression.wrap(t) + }, + true + ) + } +} + +interface HasGetOp { + fun getOp(): Op? +} + +fun Query.applyOps(vararg ops: HasGetOp?) { + ops.mapNotNull { it?.getOp() }.forEach { + andWhere { it } + } +} + +interface Filter> : HasGetOp { + val and: List? + val or: List? + val not: T? + + fun getOpList(): List> + + override fun getOp(): Op? { + var op: Op? = null + fun newOp( + otherOp: Op?, + operator: (Op, Op) -> Op + ) { + 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?) { + newOp(andOp, Op::and) + } + fun orOp(orOp: Op?) { + newOp(orOp, Op::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 { + val isNull: Boolean? + val equalTo: T? + val notEqualTo: T? + val distinctFrom: T? + val notDistinctFrom: T? + val `in`: List? + val notIn: List? +} + +interface ComparableScalarFilter> : ScalarFilter { + val lessThan: T? + val lessThanOrEqualTo: T? + val greaterThan: T? + val greaterThanOrEqualTo: T? +} + +interface ListScalarFilter> : ScalarFilter { + val hasAny: List? + val hasAll: List? + val hasNone: List? +} + +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? = null, + override val notIn: List? = null, + override val lessThan: Long? = null, + override val lessThanOrEqualTo: Long? = null, + override val greaterThan: Long? = null, + override val greaterThanOrEqualTo: Long? = null +) : ComparableScalarFilter + +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? = null, + override val notIn: List? = null, + override val lessThan: Boolean? = null, + override val lessThanOrEqualTo: Boolean? = null, + override val greaterThan: Boolean? = null, + override val greaterThanOrEqualTo: Boolean? = null +) : ComparableScalarFilter + +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? = null, + override val notIn: List? = null, + override val lessThan: Int? = null, + override val lessThanOrEqualTo: Int? = null, + override val greaterThan: Int? = null, + override val greaterThanOrEqualTo: Int? = null +) : ComparableScalarFilter + +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? = null, + override val notIn: List? = null, + override val lessThan: Float? = null, + override val lessThanOrEqualTo: Float? = null, + override val greaterThan: Float? = null, + override val greaterThanOrEqualTo: Float? = null +) : ComparableScalarFilter + +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? = null, + override val notIn: List? = 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? = null, + val notInInsensitive: List? = null, + val lessThanInsensitive: String? = null, + val lessThanOrEqualToInsensitive: String? = null, + val greaterThanInsensitive: String? = null, + val greaterThanOrEqualToInsensitive: String? = null +) : ComparableScalarFilter + +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? = null, + override val notIn: List? = null, + override val hasAny: List? = null, + override val hasAll: List? = null, + override val hasNone: List? = null, + val hasAnyInsensitive: List? = null, + val hasAllInsensitive: List? = null, + val hasNoneInsensitive: List? = null +) : ListScalarFilter> + +@Suppress("UNCHECKED_CAST") +fun andFilterWithCompareString( + column: Column, + filter: StringFilter? +): Op? { + filter ?: return null + val opAnd = OpAnd() + 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 T) } + opAnd.andWhere(filter.notDistinctFromInsensitive) { DistinctFromOp.notDistinctFrom(column.upperCase(), it.uppercase() as T) } + + opAnd.andWhere(filter.inInsensitive) { column.upperCase() inList (it.map { it.uppercase() } as List) } + opAnd.andWhere(filter.notInInsensitive) { column.upperCase() notInList (it.map { it.uppercase() } as List) } + + 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? = null) { + fun andWhere(value: T?, andPart: SqlExpressionBuilder.(T) -> Op) { + value ?: return + val expr = Op.build { andPart(value) } + op = if (op == null) expr else (op!! and expr) + } + + fun eq(value: T?, column: Column) = andWhere(value) { column eq it } + fun > eq(value: T?, column: Column>) = andWhere(value) { column eq it } +} + +fun > andFilterWithCompare( + column: Column, + filter: ComparableScalarFilter? +): Op? { + 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 > andFilterWithCompareEntity( + column: Column>, + filter: ComparableScalarFilter? +): Op? { + 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 > andFilter( + column: Column, + filter: ScalarFilter? +): Op? { + 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 > andFilterEntity( + column: Column>, + filter: ScalarFilter? +): Op? { + 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 +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/JavalinGraphQLRequestParser.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/JavalinGraphQLRequestParser.kt new file mode 100644 index 000000000..efd5daad3 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/JavalinGraphQLRequestParser.kt @@ -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 { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun parseRequest(context: Context): GraphQLServerRequest? = try { + context.bodyAsClass(GraphQLServerRequest::class.java) + } catch (e: IOException) { + null + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt new file mode 100644 index 000000000..b180e9f8d --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt @@ -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.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.SourceForMangaDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.SourcesForExtensionDataLoader + +class TachideskDataLoaderRegistryFactory { + companion object { + fun create(): KotlinDataLoaderRegistryFactory { + return KotlinDataLoaderRegistryFactory( + MangaDataLoader(), + ChapterDataLoader(), + ChaptersForMangaDataLoader(), + GlobalMetaDataLoader(), + ChapterMetaDataLoader(), + MangaMetaDataLoader(), + MangaForCategoryDataLoader(), + CategoryMetaDataLoader(), + CategoriesForMangaDataLoader(), + SourceDataLoader(), + SourceForMangaDataLoader(), + SourcesForExtensionDataLoader(), + ExtensionDataLoader(), + ExtensionForSourceDataLoader() + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt new file mode 100644 index 000000000..34147ae8d --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt @@ -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 { + override suspend fun generateContextMap(request: Context): Map<*, Any> = emptyMap() +// mutableMapOf( +// "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() +} + +/** + * Create a [GraphQLContext] from [this] map + * @return a new [GraphQLContext] + */ +fun Map<*, Any?>.toGraphQLContext(): graphql.GraphQLContext = + graphql.GraphQLContext.of(this) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt new file mode 100644 index 000000000..b8e1cb3eb --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt @@ -0,0 +1,59 @@ +/* + * 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.schema.GraphQLType +import suwayomi.tachidesk.graphql.mutations.ChapterMutation +import suwayomi.tachidesk.graphql.mutations.MangaMutation +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.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 + else -> super.willGenerateGraphQLType(type) + } +} + +val schema = toSchema( + config = SchemaGeneratorConfig( + supportedPackages = listOf("suwayomi.tachidesk.graphql"), + introspectionEnabled = true, + hooks = CustomSchemaGeneratorHooks() + ), + queries = listOf( + TopLevelObject(MangaQuery()), + TopLevelObject(ChapterQuery()), + TopLevelObject(CategoryQuery()), + TopLevelObject(SourceQuery()), + TopLevelObject(ExtensionQuery()), + TopLevelObject(MetaQuery()) + ), + mutations = listOf( + TopLevelObject(ChapterMutation()), + TopLevelObject(MangaMutation()) + ), + subscriptions = listOf( + TopLevelObject(DownloadSubscription()) + ) +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt new file mode 100644 index 000000000..47d3e29eb --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt @@ -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(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) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/Cursor.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/Cursor.kt new file mode 100644 index 000000000..eb07e4705 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/Cursor.kt @@ -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 { + 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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/LongAsString.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/LongAsString.kt new file mode 100644 index 000000000..e5157ee62 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/LongAsString.kt @@ -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 { + 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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/NodeList.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/NodeList.kt new file mode 100644 index 000000000..ae4ac253a --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/NodeList.kt @@ -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 + + @GraphQLDescription("A list of edges which contains the [T] and cursor to aid in pagination.") + abstract val edges: List + + @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 +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/OrderBy.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/OrderBy.kt new file mode 100644 index 000000000..4cf89215f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/OrderBy.kt @@ -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 { + val column: Column> + + fun asCursor(type: T): Cursor + + fun greater(cursor: Cursor): Op + + fun less(cursor: Cursor): Op +} + +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 > greaterNotUnique( + column: Column, + idColumn: Column>, + cursor: Cursor, + toValue: (String) -> T +): Op { + return greaterNotUniqueImpl(column, idColumn, cursor, String::toInt, toValue) +} + +@JvmName("greaterNotUniqueLongKey") +fun > greaterNotUnique( + column: Column, + idColumn: Column>, + cursor: Cursor, + toValue: (String) -> T +): Op { + return greaterNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue) +} + +private fun , V : Comparable> greaterNotUniqueImpl( + column: Column, + idColumn: Column>, + cursor: Cursor, + toKey: (String) -> K, + toValue: (String) -> V +): Op { + 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 > greaterNotUnique( + column: Column, + idColumn: Column, + cursor: Cursor, + toValue: (String) -> T +): Op { + 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 > lessNotUnique( + column: Column, + idColumn: Column>, + cursor: Cursor, + toValue: (String) -> T +): Op { + return lessNotUniqueImpl(column, idColumn, cursor, String::toInt, toValue) +} + +@JvmName("lessNotUniqueLongKey") +fun > lessNotUnique( + column: Column, + idColumn: Column>, + cursor: Cursor, + toValue: (String) -> T +): Op { + return lessNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue) +} + +private fun , V : Comparable> lessNotUniqueImpl( + column: Column, + idColumn: Column>, + cursor: Cursor, + toKey: (String) -> K, + toValue: (String) -> V +): Op { + 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 > lessNotUnique( + column: Column, + idColumn: Column, + cursor: Cursor, + toValue: (String) -> T +): Op { + val id = cursor.value.substringBefore("\\-") + val value = toValue(cursor.value.substringAfter("\\-")) + return (column less value) or ((column eq value) and (idColumn less id)) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/QueryResults.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/QueryResults.kt new file mode 100644 index 000000000..a3f604f2a --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/QueryResults.kt @@ -0,0 +1,5 @@ +package suwayomi.tachidesk.graphql.server.primitives + +import org.jetbrains.exposed.sql.ResultRow + +data class QueryResults(val total: Long, val firstKey: T, val lastKey: T, val results: List) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/ApolloSubscriptionProtocolHandler.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/ApolloSubscriptionProtocolHandler.kt new file mode 100644 index 000000000..e8da2c603 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/ApolloSubscriptionProtocolHandler.kt @@ -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 { + 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 { + 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 { + 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(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 { + 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 { + 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 { + return sessionState.stopOperation(context, operationMessage) + } + + private fun onDisconnect(context: WsContext): Flow { + sessionState.terminateSession(context) + return emptyFlow() + } + + private fun onUnknownOperation(operationMessage: SubscriptionOperationMessage, context: WsContext): Flow { + logger.error("Unknown subscription operation $operationMessage") + sessionState.stopOperation(context, operationMessage) + return flowOf(getConnectionErrorMessage(operationMessage)) + } + + private fun onException(exception: Exception): Flow { + 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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/ApolloSubscriptionSessionState.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/ApolloSubscriptionSessionState.kt new file mode 100644 index 000000000..f9ec6b0f2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/ApolloSubscriptionSessionState.kt @@ -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() + + // Operations are saved by web socket session id, then operation id + internal val activeOperations = ConcurrentHashMap>() + + // The graphQL context is saved by web socket session id + private val cachedGraphQLContext = ConcurrentHashMap() + + /** + * 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().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 = 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 { + 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 { + return getCompleteMessage(operationMessage) + .onCompletion { removeActiveOperation(context, operationMessage.id, cancelSubscription = true) } + } + + private fun getCompleteMessage(operationMessage: SubscriptionOperationMessage): Flow { + 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 +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/FlowSubscriptionSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/FlowSubscriptionSource.kt new file mode 100644 index 000000000..16c60863c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/FlowSubscriptionSource.kt @@ -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 { + private val mutableSharedFlow = MutableSharedFlow() + val emitter = mutableSharedFlow.asSharedFlow() + + fun publish(value: T) { + mutableSharedFlow.tryEmit(value) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/GraphQLSubscriptionHandler.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/GraphQLSubscriptionHandler.kt new file mode 100644 index 000000000..a402e6421 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/GraphQLSubscriptionHandler.kt @@ -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()) + ): Flow> { + val dataLoaderRegistry = dataLoaderRegistryFactory?.generate() + val input = graphQLRequest.toExecutionInput(dataLoaderRegistry, graphQLContext) + + val res = graphQL.execute(input) + val data = res.getData>() + val mapped = data.map { result -> result.toGraphQLResponse() } + return mapped.catch { throwable -> + val error = throwable.toGraphQLError() + emit(GraphQLResponse(errors = listOf(error.toGraphQLKotlinType()))) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/SubscriptionOperationMessage.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/SubscriptionOperationMessage.kt new file mode 100644 index 000000000..c118eb7cc --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/subscriptions/SubscriptionOperationMessage.kt @@ -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") + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/subscriptions/DownloadSubscription.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/subscriptions/DownloadSubscription.kt new file mode 100644 index 000000000..deb9bacc1 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/subscriptions/DownloadSubscription.kt @@ -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() + +class DownloadSubscription { + fun downloadChanged(dataFetchingEnvironment: DataFetchingEnvironment): Flow { + return downloadSubscriptionSource.emitter.map { downloadChapter -> + DownloadType(downloadChapter) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/CategoryType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/CategoryType.kt new file mode 100644 index 000000000..b535c6cca --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/CategoryType.kt @@ -0,0 +1,83 @@ +/* + * 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.CategoryTable +import java.util.concurrent.CompletableFuture + +class CategoryType( + val id: Int, + val order: Int, + val name: String, + val default: Boolean +) : Node { + constructor(row: ResultRow) : this( + row[CategoryTable.id].value, + row[CategoryTable.order], + row[CategoryTable.name], + row[CategoryTable.isDefault] + ) + + fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("MangaForCategoryDataLoader", id) + } + + fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("CategoryMetaDataLoader", id) + } +} + +data class CategoryNodeList( + override val nodes: List, + override val edges: List, + 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.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.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + CategoryEdge( + cursor = Cursor("0"), + node = first() + ), + CategoryEdge( + cursor = Cursor(lastIndex.toString()), + node = last() + ) + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt new file mode 100644 index 000000000..321f8cf50 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt @@ -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 { + return dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", mangaId) + } + + fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("ChapterMetaDataLoader", id) + } +} + +data class ChapterNodeList( + override val nodes: List, + override val edges: List, + 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.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.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + ChapterEdge( + cursor = Cursor("0"), + node = first() + ), + ChapterEdge( + cursor = Cursor(lastIndex.toString()), + node = last() + ) + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/DownloadType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/DownloadType.kt new file mode 100644 index 000000000..37d7e8f3f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/DownloadType.kt @@ -0,0 +1,93 @@ +/* + * 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.generator.annotations.GraphQLIgnore +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 suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass +import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass + +class DownloadType( + val chapterId: Int, + val chapterIndex: Int, + val mangaId: Int, + var state: DownloadState = DownloadState.Queued, + var progress: Float = 0f, + var tries: Int = 0, + @GraphQLIgnore + var mangaDataClass: MangaDataClass, + @GraphQLIgnore + var chapterDataClass: ChapterDataClass +) : Node { + constructor(downloadChapter: DownloadChapter) : this( + downloadChapter.chapter.id, + downloadChapter.chapterIndex, + downloadChapter.mangaId, + downloadChapter.state, + downloadChapter.progress, + downloadChapter.tries, + downloadChapter.manga, + downloadChapter.chapter + ) + + fun manga(): MangaType { + return MangaType(mangaDataClass) + } + + fun chapter(): ChapterType { + return ChapterType(chapterDataClass) + } +} + +data class DownloadNodeList( + override val nodes: List, + override val edges: List, + 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.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.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + DownloadEdge( + cursor = Cursor("0"), + node = first() + ), + DownloadEdge( + cursor = Cursor(lastIndex.toString()), + node = last() + ) + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ExtensionType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ExtensionType.kt new file mode 100644 index 000000000..14d36f896 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ExtensionType.kt @@ -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 { + return dataFetchingEnvironment.getValueFromDataLoader("SourcesForExtensionDataLoader", pkgName) + } +} + +data class ExtensionNodeList( + override val nodes: List, + override val edges: List, + 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.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.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + ExtensionEdge( + cursor = Cursor("0"), + node = first() + ), + ExtensionEdge( + cursor = Cursor(lastIndex.toString()), + node = last() + ) + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt new file mode 100644 index 000000000..9631d854f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt @@ -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, + 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> { + return dataFetchingEnvironment.getValueFromDataLoader>("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 { + return dataFetchingEnvironment.getValueFromDataLoader("MangaMetaDataLoader", id) + } + + fun categories(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("CategoriesForMangaDataLoader", id) + } + + fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("SourceForMangaDataLoader", id) + } +} + +data class MangaNodeList( + override val nodes: List, + override val edges: List, + 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.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.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + MangaEdge( + cursor = Cursor("0"), + node = first() + ), + MangaEdge( + cursor = Cursor(lastIndex.toString()), + node = last() + ) + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MetaType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MetaType.kt new file mode 100644 index 000000000..5c5ba1c07 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MetaType.kt @@ -0,0 +1,78 @@ +package suwayomi.tachidesk.graphql.types + +import com.expediagroup.graphql.generator.annotations.GraphQLIgnore +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 + +open class MetaItem( + val key: String, + val value: String, + @GraphQLIgnore + val ref: Int? +) : Node + +class ChapterMetaItem( + private val row: ResultRow +) : MetaItem(row[ChapterMetaTable.key], row[ChapterMetaTable.value], row[ChapterMetaTable.ref].value) + +class MangaMetaItem( + private val row: ResultRow +) : MetaItem(row[MangaMetaTable.key], row[MangaMetaTable.value], row[MangaMetaTable.ref].value) + +class CategoryMetaItem( + private val row: ResultRow +) : MetaItem(row[CategoryMetaTable.key], row[CategoryMetaTable.value], row[CategoryMetaTable.ref].value) + +class GlobalMetaItem( + private val row: ResultRow +) : MetaItem(row[GlobalMetaTable.key], row[GlobalMetaTable.value], null) + +data class MetaNodeList( + override val nodes: List, + override val edges: List, + override val pageInfo: PageInfo, + override val totalCount: Int +) : NodeList() { + data class MetaEdge( + override val cursor: Cursor, + override val node: MetaItem + ) : Edge() + + companion object { + fun List.toNodeList(): MetaNodeList { + return MetaNodeList( + nodes = this, + edges = getEdges(), + pageInfo = PageInfo( + hasNextPage = false, + hasPreviousPage = false, + startCursor = Cursor(0.toString()), + endCursor = Cursor(lastIndex.toString()) + ), + totalCount = size + ) + } + + private fun List.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + MetaEdge( + cursor = Cursor("0"), + node = first() + ), + MetaEdge( + cursor = Cursor(lastIndex.toString()), + node = last() + ) + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SourceType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SourceType.kt new file mode 100644 index 000000000..a3018c394 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SourceType.kt @@ -0,0 +1,124 @@ +/* + * 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.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 { + return dataFetchingEnvironment.getValueFromDataLoader("MangaForSourceDataLoader", id) + } + + fun extension(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("ExtensionForSourceDataLoader", id) + } +} + +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, + override val edges: List, + 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.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.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + SourceEdge( + cursor = Cursor("0"), + node = first() + ), + SourceEdge( + cursor = Cursor(lastIndex.toString()), + node = last() + ) + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt index 06f055a68..48328a312 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt @@ -13,7 +13,11 @@ import suwayomi.tachidesk.manga.impl.Chapter 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.* +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 import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.util.formParam import suwayomi.tachidesk.server.util.handler @@ -115,7 +119,7 @@ object UpdateController { updater.addMangasToQueue( mangasToUpdate - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title)), + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title)) ) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt index 7ddd40d53..fe9a13a55 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt @@ -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 } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 7ccce7310..cd7b7bb0d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -24,6 +24,7 @@ import org.kodein.di.DI import org.kodein.di.conf.global import org.kodein.di.instance import suwayomi.tachidesk.global.GlobalAPI +import suwayomi.tachidesk.graphql.GraphQL import suwayomi.tachidesk.manga.MangaAPI import suwayomi.tachidesk.server.util.Browser import suwayomi.tachidesk.server.util.setupWebInterface @@ -109,6 +110,7 @@ object JavalinSetup { GlobalAPI.defineEndpoints() MangaAPI.defineEndpoints() } + GraphQL.defineEndpoints() } } diff --git a/server/src/main/resources/graphql-playground.html b/server/src/main/resources/graphql-playground.html new file mode 100644 index 000000000..bed46d95d --- /dev/null +++ b/server/src/main/resources/graphql-playground.html @@ -0,0 +1,103 @@ + + + + GraphiQL + + + + + + + + + + + + + +
Loading...
+ + + + + +