mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-05 03:44:36 -05:00
Backup creation and restore gql endpoints (#587)
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
package suwayomi.tachidesk.graphql
|
package suwayomi.tachidesk.graphql
|
||||||
|
|
||||||
import io.javalin.apibuilder.ApiBuilder.get
|
import io.javalin.apibuilder.ApiBuilder.get
|
||||||
|
import io.javalin.apibuilder.ApiBuilder.path
|
||||||
import io.javalin.apibuilder.ApiBuilder.post
|
import io.javalin.apibuilder.ApiBuilder.post
|
||||||
import io.javalin.apibuilder.ApiBuilder.ws
|
import io.javalin.apibuilder.ApiBuilder.ws
|
||||||
import suwayomi.tachidesk.graphql.controller.GraphQLController
|
import suwayomi.tachidesk.graphql.controller.GraphQLController
|
||||||
@@ -19,5 +20,9 @@ object GraphQL {
|
|||||||
|
|
||||||
// graphql playground
|
// graphql playground
|
||||||
get("graphql", GraphQLController::playground)
|
get("graphql", GraphQLController::playground)
|
||||||
|
|
||||||
|
path("graphql/files") {
|
||||||
|
get("backup/{file}", GraphQLController::retrieveFile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import io.javalin.http.ContentType
|
|||||||
import io.javalin.http.Context
|
import io.javalin.http.Context
|
||||||
import io.javalin.websocket.WsConfig
|
import io.javalin.websocket.WsConfig
|
||||||
import suwayomi.tachidesk.graphql.server.TachideskGraphQLServer
|
import suwayomi.tachidesk.graphql.server.TachideskGraphQLServer
|
||||||
|
import suwayomi.tachidesk.graphql.server.TemporaryFileStorage
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
|
import kotlin.io.path.inputStream
|
||||||
|
|
||||||
object GraphQLController {
|
object GraphQLController {
|
||||||
private val server = TachideskGraphQLServer.create()
|
private val server = TachideskGraphQLServer.create()
|
||||||
@@ -30,6 +32,14 @@ object GraphQLController {
|
|||||||
ctx.result(javaClass.getResourceAsStream("/graphql-playground.html")!!)
|
ctx.result(javaClass.getResourceAsStream("/graphql-playground.html")!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun retrieveFile(ctx: Context) {
|
||||||
|
val filename = ctx.pathParam("file")
|
||||||
|
val file = TemporaryFileStorage.retrieveFile(filename)
|
||||||
|
ctx.contentType("application/octet-stream")
|
||||||
|
ctx.header("Content-Disposition", """attachment; filename="$filename"""")
|
||||||
|
ctx.result(file.inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
fun webSocket(ws: WsConfig) {
|
fun webSocket(ws: WsConfig) {
|
||||||
ws.onMessage { ctx ->
|
ws.onMessage { ctx ->
|
||||||
server.handleSubscriptionMessage(ctx)
|
server.handleSubscriptionMessage(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.mutations
|
||||||
|
|
||||||
|
import io.javalin.http.UploadedFile
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import suwayomi.tachidesk.graphql.server.TemporaryFileStorage
|
||||||
|
import suwayomi.tachidesk.graphql.types.BackupRestoreState
|
||||||
|
import suwayomi.tachidesk.graphql.types.BackupRestoreStatus
|
||||||
|
import suwayomi.tachidesk.graphql.types.toStatus
|
||||||
|
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
||||||
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
|
||||||
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
|
||||||
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class BackupMutation {
|
||||||
|
data class RestoreBackupInput(
|
||||||
|
val clientMutationId: String? = null,
|
||||||
|
val backup: UploadedFile
|
||||||
|
)
|
||||||
|
data class RestoreBackupPayload(
|
||||||
|
val clientMutationId: String?,
|
||||||
|
val status: BackupRestoreStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
fun restoreBackup(
|
||||||
|
input: RestoreBackupInput
|
||||||
|
): CompletableFuture<RestoreBackupPayload> {
|
||||||
|
val (clientMutationId, backup) = input
|
||||||
|
|
||||||
|
return future {
|
||||||
|
GlobalScope.launch {
|
||||||
|
ProtoBackupImport.performRestore(backup.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = withTimeout(10.seconds) {
|
||||||
|
ProtoBackupImport.backupRestoreState.first {
|
||||||
|
it != ProtoBackupImport.BackupRestoreState.Idle
|
||||||
|
}.toStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
RestoreBackupPayload(clientMutationId, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreateBackupInput(
|
||||||
|
val clientMutationId: String? = null,
|
||||||
|
val includeChapters: Boolean? = null,
|
||||||
|
val includeCategories: Boolean? = null
|
||||||
|
)
|
||||||
|
data class CreateBackupPayload(
|
||||||
|
val clientMutationId: String?,
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
fun createBackup(
|
||||||
|
input: CreateBackupInput? = null
|
||||||
|
): CreateBackupPayload {
|
||||||
|
val currentDate = SimpleDateFormat("yyyy-MM-dd_HH-mm").format(Date())
|
||||||
|
val filename = "tachidesk_$currentDate.proto.gz"
|
||||||
|
|
||||||
|
val backup = ProtoBackupExport.createBackup(
|
||||||
|
BackupFlags(
|
||||||
|
includeManga = true,
|
||||||
|
includeCategories = input?.includeCategories ?: true,
|
||||||
|
includeChapters = input?.includeChapters ?: true,
|
||||||
|
includeTracking = true,
|
||||||
|
includeHistory = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
TemporaryFileStorage.saveFile(filename, backup)
|
||||||
|
|
||||||
|
return CreateBackupPayload(
|
||||||
|
clientMutationId = input?.clientMutationId,
|
||||||
|
url = "/api/graphql/files/backup/$filename"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.queries
|
||||||
|
|
||||||
|
import io.javalin.http.UploadedFile
|
||||||
|
import suwayomi.tachidesk.graphql.types.BackupRestoreStatus
|
||||||
|
import suwayomi.tachidesk.graphql.types.toStatus
|
||||||
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
|
||||||
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator
|
||||||
|
|
||||||
|
class BackupQuery {
|
||||||
|
data class ValidateBackupInput(
|
||||||
|
val backup: UploadedFile
|
||||||
|
)
|
||||||
|
data class ValidateBackupSource(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
data class ValidateBackupResult(
|
||||||
|
val missingSources: List<ValidateBackupSource>
|
||||||
|
)
|
||||||
|
fun validateBackup(
|
||||||
|
input: ValidateBackupInput
|
||||||
|
): ValidateBackupResult {
|
||||||
|
val result = ProtoBackupValidator.validate(input.backup.content)
|
||||||
|
return ValidateBackupResult(
|
||||||
|
result.missingSourceIds.map { ValidateBackupSource(it.first, it.second) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreStatus(): BackupRestoreStatus {
|
||||||
|
return ProtoBackupImport.backupRestoreState.value.toStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.queries
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.sql.select
|
||||||
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
|
import org.kodein.di.DI
|
||||||
|
import org.kodein.di.conf.global
|
||||||
|
import org.kodein.di.instance
|
||||||
|
import suwayomi.tachidesk.graphql.types.MangaType
|
||||||
|
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||||
|
import suwayomi.tachidesk.manga.impl.update.JobStatus
|
||||||
|
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
|
||||||
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
|
|
||||||
|
class UpdaterQuery {
|
||||||
|
sealed interface UpdaterStatus {
|
||||||
|
data class UpdaterJob(val status: JobStatus, val manga: MangaType)
|
||||||
|
|
||||||
|
data class Running(val jobs: Map<JobStatus, List<MangaType>>) : UpdaterStatus
|
||||||
|
|
||||||
|
// data class Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private val updater by DI.global.instance<IUpdater>()
|
||||||
|
|
||||||
|
fun updaterStatus() {
|
||||||
|
val status = updater.status.value
|
||||||
|
if (status.running) {
|
||||||
|
val mangaIds = status.statusMap.values.flatMap { mangas -> mangas.map { it.id } }
|
||||||
|
val mangaMap = transaction {
|
||||||
|
MangaTable.select { MangaTable.id inList mangaIds }
|
||||||
|
.map { MangaType(it) }
|
||||||
|
.associateBy { it.id }
|
||||||
|
}
|
||||||
|
UpdaterStatus.Running(
|
||||||
|
status.statusMap.mapValues { (_, mangas) ->
|
||||||
|
mangas.mapNotNull { mangaMap[it.id] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
UpdaterSocket
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,102 @@
|
|||||||
package suwayomi.tachidesk.graphql.server
|
package suwayomi.tachidesk.graphql.server
|
||||||
|
|
||||||
import com.expediagroup.graphql.server.execution.GraphQLRequestParser
|
import com.expediagroup.graphql.server.execution.GraphQLRequestParser
|
||||||
|
import com.expediagroup.graphql.server.types.GraphQLBatchRequest
|
||||||
|
import com.expediagroup.graphql.server.types.GraphQLRequest
|
||||||
import com.expediagroup.graphql.server.types.GraphQLServerRequest
|
import com.expediagroup.graphql.server.types.GraphQLServerRequest
|
||||||
import io.javalin.http.Context
|
import io.javalin.http.Context
|
||||||
|
import io.javalin.http.UploadedFile
|
||||||
|
import io.javalin.plugin.json.jsonMapper
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
|
class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
|
||||||
|
|
||||||
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
|
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE", "UNCHECKED_CAST")
|
||||||
override suspend fun parseRequest(context: Context): GraphQLServerRequest? = try {
|
override suspend fun parseRequest(context: Context): GraphQLServerRequest? {
|
||||||
context.bodyAsClass(GraphQLServerRequest::class.java)
|
return try {
|
||||||
} catch (e: IOException) {
|
val formParam = context.formParam("operation")
|
||||||
null
|
?: return context.bodyAsClass(GraphQLServerRequest::class.java)
|
||||||
|
|
||||||
|
val request = context.jsonMapper().fromJsonString(
|
||||||
|
formParam,
|
||||||
|
GraphQLServerRequest::class.java
|
||||||
|
)
|
||||||
|
val map = context.formParam("map")?.let {
|
||||||
|
context.jsonMapper().fromJsonString(
|
||||||
|
it,
|
||||||
|
Map::class.java as Class<Map<String, List<String>>>
|
||||||
|
)
|
||||||
|
}.orEmpty()
|
||||||
|
|
||||||
|
val filesMap = map.keys
|
||||||
|
.sortedBy { it.toIntOrNull() }
|
||||||
|
.map { context.uploadedFile(it) }
|
||||||
|
|
||||||
|
val mapItems = map.flatMap { (index, variables) ->
|
||||||
|
val indexInt = index.toIntOrNull() ?: return@flatMap emptyList()
|
||||||
|
val file = filesMap.getOrNull(indexInt)
|
||||||
|
variables.map { fullVariable ->
|
||||||
|
val variable = fullVariable.removePrefix("variables.").substringBefore('.')
|
||||||
|
val listIndex = fullVariable.substringAfterLast('.').toIntOrNull()
|
||||||
|
MapItem(
|
||||||
|
indexInt,
|
||||||
|
variable,
|
||||||
|
listIndex,
|
||||||
|
file
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.groupBy { it.variable }
|
||||||
|
|
||||||
|
when (request) {
|
||||||
|
is GraphQLRequest -> {
|
||||||
|
request.copy(variables = request.variables?.modifyFiles(mapItems))
|
||||||
|
}
|
||||||
|
is GraphQLBatchRequest -> {
|
||||||
|
request.copy(
|
||||||
|
requests = request.requests.map {
|
||||||
|
it.copy(
|
||||||
|
variables = it.variables?.modifyFiles(mapItems)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MapItem(
|
||||||
|
val index: Int,
|
||||||
|
val variable: String,
|
||||||
|
val listIndex: Int?,
|
||||||
|
val file: UploadedFile?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example [this]: { "file": null }
|
||||||
|
* Example: '{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) { id } }", "variables": { "file": null } }'
|
||||||
|
* Example map "{ "0": ["variables.file"] }"
|
||||||
|
* TODO nested objects
|
||||||
|
*/
|
||||||
|
private fun Map<String, Any?>.modifyFiles(map: Map<String, List<MapItem>>): Map<String, Any?> {
|
||||||
|
return mapValues { (name, value) ->
|
||||||
|
if (map.containsKey(name)) {
|
||||||
|
val items = map[name].orEmpty()
|
||||||
|
if (items.size > 1) {
|
||||||
|
if (value is List<*>) {
|
||||||
|
value.mapIndexed { index, any ->
|
||||||
|
any ?: items.firstOrNull { it.listIndex == index }?.file
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value ?: items.firstOrNull()?.file
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import com.expediagroup.graphql.generator.TopLevelObject
|
|||||||
import com.expediagroup.graphql.generator.hooks.FlowSubscriptionSchemaGeneratorHooks
|
import com.expediagroup.graphql.generator.hooks.FlowSubscriptionSchemaGeneratorHooks
|
||||||
import com.expediagroup.graphql.generator.toSchema
|
import com.expediagroup.graphql.generator.toSchema
|
||||||
import graphql.schema.GraphQLType
|
import graphql.schema.GraphQLType
|
||||||
|
import io.javalin.http.UploadedFile
|
||||||
|
import suwayomi.tachidesk.graphql.mutations.BackupMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.CategoryMutation
|
import suwayomi.tachidesk.graphql.mutations.CategoryMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.ChapterMutation
|
import suwayomi.tachidesk.graphql.mutations.ChapterMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
|
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.MangaMutation
|
import suwayomi.tachidesk.graphql.mutations.MangaMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
||||||
|
import suwayomi.tachidesk.graphql.queries.BackupQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.CategoryQuery
|
import suwayomi.tachidesk.graphql.queries.CategoryQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.ChapterQuery
|
import suwayomi.tachidesk.graphql.queries.ChapterQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
|
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
|
||||||
@@ -27,6 +30,7 @@ import suwayomi.tachidesk.graphql.queries.SourceQuery
|
|||||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
|
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
|
import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload
|
||||||
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
|
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.reflect.KType
|
import kotlin.reflect.KType
|
||||||
@@ -35,6 +39,7 @@ class CustomSchemaGeneratorHooks : FlowSubscriptionSchemaGeneratorHooks() {
|
|||||||
override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) {
|
override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) {
|
||||||
Long::class -> GraphQLLongAsString // encode to string for JS
|
Long::class -> GraphQLLongAsString // encode to string for JS
|
||||||
Cursor::class -> GraphQLCursor
|
Cursor::class -> GraphQLCursor
|
||||||
|
UploadedFile::class -> GraphQLUpload
|
||||||
else -> super.willGenerateGraphQLType(type)
|
else -> super.willGenerateGraphQLType(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +51,7 @@ val schema = toSchema(
|
|||||||
hooks = CustomSchemaGeneratorHooks()
|
hooks = CustomSchemaGeneratorHooks()
|
||||||
),
|
),
|
||||||
queries = listOf(
|
queries = listOf(
|
||||||
|
TopLevelObject(BackupQuery()),
|
||||||
TopLevelObject(CategoryQuery()),
|
TopLevelObject(CategoryQuery()),
|
||||||
TopLevelObject(ChapterQuery()),
|
TopLevelObject(ChapterQuery()),
|
||||||
TopLevelObject(ExtensionQuery()),
|
TopLevelObject(ExtensionQuery()),
|
||||||
@@ -54,6 +60,7 @@ val schema = toSchema(
|
|||||||
TopLevelObject(SourceQuery())
|
TopLevelObject(SourceQuery())
|
||||||
),
|
),
|
||||||
mutations = listOf(
|
mutations = listOf(
|
||||||
|
TopLevelObject(BackupMutation()),
|
||||||
TopLevelObject(CategoryMutation()),
|
TopLevelObject(CategoryMutation()),
|
||||||
TopLevelObject(ChapterMutation()),
|
TopLevelObject(ChapterMutation()),
|
||||||
TopLevelObject(ExtensionMutation()),
|
TopLevelObject(ExtensionMutation()),
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.server
|
||||||
|
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
import kotlin.io.path.ExperimentalPathApi
|
||||||
|
import kotlin.io.path.deleteIfExists
|
||||||
|
import kotlin.io.path.deleteRecursively
|
||||||
|
import kotlin.io.path.outputStream
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPathApi::class)
|
||||||
|
object TemporaryFileStorage {
|
||||||
|
private val folder = Files.createTempDirectory("Tachidesk")
|
||||||
|
|
||||||
|
init {
|
||||||
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
thread(start = false) {
|
||||||
|
folder.deleteRecursively()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
fun saveFile(name: String, content: InputStream) {
|
||||||
|
val file = folder.resolve(name)
|
||||||
|
content.use { inStream ->
|
||||||
|
file.outputStream().use {
|
||||||
|
inStream.copyTo(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GlobalScope.launch {
|
||||||
|
delay(1.days)
|
||||||
|
file.deleteIfExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retrieveFile(name: String): Path {
|
||||||
|
return folder.resolve(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.server.primitives
|
||||||
|
|
||||||
|
import graphql.GraphQLContext
|
||||||
|
import graphql.scalar.CoercingUtil
|
||||||
|
import graphql.schema.Coercing
|
||||||
|
import graphql.schema.CoercingParseValueException
|
||||||
|
import graphql.schema.CoercingSerializeException
|
||||||
|
import graphql.schema.GraphQLScalarType
|
||||||
|
import io.javalin.http.UploadedFile
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
val GraphQLUpload = GraphQLScalarType.newScalar()
|
||||||
|
.name("Upload")
|
||||||
|
.description("A file part in a multipart request")
|
||||||
|
.coercing(GraphqlUploadCoercing())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private class GraphqlUploadCoercing : Coercing<UploadedFile, Void?> {
|
||||||
|
private fun parseValueImpl(input: Any, locale: Locale): UploadedFile {
|
||||||
|
if (input !is UploadedFile) {
|
||||||
|
throw CoercingParseValueException(
|
||||||
|
CoercingUtil.i18nMsg(
|
||||||
|
locale,
|
||||||
|
"String.unexpectedRawValueType",
|
||||||
|
CoercingUtil.typeName(input)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("")
|
||||||
|
override fun serialize(dataFetcherResult: Any): Void? {
|
||||||
|
throw CoercingSerializeException("Upload is an input-only type")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(CoercingSerializeException::class)
|
||||||
|
override fun serialize(
|
||||||
|
dataFetcherResult: Any,
|
||||||
|
graphQLContext: GraphQLContext,
|
||||||
|
locale: Locale
|
||||||
|
): Void? {
|
||||||
|
throw CoercingSerializeException("Upload is an input-only type")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("")
|
||||||
|
override fun parseValue(input: Any): UploadedFile {
|
||||||
|
return parseValueImpl(input, Locale.getDefault())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(CoercingParseValueException::class)
|
||||||
|
override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): UploadedFile {
|
||||||
|
return parseValueImpl(input, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("")
|
||||||
|
override fun parseLiteral(input: Any): UploadedFile {
|
||||||
|
return parseValueImpl(input, Locale.getDefault())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.types
|
||||||
|
|
||||||
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
|
||||||
|
|
||||||
|
enum class BackupRestoreState {
|
||||||
|
IDLE,
|
||||||
|
RESTORING_CATEGORIES,
|
||||||
|
RESTORING_MANGA
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BackupRestoreStatus(
|
||||||
|
val state: BackupRestoreState,
|
||||||
|
val totalManga: Int,
|
||||||
|
val mangaProgress: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ProtoBackupImport.BackupRestoreState.toStatus(): BackupRestoreStatus {
|
||||||
|
return when (this) {
|
||||||
|
ProtoBackupImport.BackupRestoreState.Idle -> BackupRestoreStatus(
|
||||||
|
state = BackupRestoreState.IDLE,
|
||||||
|
totalManga = 0,
|
||||||
|
mangaProgress = 0
|
||||||
|
)
|
||||||
|
is ProtoBackupImport.BackupRestoreState.RestoringCategories -> BackupRestoreStatus(
|
||||||
|
state = BackupRestoreState.RESTORING_CATEGORIES,
|
||||||
|
totalManga = totalManga,
|
||||||
|
mangaProgress = 0
|
||||||
|
)
|
||||||
|
is ProtoBackupImport.BackupRestoreState.RestoringManga -> BackupRestoreStatus(
|
||||||
|
state = BackupRestoreState.RESTORING_MANGA,
|
||||||
|
totalManga = totalManga,
|
||||||
|
mangaProgress = current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ import java.io.InputStream
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object ProtoBackupExport : ProtoBackupBase() {
|
object ProtoBackupExport : ProtoBackupBase() {
|
||||||
suspend fun createBackup(flags: BackupFlags): InputStream {
|
fun createBackup(flags: BackupFlags): InputStream {
|
||||||
// Create root object
|
// Create root object
|
||||||
|
|
||||||
val databaseManga = transaction { MangaTable.select { MangaTable.inLibrary eq true } }
|
val databaseManga = transaction { MangaTable.select { MangaTable.inLibrary eq true } }
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ package suwayomi.tachidesk.manga.impl.backup.proto
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.gzip
|
import okio.gzip
|
||||||
@@ -43,35 +46,55 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
|
|
||||||
private val errors = mutableListOf<Pair<Date, String>>()
|
private val errors = mutableListOf<Pair<Date, String>>()
|
||||||
|
|
||||||
|
private val backupMutex = Mutex()
|
||||||
|
sealed class BackupRestoreState {
|
||||||
|
object Idle : BackupRestoreState()
|
||||||
|
data class RestoringCategories(val totalManga: Int) : BackupRestoreState()
|
||||||
|
data class RestoringManga(val current: Int, val totalManga: Int, val title: String) : BackupRestoreState()
|
||||||
|
}
|
||||||
|
|
||||||
|
val backupRestoreState = MutableStateFlow<BackupRestoreState>(BackupRestoreState.Idle)
|
||||||
|
|
||||||
suspend fun performRestore(sourceStream: InputStream): ValidationResult {
|
suspend fun performRestore(sourceStream: InputStream): ValidationResult {
|
||||||
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
|
return backupMutex.withLock {
|
||||||
val backup = parser.decodeFromByteArray(BackupSerializer, backupString)
|
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
|
||||||
|
val backup = parser.decodeFromByteArray(BackupSerializer, backupString)
|
||||||
|
|
||||||
val validationResult = validate(backup)
|
val validationResult = validate(backup)
|
||||||
|
|
||||||
restoreAmount = backup.backupManga.size + 1 // +1 for categories
|
restoreAmount = backup.backupManga.size + 1 // +1 for categories
|
||||||
|
|
||||||
// Restore categories
|
backupRestoreState.value = BackupRestoreState.RestoringCategories(backup.backupManga.size)
|
||||||
if (backup.backupCategories.isNotEmpty()) {
|
// Restore categories
|
||||||
restoreCategories(backup.backupCategories)
|
if (backup.backupCategories.isNotEmpty()) {
|
||||||
}
|
restoreCategories(backup.backupCategories)
|
||||||
|
|
||||||
val categoryMapping = transaction {
|
|
||||||
backup.backupCategories.associate {
|
|
||||||
it.order to CategoryTable.select { CategoryTable.name eq it.name }.first()[CategoryTable.id].value
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Store source mapping for error messages
|
val categoryMapping = transaction {
|
||||||
sourceMapping = backup.getSourceMap()
|
backup.backupCategories.associate {
|
||||||
|
it.order to CategoryTable.select { CategoryTable.name eq it.name }.first()[CategoryTable.id].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Restore individual manga
|
// Store source mapping for error messages
|
||||||
backup.backupManga.forEach {
|
sourceMapping = backup.getSourceMap()
|
||||||
restoreManga(it, backup.backupCategories, categoryMapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info {
|
// Restore individual manga
|
||||||
"""
|
backup.backupManga.forEachIndexed { index, manga ->
|
||||||
|
backupRestoreState.value = BackupRestoreState.RestoringManga(
|
||||||
|
current = index + 1,
|
||||||
|
totalManga = backup.backupManga.size,
|
||||||
|
title = manga.title
|
||||||
|
)
|
||||||
|
restoreManga(
|
||||||
|
backupManga = manga,
|
||||||
|
backupCategories = backup.backupCategories,
|
||||||
|
categoryMapping = categoryMapping
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info {
|
||||||
|
"""
|
||||||
Restore Errors:
|
Restore Errors:
|
||||||
${errors.joinToString("\n") { "${it.first} - ${it.second}" }}
|
${errors.joinToString("\n") { "${it.first} - ${it.second}" }}
|
||||||
Restore Summary:
|
Restore Summary:
|
||||||
@@ -81,10 +104,12 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
${validationResult.mangasMissingSources.joinToString("\n ")}
|
${validationResult.mangasMissingSources.joinToString("\n ")}
|
||||||
- Missing Trackers:
|
- Missing Trackers:
|
||||||
${validationResult.missingTrackers.joinToString("\n ")}
|
${validationResult.missingTrackers.joinToString("\n ")}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
backupRestoreState.value = BackupRestoreState.Idle
|
||||||
|
|
||||||
return validationResult
|
validationResult
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreCategories(backupCategories: List<BackupCategory>) {
|
private fun restoreCategories(backupCategories: List<BackupCategory>) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.gzip
|
import okio.gzip
|
||||||
import okio.source
|
import okio.source
|
||||||
@@ -21,7 +22,9 @@ object ProtoBackupValidator {
|
|||||||
data class ValidationResult(
|
data class ValidationResult(
|
||||||
val missingSources: List<String>,
|
val missingSources: List<String>,
|
||||||
val missingTrackers: List<String>,
|
val missingTrackers: List<String>,
|
||||||
val mangasMissingSources: List<String>
|
val mangasMissingSources: List<String>,
|
||||||
|
@JsonIgnore
|
||||||
|
val missingSourceIds: List<Pair<Long, String>>
|
||||||
)
|
)
|
||||||
|
|
||||||
fun validate(backup: Backup): ValidationResult {
|
fun validate(backup: Backup): ValidationResult {
|
||||||
@@ -32,18 +35,9 @@ object ProtoBackupValidator {
|
|||||||
val sources = backup.getSourceMap()
|
val sources = backup.getSourceMap()
|
||||||
|
|
||||||
val missingSources = transaction {
|
val missingSources = transaction {
|
||||||
sources
|
sources.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
|
||||||
.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
|
|
||||||
.map { "${it.value} (${it.key})" }
|
|
||||||
.sorted()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val brokenSourceIds = backup.brokenBackupSources.map { it.sourceId }
|
|
||||||
|
|
||||||
val mangasMissingSources = backup.backupManga
|
|
||||||
.filter { it.source in brokenSourceIds }
|
|
||||||
.map { manga -> "${manga.title} (from ${backup.brokenBackupSources.first { it.sourceId == manga.source }.name})" }
|
|
||||||
|
|
||||||
// val trackers = backup.backupManga
|
// val trackers = backup.backupManga
|
||||||
// .flatMap { it.tracking }
|
// .flatMap { it.tracking }
|
||||||
// .map { it.syncId }
|
// .map { it.syncId }
|
||||||
@@ -56,10 +50,17 @@ object ProtoBackupValidator {
|
|||||||
// .map { context.getString(it.nameRes()) }
|
// .map { context.getString(it.nameRes()) }
|
||||||
// .sorted()
|
// .sorted()
|
||||||
|
|
||||||
return ValidationResult(missingSources, missingTrackers, mangasMissingSources)
|
return ValidationResult(
|
||||||
|
missingSources
|
||||||
|
.map { "${it.value} (${it.key})" }
|
||||||
|
.sorted(),
|
||||||
|
missingTrackers,
|
||||||
|
emptyList(),
|
||||||
|
missingSources.toList()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun validate(sourceStream: InputStream): ValidationResult {
|
fun validate(sourceStream: InputStream): ValidationResult {
|
||||||
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
|
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
|
||||||
val backup = ProtoBackupImport.parser.decodeFromByteArray(BackupSerializer, backupString)
|
val backup = ProtoBackupImport.parser.decodeFromByteArray(BackupSerializer, backupString)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user