mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 06:42:07 +01:00
Backup creation and restore gql endpoints (#587)
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
package suwayomi.tachidesk.graphql
|
||||
|
||||
import io.javalin.apibuilder.ApiBuilder.get
|
||||
import io.javalin.apibuilder.ApiBuilder.path
|
||||
import io.javalin.apibuilder.ApiBuilder.post
|
||||
import io.javalin.apibuilder.ApiBuilder.ws
|
||||
import suwayomi.tachidesk.graphql.controller.GraphQLController
|
||||
@@ -19,5 +20,9 @@ object GraphQL {
|
||||
|
||||
// graphql 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.websocket.WsConfig
|
||||
import suwayomi.tachidesk.graphql.server.TachideskGraphQLServer
|
||||
import suwayomi.tachidesk.graphql.server.TemporaryFileStorage
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
import kotlin.io.path.inputStream
|
||||
|
||||
object GraphQLController {
|
||||
private val server = TachideskGraphQLServer.create()
|
||||
@@ -30,6 +32,14 @@ object GraphQLController {
|
||||
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) {
|
||||
ws.onMessage { 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
|
||||
|
||||
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 io.javalin.http.Context
|
||||
import io.javalin.http.UploadedFile
|
||||
import io.javalin.plugin.json.jsonMapper
|
||||
import java.io.IOException
|
||||
|
||||
class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
|
||||
|
||||
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
|
||||
override suspend fun parseRequest(context: Context): GraphQLServerRequest? = try {
|
||||
context.bodyAsClass(GraphQLServerRequest::class.java)
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE", "UNCHECKED_CAST")
|
||||
override suspend fun parseRequest(context: Context): GraphQLServerRequest? {
|
||||
return try {
|
||||
val formParam = context.formParam("operation")
|
||||
?: 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.toSchema
|
||||
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.ChapterMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.MangaMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
||||
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
||||
import suwayomi.tachidesk.graphql.queries.BackupQuery
|
||||
import suwayomi.tachidesk.graphql.queries.CategoryQuery
|
||||
import suwayomi.tachidesk.graphql.queries.ChapterQuery
|
||||
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.GraphQLCursor
|
||||
import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
|
||||
import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload
|
||||
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KType
|
||||
@@ -35,6 +39,7 @@ 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
|
||||
UploadedFile::class -> GraphQLUpload
|
||||
else -> super.willGenerateGraphQLType(type)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +51,7 @@ val schema = toSchema(
|
||||
hooks = CustomSchemaGeneratorHooks()
|
||||
),
|
||||
queries = listOf(
|
||||
TopLevelObject(BackupQuery()),
|
||||
TopLevelObject(CategoryQuery()),
|
||||
TopLevelObject(ChapterQuery()),
|
||||
TopLevelObject(ExtensionQuery()),
|
||||
@@ -54,6 +60,7 @@ val schema = toSchema(
|
||||
TopLevelObject(SourceQuery())
|
||||
),
|
||||
mutations = listOf(
|
||||
TopLevelObject(BackupMutation()),
|
||||
TopLevelObject(CategoryMutation()),
|
||||
TopLevelObject(ChapterMutation()),
|
||||
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
|
||||
|
||||
object ProtoBackupExport : ProtoBackupBase() {
|
||||
suspend fun createBackup(flags: BackupFlags): InputStream {
|
||||
fun createBackup(flags: BackupFlags): InputStream {
|
||||
// Create root object
|
||||
|
||||
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
|
||||
* 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 okio.buffer
|
||||
import okio.gzip
|
||||
@@ -43,35 +46,55 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
|
||||
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 {
|
||||
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
|
||||
val backup = parser.decodeFromByteArray(BackupSerializer, backupString)
|
||||
return backupMutex.withLock {
|
||||
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
|
||||
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
|
||||
backupRestoreState.value = BackupRestoreState.RestoringCategories(backup.backupManga.size)
|
||||
// Restore categories
|
||||
if (backup.backupCategories.isNotEmpty()) {
|
||||
restoreCategories(backup.backupCategories)
|
||||
}
|
||||
}
|
||||
|
||||
// Store source mapping for error messages
|
||||
sourceMapping = backup.getSourceMap()
|
||||
val categoryMapping = transaction {
|
||||
backup.backupCategories.associate {
|
||||
it.order to CategoryTable.select { CategoryTable.name eq it.name }.first()[CategoryTable.id].value
|
||||
}
|
||||
}
|
||||
|
||||
// Restore individual manga
|
||||
backup.backupManga.forEach {
|
||||
restoreManga(it, backup.backupCategories, categoryMapping)
|
||||
}
|
||||
// Store source mapping for error messages
|
||||
sourceMapping = backup.getSourceMap()
|
||||
|
||||
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:
|
||||
${errors.joinToString("\n") { "${it.first} - ${it.second}" }}
|
||||
Restore Summary:
|
||||
@@ -81,10 +104,12 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
${validationResult.mangasMissingSources.joinToString("\n ")}
|
||||
- Missing Trackers:
|
||||
${validationResult.missingTrackers.joinToString("\n ")}
|
||||
""".trimIndent()
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
backupRestoreState.value = BackupRestoreState.Idle
|
||||
|
||||
return validationResult
|
||||
validationResult
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import okio.buffer
|
||||
import okio.gzip
|
||||
import okio.source
|
||||
@@ -21,7 +22,9 @@ object ProtoBackupValidator {
|
||||
data class ValidationResult(
|
||||
val missingSources: 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 {
|
||||
@@ -32,18 +35,9 @@ object ProtoBackupValidator {
|
||||
val sources = backup.getSourceMap()
|
||||
|
||||
val missingSources = transaction {
|
||||
sources
|
||||
.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
|
||||
.map { "${it.value} (${it.key})" }
|
||||
.sorted()
|
||||
sources.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
|
||||
}
|
||||
|
||||
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
|
||||
// .flatMap { it.tracking }
|
||||
// .map { it.syncId }
|
||||
@@ -56,10 +50,17 @@ object ProtoBackupValidator {
|
||||
// .map { context.getString(it.nameRes()) }
|
||||
// .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 backup = ProtoBackupImport.parser.decodeFromByteArray(BackupSerializer, backupString)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user