add trackers support (#720)

* add trackers support

* Cleanup Tracker Code

* Add GraphQL support for Tracking

* Fix lint and deprecation errors

* remove password from logs

* Fixes after merge

* Disable tracking for now

* More disabled

---------

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
This commit is contained in:
Tachimanga
2024-01-08 04:07:41 +08:00
committed by GitHub
parent 230427e758
commit 5a178ada74
44 changed files with 3726 additions and 10 deletions

View File

@@ -0,0 +1,112 @@
/*
* 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.TrackRecordNodeList
import suwayomi.tachidesk.graphql.types.TrackRecordNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.TrackRecordType
import suwayomi.tachidesk.graphql.types.TrackerType
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.server.JavalinSetup.future
class TrackerDataLoader : KotlinDataLoader<Int, TrackerType> {
override val dataLoaderName = "TrackerDataLoader"
override fun getDataLoader(): DataLoader<Int, TrackerType> =
DataLoaderFactory.newDataLoader { ids ->
future {
ids.map { id ->
TrackerManager.getTracker(id)?.let { TrackerType(it) }
}
}
}
}
class TrackRecordsForMangaIdDataLoader : KotlinDataLoader<Int, TrackRecordNodeList> {
override val dataLoaderName = "TrackRecordsForMangaIdDataLoader"
override fun getDataLoader(): DataLoader<Int, TrackRecordNodeList> =
DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val trackRecordsByMangaId =
TrackRecordTable.select { TrackRecordTable.mangaId inList ids }
.map { TrackRecordType(it) }
.groupBy { it.mangaId }
ids.map { (trackRecordsByMangaId[it] ?: emptyList()).toNodeList() }
}
}
}
}
class DisplayScoreForTrackRecordDataLoader : KotlinDataLoader<Int, String> {
override val dataLoaderName = "DisplayScoreForTrackRecordDataLoader"
override fun getDataLoader(): DataLoader<Int, String> =
DataLoaderFactory.newDataLoader<Int, String> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val trackRecords =
TrackRecordTable.select { TrackRecordTable.id inList ids }
.toList()
.map { it.toTrack() }
.associateBy { it.id!! }
.mapValues { TrackerManager.getTracker(it.value.sync_id)?.displayScore(it.value) }
ids.map { trackRecords[it] }
}
}
}
}
class TrackRecordsForTrackerIdDataLoader : KotlinDataLoader<Int, TrackRecordNodeList> {
override val dataLoaderName = "TrackRecordsForTrackerIdDataLoader"
override fun getDataLoader(): DataLoader<Int, TrackRecordNodeList> =
DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val trackRecordsBySyncId =
TrackRecordTable.select { TrackRecordTable.syncId inList ids }
.map { TrackRecordType(it) }
.groupBy { it.mangaId }
ids.map { (trackRecordsBySyncId[it] ?: emptyList()).toNodeList() }
}
}
}
}
class TrackRecordDataLoader : KotlinDataLoader<Int, TrackRecordType> {
override val dataLoaderName = "TrackRecordDataLoader"
override fun getDataLoader(): DataLoader<Int, TrackRecordType> =
DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val trackRecordsId =
TrackRecordTable.select { TrackRecordTable.id inList ids }
.map { TrackRecordType(it) }
.associateBy { it.id }
ids.map { trackRecordsId[it] }
}
}
}
}

View File

@@ -0,0 +1,189 @@
package suwayomi.tachidesk.graphql.mutations
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.TrackRecordType
import suwayomi.tachidesk.graphql.types.TrackSearchType
import suwayomi.tachidesk.graphql.types.TrackerType
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.model.dataclass.TrackSearchDataClass
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
class TrackMutation {
data class LoginTrackerOAuthInput(
val clientMutationId: String? = null,
val trackerId: Int,
val callbackUrl: String,
)
data class LoginTrackerOAuthPayload(
val clientMutationId: String?,
val isLoggedIn: Boolean,
val tracker: TrackerType,
)
fun loginTrackerOAuth(input: LoginTrackerOAuthInput): CompletableFuture<LoginTrackerOAuthPayload> {
val tracker =
requireNotNull(TrackerManager.getTracker(input.trackerId)) {
"Could not find tracker"
}
return future {
tracker.authCallback(input.callbackUrl)
val trackerType = TrackerType(tracker)
LoginTrackerOAuthPayload(
input.clientMutationId,
trackerType.isLoggedIn,
trackerType,
)
}
}
data class LoginTrackerCredentialsInput(
val clientMutationId: String? = null,
val trackerId: Int,
val username: String,
val password: String,
)
data class LoginTrackerCredentialsPayload(
val clientMutationId: String?,
val isLoggedIn: Boolean,
val tracker: TrackerType,
)
fun loginTrackerCredentials(input: LoginTrackerCredentialsInput): CompletableFuture<LoginTrackerCredentialsPayload> {
val tracker =
requireNotNull(TrackerManager.getTracker(input.trackerId)) {
"Could not find tracker"
}
return future {
tracker.login(input.username, input.password)
val trackerType = TrackerType(tracker)
LoginTrackerCredentialsPayload(
input.clientMutationId,
trackerType.isLoggedIn,
trackerType,
)
}
}
data class LogoutTrackerInput(
val clientMutationId: String? = null,
val trackerId: Int,
)
data class LogoutTrackerPayload(
val clientMutationId: String?,
val isLoggedIn: Boolean,
val tracker: TrackerType,
)
fun logoutTracker(input: LogoutTrackerInput): CompletableFuture<LogoutTrackerPayload> {
val tracker =
requireNotNull(TrackerManager.getTracker(input.trackerId)) {
"Could not find tracker"
}
require(tracker.isLoggedIn) {
"Cannot logout of a tracker that is not logged-in"
}
return future {
tracker.logout()
val trackerType = TrackerType(tracker)
LogoutTrackerPayload(
input.clientMutationId,
trackerType.isLoggedIn,
trackerType,
)
}
}
data class BindTrackInput(
val clientMutationId: String? = null,
val mangaId: Int,
val track: TrackSearchType,
)
data class BindTrackPayload(
val clientMutationId: String?,
val trackRecord: TrackRecordType,
)
fun bindTrack(input: BindTrackInput): CompletableFuture<BindTrackPayload> {
val (clientMutationId, mangaId, track) = input
return future {
Track.bind(
mangaId,
TrackSearchDataClass(
syncId = track.syncId,
mediaId = track.mediaId,
title = track.title,
totalChapters = track.totalChapters,
trackingUrl = track.trackingUrl,
coverUrl = track.coverUrl,
summary = track.summary,
publishingStatus = track.publishingStatus,
publishingType = track.publishingType,
startDate = track.startDate,
),
)
val trackRecord =
transaction {
TrackRecordTable.select {
TrackRecordTable.mangaId eq mangaId and (TrackRecordTable.syncId eq track.syncId)
}.first()
}
BindTrackPayload(
clientMutationId,
TrackRecordType(trackRecord),
)
}
}
data class UpdateTrackInput(
val clientMutationId: String? = null,
val recordId: Int,
val status: Int? = null,
val lastChapterRead: Double? = null,
val scoreString: String? = null,
val startDate: Long? = null,
val finishDate: Long? = null,
val unbind: Boolean? = null,
)
data class UpdateTrackPayload(
val clientMutationId: String?,
val trackRecord: TrackRecordType?,
)
fun updateTrack(input: UpdateTrackInput): CompletableFuture<UpdateTrackPayload> {
return future {
Track.update(
Track.UpdateInput(
input.recordId,
input.status,
input.lastChapterRead,
input.scoreString,
input.startDate,
input.finishDate,
input.unbind,
),
)
val trackRecord =
transaction {
TrackRecordTable.select {
TrackRecordTable.id eq input.recordId
}.firstOrNull()
}
UpdateTrackPayload(
input.clientMutationId,
trackRecord?.let { TrackRecordType(it) },
)
}
}
}

View File

@@ -0,0 +1,471 @@
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.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
import suwayomi.tachidesk.graphql.queries.filter.DoubleFilter
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.applyBeforeAfter
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.TrackRecordNodeList
import suwayomi.tachidesk.graphql.types.TrackRecordType
import suwayomi.tachidesk.graphql.types.TrackSearchType
import suwayomi.tachidesk.graphql.types.TrackerNodeList
import suwayomi.tachidesk.graphql.types.TrackerType
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
class TrackQuery {
fun tracker(
dataFetchingEnvironment: DataFetchingEnvironment,
id: Int,
): CompletableFuture<TrackerType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("TrackerDataLoader", id)
}
enum class TrackerOrderBy {
ID,
NAME,
IS_LOGGED_IN,
;
fun greater(
tracker: TrackerType,
cursor: Cursor,
): Boolean {
return when (this) {
ID -> tracker.id > cursor.value.toInt()
NAME -> tracker.name > cursor.value
IS_LOGGED_IN -> {
val value = cursor.value.substringAfter('-').toBooleanStrict()
!value || tracker.isLoggedIn
}
}
}
fun less(
tracker: TrackerType,
cursor: Cursor,
): Boolean {
return when (this) {
ID -> tracker.id < cursor.value.toInt()
NAME -> tracker.name < cursor.value
IS_LOGGED_IN -> {
val value = cursor.value.substringAfter('-').toBooleanStrict()
value || !tracker.isLoggedIn
}
}
}
fun asCursor(type: TrackerType): Cursor {
val value =
when (this) {
ID -> type.id.toString()
NAME -> type.name
IS_LOGGED_IN -> type.id.toString() + "-" + type.isLoggedIn
}
return Cursor(value)
}
}
data class TrackerCondition(
val id: Int? = null,
val name: String? = null,
val icon: String? = null,
val isLoggedIn: Boolean? = null,
)
data class TrackerFilter(
val id: IntFilter? = null,
val name: StringFilter? = null,
val icon: StringFilter? = null,
val isLoggedIn: BooleanFilter? = null,
val authUrl: StringFilter? = null,
val and: List<TrackerFilter>? = null,
val or: List<TrackerFilter>? = null,
val not: TrackerFilter? = null,
)
fun trackers(
condition: TrackerCondition? = null,
orderBy: TrackerOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null,
): TrackerNodeList {
val (queryResults, resultsAsType) =
run {
var res = TrackerManager.services.map { TrackerType(it) }
if (condition != null) {
res =
res.filter { tracker ->
(condition.id == null || (condition.id == tracker.id)) &&
(condition.name == null || (condition.name == tracker.name)) &&
(condition.icon == null || (condition.icon == tracker.icon)) &&
(condition.isLoggedIn == null || (condition.isLoggedIn == tracker.isLoggedIn))
}
}
if (orderBy != null || (last != null || before != null)) {
val orderType = orderByType.maybeSwap(last ?: before)
res =
when (orderType) {
SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST ->
when (orderBy) {
TrackerOrderBy.ID, null -> res.sortedByDescending { it.id }
TrackerOrderBy.NAME -> res.sortedByDescending { it.name }
TrackerOrderBy.IS_LOGGED_IN -> res.sortedByDescending { it.isLoggedIn }
}
SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST ->
when (orderBy) {
TrackerOrderBy.ID, null -> res.sortedBy { it.id }
TrackerOrderBy.NAME -> res.sortedBy { it.name }
TrackerOrderBy.IS_LOGGED_IN -> res.sortedBy { it.isLoggedIn }
}
}
}
val total = res.size
val firstResult = res.firstOrNull()
val lastResult = res.lastOrNull()
val realOrderBy = orderBy ?: TrackerOrderBy.ID
if (after != null) {
res =
res.filter {
when (orderByType) {
SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> realOrderBy.less(it, after)
null, SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> realOrderBy.greater(it, after)
}
}
} else if (before != null) {
res =
res.filter {
when (orderByType) {
SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> realOrderBy.greater(it, before)
null, SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> realOrderBy.less(it, before)
}
}
}
if (first != null) {
res = res.drop(offset ?: 0).take(first)
} else if (last != null) {
res = res.take(last)
}
QueryResults(total.toLong(), firstResult, lastResult, emptyList()) to res
}
val getAsCursor: (TrackerType) -> Cursor = (orderBy ?: TrackerOrderBy.ID)::asCursor
return TrackerNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
TrackerNodeList.TrackerEdge(
getAsCursor(it),
it,
)
},
resultsAsType.lastOrNull()?.let {
TrackerNodeList.TrackerEdge(
getAsCursor(it),
it,
)
},
)
},
pageInfo =
PageInfo(
hasNextPage = queryResults.lastKey?.id != resultsAsType.lastOrNull()?.id,
hasPreviousPage = queryResults.firstKey?.id != resultsAsType.firstOrNull()?.id,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) },
),
totalCount = queryResults.total.toInt(),
)
}
fun trackRecord(
dataFetchingEnvironment: DataFetchingEnvironment,
id: Int,
): CompletableFuture<TrackRecordType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackRecordType>("TrackRecordDataLoader", id)
}
enum class TrackRecordOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<TrackRecordType> {
ID(TrackRecordTable.id),
MANGA_ID(TrackRecordTable.mangaId),
SYNC_ID(TrackRecordTable.syncId),
REMOTE_ID(TrackRecordTable.remoteId),
TITLE(TrackRecordTable.title),
LAST_CHAPTER_READ(TrackRecordTable.lastChapterRead),
TOTAL_CHAPTERS(TrackRecordTable.lastChapterRead),
SCORE(TrackRecordTable.score),
START_DATE(TrackRecordTable.startDate),
FINISH_DATE(TrackRecordTable.finishDate),
;
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> TrackRecordTable.id greater cursor.value.toInt()
MANGA_ID -> greaterNotUnique(TrackRecordTable.mangaId, TrackRecordTable.id, cursor)
SYNC_ID -> greaterNotUnique(TrackRecordTable.syncId, TrackRecordTable.id, cursor, String::toInt)
REMOTE_ID -> greaterNotUnique(TrackRecordTable.remoteId, TrackRecordTable.id, cursor, String::toLong)
TITLE -> greaterNotUnique(TrackRecordTable.title, TrackRecordTable.id, cursor, String::toString)
LAST_CHAPTER_READ -> greaterNotUnique(TrackRecordTable.lastChapterRead, TrackRecordTable.id, cursor, String::toDouble)
TOTAL_CHAPTERS -> greaterNotUnique(TrackRecordTable.totalChapters, TrackRecordTable.id, cursor, String::toInt)
SCORE -> greaterNotUnique(TrackRecordTable.score, TrackRecordTable.id, cursor, String::toDouble)
START_DATE -> greaterNotUnique(TrackRecordTable.startDate, TrackRecordTable.id, cursor, String::toLong)
FINISH_DATE -> greaterNotUnique(TrackRecordTable.finishDate, TrackRecordTable.id, cursor, String::toLong)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> TrackRecordTable.id less cursor.value.toInt()
MANGA_ID -> lessNotUnique(TrackRecordTable.mangaId, TrackRecordTable.id, cursor)
SYNC_ID -> lessNotUnique(TrackRecordTable.syncId, TrackRecordTable.id, cursor, String::toInt)
REMOTE_ID -> lessNotUnique(TrackRecordTable.remoteId, TrackRecordTable.id, cursor, String::toLong)
TITLE -> lessNotUnique(TrackRecordTable.title, TrackRecordTable.id, cursor, String::toString)
LAST_CHAPTER_READ -> lessNotUnique(TrackRecordTable.lastChapterRead, TrackRecordTable.id, cursor, String::toDouble)
TOTAL_CHAPTERS -> lessNotUnique(TrackRecordTable.totalChapters, TrackRecordTable.id, cursor, String::toInt)
SCORE -> lessNotUnique(TrackRecordTable.score, TrackRecordTable.id, cursor, String::toDouble)
START_DATE -> lessNotUnique(TrackRecordTable.startDate, TrackRecordTable.id, cursor, String::toLong)
FINISH_DATE -> lessNotUnique(TrackRecordTable.finishDate, TrackRecordTable.id, cursor, String::toLong)
}
}
override fun asCursor(type: TrackRecordType): Cursor {
val value =
when (this) {
ID -> type.id.toString()
MANGA_ID -> type.id.toString() + "-" + type.mangaId
SYNC_ID -> type.id.toString() + "-" + type.syncId
REMOTE_ID -> type.id.toString() + "-" + type.remoteId
TITLE -> type.id.toString() + "-" + type.title
LAST_CHAPTER_READ -> type.id.toString() + "-" + type.lastChapterRead
TOTAL_CHAPTERS -> type.id.toString() + "-" + type.totalChapters
SCORE -> type.id.toString() + "-" + type.score
START_DATE -> type.id.toString() + "-" + type.startDate
FINISH_DATE -> type.id.toString() + "-" + type.finishDate
}
return Cursor(value)
}
}
data class TrackRecordCondition(
val id: Int? = null,
val mangaId: Int? = null,
val syncId: Int? = null,
val remoteId: Long? = null,
val libraryId: Long? = null,
val title: String? = null,
val lastChapterRead: Double? = null,
val totalChapters: Int? = null,
val status: Int? = null,
val score: Double? = null,
val remoteUrl: String? = null,
val startDate: Long? = null,
val finishDate: Long? = null,
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, TrackRecordTable.id)
opAnd.eq(mangaId, TrackRecordTable.mangaId)
opAnd.eq(syncId, TrackRecordTable.syncId)
opAnd.eq(remoteId, TrackRecordTable.remoteId)
opAnd.eq(libraryId, TrackRecordTable.libraryId)
opAnd.eq(title, TrackRecordTable.title)
opAnd.eq(lastChapterRead, TrackRecordTable.lastChapterRead)
opAnd.eq(totalChapters, TrackRecordTable.totalChapters)
opAnd.eq(status, TrackRecordTable.status)
opAnd.eq(score, TrackRecordTable.score)
opAnd.eq(remoteUrl, TrackRecordTable.remoteUrl)
opAnd.eq(startDate, TrackRecordTable.startDate)
opAnd.eq(finishDate, TrackRecordTable.finishDate)
return opAnd.op
}
}
data class TrackRecordFilter(
val id: IntFilter? = null,
val mangaId: IntFilter? = null,
val syncId: IntFilter? = null,
val remoteId: LongFilter? = null,
val libraryId: LongFilter? = null,
val title: StringFilter? = null,
val lastChapterRead: DoubleFilter? = null,
val totalChapters: IntFilter? = null,
val status: IntFilter? = null,
val score: DoubleFilter? = null,
val remoteUrl: StringFilter? = null,
val startDate: LongFilter? = null,
val finishDate: LongFilter? = null,
override val and: List<TrackRecordFilter>? = null,
override val or: List<TrackRecordFilter>? = null,
override val not: TrackRecordFilter? = null,
) : Filter<TrackRecordFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(TrackRecordTable.id, id),
andFilterWithCompareEntity(TrackRecordTable.mangaId, mangaId),
andFilterWithCompare(TrackRecordTable.syncId, syncId),
andFilterWithCompare(TrackRecordTable.remoteId, remoteId),
andFilterWithCompare(TrackRecordTable.libraryId, libraryId),
andFilterWithCompareString(TrackRecordTable.title, title),
andFilterWithCompare(TrackRecordTable.lastChapterRead, lastChapterRead),
andFilterWithCompare(TrackRecordTable.totalChapters, totalChapters),
andFilterWithCompare(TrackRecordTable.status, status),
andFilterWithCompare(TrackRecordTable.score, score),
andFilterWithCompareString(TrackRecordTable.remoteUrl, remoteUrl),
andFilterWithCompare(TrackRecordTable.startDate, startDate),
andFilterWithCompare(TrackRecordTable.finishDate, finishDate),
)
}
}
fun trackRecords(
condition: TrackRecordCondition? = null,
filter: TrackRecordFilter? = null,
orderBy: TrackRecordOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null,
): TrackRecordNodeList {
val queryResults =
transaction {
val res = TrackRecordTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: TrackRecordTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == TrackRecordOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
TrackRecordTable.id to SortOrder.ASC,
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(TrackRecordTable.id)?.value
val lastResult = res.lastOrNull()?.get(TrackRecordTable.id)?.value
res.applyBeforeAfter(
before = before,
after = after,
orderBy = orderBy ?: TrackRecordOrderBy.ID,
orderByType = orderByType,
)
if (first != null) {
res.limit(first, offset?.toLong() ?: 0)
} else if (last != null) {
res.limit(last)
}
QueryResults(total, firstResult, lastResult, res.toList())
}
val getAsCursor: (TrackRecordType) -> Cursor = (orderBy ?: TrackRecordOrderBy.ID)::asCursor
val resultsAsType = queryResults.results.map { TrackRecordType(it) }
return TrackRecordNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
TrackRecordNodeList.TrackRecordEdge(
getAsCursor(it),
it,
)
},
resultsAsType.lastOrNull()?.let {
TrackRecordNodeList.TrackRecordEdge(
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(),
)
}
data class SearchTrackerInput(
val trackerId: Int,
val query: String,
)
data class SearchTrackerPayload(val trackSearches: List<TrackSearchType>)
fun searchTracker(input: SearchTrackerInput): CompletableFuture<SearchTrackerPayload> {
return future {
val tracker =
requireNotNull(TrackerManager.getTracker(input.trackerId)) {
"Tracker not found"
}
require(tracker.isLoggedIn) {
"Tracker needs to be logged-in to search"
}
SearchTrackerPayload(
tracker.search(input.query).map {
TrackSearchType(it)
},
)
}
}
}

View File

@@ -259,6 +259,20 @@ data class FloatFilter(
override val greaterThanOrEqualTo: Float? = null,
) : ComparableScalarFilter<Float>
data class DoubleFilter(
override val isNull: Boolean? = null,
override val equalTo: Double? = null,
override val notEqualTo: Double? = null,
override val distinctFrom: Double? = null,
override val notDistinctFrom: Double? = null,
override val `in`: List<Double>? = null,
override val notIn: List<Double>? = null,
override val lessThan: Double? = null,
override val lessThanOrEqualTo: Double? = null,
override val greaterThan: Double? = null,
override val greaterThanOrEqualTo: Double? = null,
) : ComparableScalarFilter<Double>
data class StringFilter(
override val isNull: Boolean? = null,
override val equalTo: String? = null,
@@ -418,8 +432,8 @@ class OpAnd(var op: Op<Boolean>? = null) {
) = andWhere(value) { column like it }
}
fun <T : Comparable<T>> andFilterWithCompare(
column: Column<T>,
fun <T : Comparable<T>, S : T?> andFilterWithCompare(
column: Column<S>,
filter: ComparableScalarFilter<T>?,
): Op<Boolean>? {
filter ?: return null
@@ -448,23 +462,24 @@ fun <T : Comparable<T>> andFilterWithCompareEntity(
return opAnd.op
}
fun <T : Comparable<T>> andFilter(
column: Column<T>,
@Suppress("UNCHECKED_CAST")
fun <T : Comparable<T>, S : T?> andFilter(
column: Column<S>,
filter: ScalarFilter<T>?,
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it }
opAnd.andWhere(filter.notEqualTo) { column neq it }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
opAnd.andWhere(filter.equalTo) { column eq it as S }
opAnd.andWhere(filter.notEqualTo) { column neq it as S }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it as S) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList it }
opAnd.andWhere(filter.`in`) { column inList it as List<S> }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList it }
opAnd.andWhere(filter.notIn) { column notInList it as List<S> }
}
return opAnd.op
}

View File

@@ -15,6 +15,7 @@ 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.DisplayScoreForTrackRecordDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
@@ -27,6 +28,10 @@ import suwayomi.tachidesk.graphql.dataLoaders.MangaForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourcesForExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordsForMangaIdDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordsForTrackerIdDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackerDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.UnreadChapterCountForMangaDataLoader
class TachideskDataLoaderRegistryFactory {
@@ -53,6 +58,11 @@ class TachideskDataLoaderRegistryFactory {
SourcesForExtensionDataLoader(),
ExtensionDataLoader(),
ExtensionForSourceDataLoader(),
// TrackerDataLoader(),
// TrackRecordsForMangaIdDataLoader(),
// DisplayScoreForTrackRecordDataLoader(),
// TrackRecordsForTrackerIdDataLoader(),
// TrackRecordDataLoader(),
)
}
}

View File

@@ -24,6 +24,7 @@ import suwayomi.tachidesk.graphql.mutations.MangaMutation
import suwayomi.tachidesk.graphql.mutations.MetaMutation
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
import suwayomi.tachidesk.graphql.mutations.SourceMutation
import suwayomi.tachidesk.graphql.mutations.TrackMutation
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
import suwayomi.tachidesk.graphql.queries.BackupQuery
import suwayomi.tachidesk.graphql.queries.CategoryQuery
@@ -35,6 +36,7 @@ import suwayomi.tachidesk.graphql.queries.MangaQuery
import suwayomi.tachidesk.graphql.queries.MetaQuery
import suwayomi.tachidesk.graphql.queries.SettingsQuery
import suwayomi.tachidesk.graphql.queries.SourceQuery
import suwayomi.tachidesk.graphql.queries.TrackQuery
import suwayomi.tachidesk.graphql.queries.UpdateQuery
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
@@ -76,6 +78,7 @@ val schema =
TopLevelObject(MetaQuery()),
TopLevelObject(SettingsQuery()),
TopLevelObject(SourceQuery()),
// TopLevelObject(TrackQuery()),
TopLevelObject(UpdateQuery()),
),
mutations =
@@ -91,6 +94,7 @@ val schema =
TopLevelObject(MetaMutation()),
TopLevelObject(SettingsMutation()),
TopLevelObject(SourceMutation()),
// TopLevelObject(TrackMutation()),
TopLevelObject(UpdateMutation()),
),
subscriptions =

View File

@@ -81,6 +81,15 @@ fun <T : Comparable<T>> greaterNotUnique(
return greaterNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
}
@JvmName("greaterNotUniqueIntKeyIntValue")
fun greaterNotUnique(
column: Column<EntityID<Int>>,
idColumn: Column<EntityID<Int>>,
cursor: Cursor,
): Op<Boolean> {
return greaterNotUniqueImpl(column, idColumn, cursor, String::toInt, String::toInt)
}
private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
column: Column<V>,
idColumn: Column<EntityID<K>>,
@@ -93,6 +102,19 @@ private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
return (column greater value) or ((column eq value) and (idColumn greater id))
}
@JvmName("greaterNotUniqueEntityValue")
private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
column: Column<EntityID<V>>,
idColumn: Column<EntityID<K>>,
cursor: Cursor,
toKey: (String) -> K,
toValue: (String) -> V,
): Op<Boolean> {
val id = toKey(cursor.value.substringBefore('-'))
val value = toValue(cursor.value.substringAfter('-'))
return (column greater value) or ((column eq value) and (idColumn greater id))
}
@JvmName("greaterNotUniqueStringKey")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
@@ -125,6 +147,15 @@ fun <T : Comparable<T>> lessNotUnique(
return lessNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
}
@JvmName("lessNotUniqueIntKeyIntValue")
fun lessNotUnique(
column: Column<EntityID<Int>>,
idColumn: Column<EntityID<Int>>,
cursor: Cursor,
): Op<Boolean> {
return lessNotUniqueImpl(column, idColumn, cursor, String::toInt, String::toInt)
}
private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
column: Column<V>,
idColumn: Column<EntityID<K>>,
@@ -137,6 +168,19 @@ private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
return (column less value) or ((column eq value) and (idColumn less id))
}
@JvmName("lessNotUniqueEntityValue")
private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
column: Column<EntityID<V>>,
idColumn: Column<EntityID<K>>,
cursor: Cursor,
toKey: (String) -> K,
toValue: (String) -> V,
): Op<Boolean> {
val id = toKey(cursor.value.substringBefore('-'))
val value = toValue(cursor.value.substringAfter('-'))
return (column less value) or ((column eq value) and (idColumn less id))
}
@JvmName("lessNotUniqueStringKey")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,

View File

@@ -139,6 +139,10 @@ class MangaType(
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceType?> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, SourceType?>("SourceDataLoader", sourceId)
}
// fun trackRecords(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackRecordNodeList> {
// return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackRecordNodeList>("TrackRecordsForMangaIdDataLoader", id)
// }
}
data class MangaNodeList(

View File

@@ -0,0 +1,203 @@
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.impl.track.tracker.Tracker
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import java.util.concurrent.CompletableFuture
class TrackerType(
val id: Int,
val name: String,
val icon: String,
val isLoggedIn: Boolean,
val authUrl: String?,
) : Node {
constructor(tracker: Tracker) : this(
tracker.isLoggedIn,
tracker,
)
constructor(isLoggedIn: Boolean, tracker: Tracker) : this(
tracker.id,
tracker.name,
tracker.getLogo(),
isLoggedIn,
if (isLoggedIn) {
null
} else {
tracker.authUrl()
},
)
fun trackRecords(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackRecordNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackRecordNodeList>("TrackRecordsForTrackerIdDataLoader", id)
}
}
class TrackRecordType(
val id: Int,
val mangaId: Int,
val syncId: Int,
val remoteId: Long,
val libraryId: Long?,
val title: String,
val lastChapterRead: Double,
val totalChapters: Int,
val status: Int,
val score: Double,
val remoteUrl: String,
val startDate: Long,
val finishDate: Long,
) : Node {
constructor(row: ResultRow) : this(
row[TrackRecordTable.id].value,
row[TrackRecordTable.mangaId].value,
row[TrackRecordTable.syncId],
row[TrackRecordTable.remoteId],
row[TrackRecordTable.libraryId],
row[TrackRecordTable.title],
row[TrackRecordTable.lastChapterRead],
row[TrackRecordTable.totalChapters],
row[TrackRecordTable.status],
row[TrackRecordTable.score],
row[TrackRecordTable.remoteUrl],
row[TrackRecordTable.startDate],
row[TrackRecordTable.finishDate],
)
fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<String> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, String>("DisplayScoreForTrackRecordDataLoader", id)
}
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", mangaId)
}
fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackerType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("TrackerDataLoader", syncId)
}
}
class TrackSearchType(
val syncId: Int,
val mediaId: Long,
val title: String,
val totalChapters: Int,
val trackingUrl: String,
val coverUrl: String,
val summary: String,
val publishingStatus: String,
val publishingType: String,
val startDate: String,
) {
constructor(trackSearch: TrackSearch) : this(
trackSearch.sync_id,
trackSearch.media_id,
trackSearch.title,
trackSearch.total_chapters,
trackSearch.tracking_url,
trackSearch.cover_url,
trackSearch.summary,
trackSearch.publishing_status,
trackSearch.publishing_type,
trackSearch.start_date,
)
fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackerType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("TrackerDataLoader", syncId)
}
}
data class TrackRecordNodeList(
override val nodes: List<TrackRecordType>,
override val edges: List<TrackRecordEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int,
) : NodeList() {
data class TrackRecordEdge(
override val cursor: Cursor,
override val node: TrackRecordType,
) : Edge()
companion object {
fun List<TrackRecordType>.toNodeList(): TrackRecordNodeList {
return TrackRecordNodeList(
nodes = this,
edges = getEdges(),
pageInfo =
PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString()),
),
totalCount = size,
)
}
private fun List<TrackRecordType>.getEdges(): List<TrackRecordEdge> {
if (isEmpty()) return emptyList()
return listOf(
TrackRecordEdge(
cursor = Cursor("0"),
node = first(),
),
TrackRecordEdge(
cursor = Cursor(lastIndex.toString()),
node = last(),
),
)
}
}
}
data class TrackerNodeList(
override val nodes: List<TrackerType>,
override val edges: List<TrackerEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int,
) : NodeList() {
data class TrackerEdge(
override val cursor: Cursor,
override val node: TrackerType,
) : Edge()
companion object {
fun List<TrackerType>.toNodeList(): TrackerNodeList {
return TrackerNodeList(
nodes = this,
edges = getEdges(),
pageInfo =
PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString()),
),
totalCount = size,
)
}
private fun List<TrackerType>.getEdges(): List<TrackerEdge> {
if (isEmpty()) return emptyList()
return listOf(
TrackerEdge(
cursor = Cursor("0"),
node = first(),
),
TrackerEdge(
cursor = Cursor(lastIndex.toString()),
node = last(),
),
)
}
}
}