c117d380a3
* Update exposed to v1 * Update Exposed * Add Kotlinx.DateTime extensions * Update H2 * Review comments --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Syer10 <syer10@users.noreply.github.com>
465 lines
16 KiB
Kotlin
465 lines
16 KiB
Kotlin
@file:Suppress("RedundantNullableReturnType", "unused")
|
|
|
|
package suwayomi.tachidesk.graphql.mutations
|
|
|
|
import kotlinx.coroutines.GlobalScope
|
|
import kotlinx.coroutines.launch
|
|
import org.jetbrains.exposed.v1.core.LikePattern
|
|
import org.jetbrains.exposed.v1.core.Op
|
|
import org.jetbrains.exposed.v1.core.and
|
|
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
|
import org.jetbrains.exposed.v1.core.eq
|
|
import org.jetbrains.exposed.v1.core.inList
|
|
import org.jetbrains.exposed.v1.core.like
|
|
import org.jetbrains.exposed.v1.core.or
|
|
import org.jetbrains.exposed.v1.core.statements.BatchUpdateStatement
|
|
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
|
import org.jetbrains.exposed.v1.jdbc.select
|
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
|
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
|
import org.jetbrains.exposed.v1.jdbc.update
|
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
|
import suwayomi.tachidesk.graphql.types.ChapterMetaType
|
|
import suwayomi.tachidesk.graphql.types.ChapterType
|
|
import suwayomi.tachidesk.graphql.types.MetaInput
|
|
import suwayomi.tachidesk.graphql.types.SyncConflictInfoType
|
|
import suwayomi.tachidesk.manga.impl.Chapter
|
|
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById
|
|
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
|
|
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
|
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
|
import java.net.URLEncoder
|
|
import java.time.Instant
|
|
import java.util.concurrent.CompletableFuture
|
|
|
|
/**
|
|
* TODO Mutations
|
|
* - Download
|
|
* - Delete download
|
|
*/
|
|
class ChapterMutation {
|
|
data class UpdateChapterPatch(
|
|
val isBookmarked: Boolean? = null,
|
|
val isRead: Boolean? = null,
|
|
val lastPageRead: Int? = null,
|
|
)
|
|
|
|
data class UpdateChapterPayload(
|
|
val clientMutationId: String?,
|
|
val chapter: ChapterType,
|
|
)
|
|
|
|
data class UpdateChapterInput(
|
|
val clientMutationId: String? = null,
|
|
val id: Int,
|
|
val patch: UpdateChapterPatch,
|
|
)
|
|
|
|
data class UpdateChaptersPayload(
|
|
val clientMutationId: String?,
|
|
val chapters: List<ChapterType>,
|
|
)
|
|
|
|
data class UpdateChaptersInput(
|
|
val clientMutationId: String? = null,
|
|
val ids: List<Int>,
|
|
val patch: UpdateChapterPatch,
|
|
)
|
|
|
|
private fun updateChapters(
|
|
ids: List<Int>,
|
|
patch: UpdateChapterPatch,
|
|
) {
|
|
if (ids.isEmpty()) {
|
|
return
|
|
}
|
|
|
|
transaction {
|
|
val chapterIdToPageCount =
|
|
if (patch.lastPageRead != null) {
|
|
ChapterTable
|
|
.select(ChapterTable.id, ChapterTable.pageCount)
|
|
.where { ChapterTable.id inList ids }
|
|
.associateBy(
|
|
{ it[ChapterTable.id].value },
|
|
{ it[ChapterTable.pageCount] },
|
|
)
|
|
} else {
|
|
emptyMap()
|
|
}
|
|
if (patch.isRead != null || patch.isBookmarked != null || patch.lastPageRead != null) {
|
|
val now = Instant.now().epochSecond
|
|
|
|
BatchUpdateStatement(ChapterTable)
|
|
.apply {
|
|
ids.forEach { chapterId ->
|
|
addBatch(EntityID(chapterId, ChapterTable))
|
|
patch.isRead?.also {
|
|
this[ChapterTable.isRead] = it
|
|
}
|
|
patch.isBookmarked?.also {
|
|
this[ChapterTable.isBookmarked] = it
|
|
}
|
|
patch.lastPageRead?.also {
|
|
this[ChapterTable.lastPageRead] = it.coerceAtMost(chapterIdToPageCount[chapterId] ?: 0).coerceAtLeast(0)
|
|
this[ChapterTable.lastReadAt] = now
|
|
}
|
|
}
|
|
}.toExecutable()
|
|
.execute(this@transaction)
|
|
}
|
|
}
|
|
|
|
// Sync with KoreaderSync when progress is updated
|
|
if (patch.lastPageRead != null || patch.isRead == true) {
|
|
GlobalScope.launch {
|
|
ids.forEach { chapterId ->
|
|
KoreaderSyncService.pushProgress(chapterId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@RequireAuth
|
|
fun updateChapter(input: UpdateChapterInput): UpdateChapterPayload? {
|
|
val (clientMutationId, id, patch) = input
|
|
|
|
updateChapters(listOf(id), patch)
|
|
|
|
val chapter =
|
|
transaction {
|
|
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq id }.first())
|
|
}
|
|
|
|
return UpdateChapterPayload(
|
|
clientMutationId = clientMutationId,
|
|
chapter = chapter,
|
|
)
|
|
}
|
|
|
|
@RequireAuth
|
|
fun updateChapters(input: UpdateChaptersInput): UpdateChaptersPayload? {
|
|
val (clientMutationId, ids, patch) = input
|
|
|
|
updateChapters(ids, patch)
|
|
|
|
val chapters =
|
|
transaction {
|
|
ChapterTable.selectAll().where { ChapterTable.id inList ids }.map { ChapterType(it) }
|
|
}
|
|
|
|
return UpdateChaptersPayload(
|
|
clientMutationId = clientMutationId,
|
|
chapters = chapters,
|
|
)
|
|
}
|
|
|
|
data class FetchChaptersInput(
|
|
val clientMutationId: String? = null,
|
|
val mangaId: Int,
|
|
)
|
|
|
|
data class FetchChaptersPayload(
|
|
val clientMutationId: String?,
|
|
val chapters: List<ChapterType>,
|
|
)
|
|
|
|
@RequireAuth
|
|
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<FetchChaptersPayload?> {
|
|
val (clientMutationId, mangaId) = input
|
|
|
|
return future {
|
|
Chapter.fetchChapterList(mangaId)
|
|
|
|
val chapters =
|
|
transaction {
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { ChapterTable.manga eq mangaId }
|
|
.orderBy(ChapterTable.sourceOrder)
|
|
.map { ChapterType(it) }
|
|
}
|
|
|
|
FetchChaptersPayload(
|
|
clientMutationId = clientMutationId,
|
|
chapters = chapters,
|
|
)
|
|
}
|
|
}
|
|
|
|
data class SetChapterMetaInput(
|
|
val clientMutationId: String? = null,
|
|
val meta: ChapterMetaType,
|
|
)
|
|
|
|
data class SetChapterMetaPayload(
|
|
val clientMutationId: String?,
|
|
val meta: ChapterMetaType,
|
|
)
|
|
|
|
@RequireAuth
|
|
fun setChapterMeta(input: SetChapterMetaInput): SetChapterMetaPayload? {
|
|
val (clientMutationId, meta) = input
|
|
|
|
Chapter.modifyChapterMeta(meta.chapterId, meta.key, meta.value)
|
|
|
|
return SetChapterMetaPayload(clientMutationId, meta)
|
|
}
|
|
|
|
data class DeleteChapterMetaInput(
|
|
val clientMutationId: String? = null,
|
|
val chapterId: Int,
|
|
val key: String,
|
|
)
|
|
|
|
data class DeleteChapterMetaPayload(
|
|
val clientMutationId: String?,
|
|
val meta: ChapterMetaType?,
|
|
val chapter: ChapterType,
|
|
)
|
|
|
|
@RequireAuth
|
|
fun deleteChapterMeta(input: DeleteChapterMetaInput): DeleteChapterMetaPayload? {
|
|
val (clientMutationId, chapterId, key) = input
|
|
|
|
val (meta, chapter) =
|
|
transaction {
|
|
val meta =
|
|
ChapterMetaTable
|
|
.selectAll()
|
|
.where { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
|
|
.firstOrNull()
|
|
|
|
ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
|
|
|
|
val chapter =
|
|
transaction {
|
|
ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first())
|
|
}
|
|
|
|
if (meta != null) {
|
|
ChapterMetaType(meta)
|
|
} else {
|
|
null
|
|
} to chapter
|
|
}
|
|
|
|
return DeleteChapterMetaPayload(clientMutationId, meta, chapter)
|
|
}
|
|
|
|
data class SetChapterMetasItem(
|
|
val chapterIds: List<Int>,
|
|
val metas: List<MetaInput>,
|
|
)
|
|
|
|
data class SetChapterMetasInput(
|
|
val clientMutationId: String? = null,
|
|
val items: List<SetChapterMetasItem>,
|
|
)
|
|
|
|
data class SetChapterMetasPayload(
|
|
val clientMutationId: String?,
|
|
val metas: List<ChapterMetaType>,
|
|
val chapters: List<ChapterType>,
|
|
)
|
|
|
|
@RequireAuth
|
|
fun setChapterMetas(input: SetChapterMetasInput): SetChapterMetasPayload? {
|
|
val (clientMutationId, items) = input
|
|
|
|
val metaByChapterId =
|
|
items
|
|
.flatMap { item ->
|
|
val metaMap = item.metas.associate { it.key to it.value }
|
|
item.chapterIds.map { chapterId -> chapterId to metaMap }
|
|
}.groupBy({ it.first }, { it.second })
|
|
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
|
|
|
|
Chapter.modifyChaptersMetas(metaByChapterId)
|
|
|
|
val allChapterIds = metaByChapterId.keys
|
|
val allMetaKeys = metaByChapterId.values.flatMap { it.keys }.distinct()
|
|
|
|
val (updatedMetas, chapters) =
|
|
transaction {
|
|
val updatedMetas =
|
|
ChapterMetaTable
|
|
.selectAll()
|
|
.where { (ChapterMetaTable.ref inList allChapterIds) and (ChapterMetaTable.key inList allMetaKeys) }
|
|
.map { ChapterMetaType(it) }
|
|
|
|
val chapters =
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { ChapterTable.id inList allChapterIds }
|
|
.map { ChapterType(it) }
|
|
.distinctBy { it.id }
|
|
|
|
updatedMetas to chapters
|
|
}
|
|
|
|
return SetChapterMetasPayload(clientMutationId, updatedMetas, chapters)
|
|
}
|
|
|
|
data class DeleteChapterMetasItem(
|
|
val chapterIds: List<Int>,
|
|
val keys: List<String>? = null,
|
|
val prefixes: List<String>? = null,
|
|
)
|
|
|
|
data class DeleteChapterMetasInput(
|
|
val clientMutationId: String? = null,
|
|
val items: List<DeleteChapterMetasItem>,
|
|
)
|
|
|
|
data class DeleteChapterMetasPayload(
|
|
val clientMutationId: String?,
|
|
val metas: List<ChapterMetaType>,
|
|
val chapters: List<ChapterType>,
|
|
)
|
|
|
|
@RequireAuth
|
|
fun deleteChapterMetas(input: DeleteChapterMetasInput): DeleteChapterMetasPayload? {
|
|
val (clientMutationId, items) = input
|
|
|
|
items.forEach { item ->
|
|
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
|
|
"Either 'keys' or 'prefixes' must be provided for each item"
|
|
}
|
|
}
|
|
|
|
val (allDeletedMetas, allChapterIds) =
|
|
transaction {
|
|
val deletedMetas = mutableListOf<ChapterMetaType>()
|
|
val chapterIds = mutableSetOf<Int>()
|
|
|
|
items.forEach { item ->
|
|
val keyCondition: Op<Boolean>? =
|
|
item.keys?.takeIf { it.isNotEmpty() }?.let { ChapterMetaTable.key inList it }
|
|
|
|
val prefixCondition: Op<Boolean>? =
|
|
item.prefixes
|
|
?.filter { it.isNotEmpty() }
|
|
?.map { (ChapterMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
|
|
?.reduceOrNull { acc, op -> acc or op }
|
|
|
|
val metaKeyCondition =
|
|
if (keyCondition != null && prefixCondition != null) {
|
|
keyCondition or prefixCondition
|
|
} else {
|
|
keyCondition ?: prefixCondition!!
|
|
}
|
|
|
|
val condition = (ChapterMetaTable.ref inList item.chapterIds) and metaKeyCondition
|
|
|
|
deletedMetas +=
|
|
ChapterMetaTable
|
|
.selectAll()
|
|
.where { condition }
|
|
.map { ChapterMetaType(it) }
|
|
|
|
ChapterMetaTable.deleteWhere { condition }
|
|
chapterIds += item.chapterIds
|
|
}
|
|
|
|
deletedMetas to chapterIds
|
|
}
|
|
|
|
val chapters =
|
|
transaction {
|
|
ChapterTable
|
|
.selectAll()
|
|
.where { ChapterTable.id inList allChapterIds }
|
|
.map { ChapterType(it) }
|
|
.distinctBy { it.id }
|
|
}
|
|
|
|
return DeleteChapterMetasPayload(clientMutationId, allDeletedMetas, chapters)
|
|
}
|
|
|
|
data class FetchChapterPagesInput(
|
|
val clientMutationId: String? = null,
|
|
val chapterId: Int,
|
|
val format: String? = null,
|
|
) {
|
|
fun toParams(): Map<String, String> =
|
|
buildMap {
|
|
if (!format.isNullOrBlank()) {
|
|
put("format", format)
|
|
}
|
|
}
|
|
}
|
|
|
|
data class FetchChapterPagesPayload(
|
|
val clientMutationId: String?,
|
|
val pages: List<String>,
|
|
val chapter: ChapterType,
|
|
val syncConflict: SyncConflictInfoType?,
|
|
)
|
|
|
|
@RequireAuth
|
|
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<FetchChapterPagesPayload?> {
|
|
val (clientMutationId, chapterId) = input
|
|
val paramsMap = input.toParams()
|
|
|
|
return future {
|
|
var chapter = getChapterDownloadReadyById(chapterId)
|
|
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
|
|
var syncConflictInfo: SyncConflictInfoType? = null
|
|
|
|
if (syncResult != null) {
|
|
if (syncResult.isConflict) {
|
|
syncConflictInfo =
|
|
SyncConflictInfoType(
|
|
deviceName = syncResult.device,
|
|
remotePage = syncResult.pageRead,
|
|
)
|
|
}
|
|
|
|
if (syncResult.shouldUpdate) {
|
|
// Update DB for SILENT and RECEIVE
|
|
transaction {
|
|
ChapterTable.update({ ChapterTable.id eq chapter.id }) {
|
|
it[lastPageRead] = syncResult.pageRead
|
|
it[lastReadAt] = syncResult.timestamp
|
|
}
|
|
}
|
|
}
|
|
// For PROMPT, SILENT, and RECEIVE, return the remote progress
|
|
chapter =
|
|
chapter.copy(
|
|
lastPageRead = if (syncResult.shouldUpdate) syncResult.pageRead else chapter.lastPageRead,
|
|
lastReadAt = if (syncResult.shouldUpdate) syncResult.timestamp else chapter.lastReadAt,
|
|
)
|
|
}
|
|
|
|
val params =
|
|
buildString {
|
|
if (paramsMap.isNotEmpty()) {
|
|
append("?")
|
|
paramsMap.entries.forEach { entry ->
|
|
if (length > 1) {
|
|
append("&")
|
|
}
|
|
append(entry.key)
|
|
append("=")
|
|
append(URLEncoder.encode(entry.value, Charsets.UTF_8))
|
|
}
|
|
}
|
|
}
|
|
|
|
FetchChapterPagesPayload(
|
|
clientMutationId = clientMutationId,
|
|
pages =
|
|
List(chapter.pageCount) { index ->
|
|
"/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/${index}$params"
|
|
},
|
|
chapter = ChapterType(chapter),
|
|
syncConflict = syncConflictInfo,
|
|
)
|
|
}
|
|
}
|
|
}
|