mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-10 06:42:05 +01:00
Update Backups to Protobuf, improve backup creation handling
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import org.gradle.api.JavaVersion
|
||||
|
||||
object Config {
|
||||
const val tachideskVersion = "v0.4.6"
|
||||
const val tachideskVersion = "v0.4.7"
|
||||
// Bump this when updating the Tachidesk version or Preview commit
|
||||
const val serverCode = 2
|
||||
const val serverCode = 3
|
||||
const val preview = true
|
||||
const val previewCommit = "a76a6d2798f2e8d7268fc66a0bb669d963613daf"
|
||||
const val previewCommit = "da44d3b2b48357d10e4c28e3b01fed4d2465115c"
|
||||
|
||||
val jvmTarget = JavaVersion.VERSION_15
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* 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 ca.gosyer.data.models
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Backup(
|
||||
val categories: List<List<@Contextual Any?>> = emptyList(),
|
||||
val mangas: List<Manga>,
|
||||
val version: Int = 1
|
||||
) {
|
||||
@Serializable
|
||||
data class Manga(
|
||||
val manga: List<@Contextual Any?>,
|
||||
val chapters: List<Chapter> = emptyList(),
|
||||
val categories: List<String> = emptyList(),
|
||||
val history: List<List<@Contextual Any?>> = emptyList(),
|
||||
val track: List<Track> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
@SerialName("u")
|
||||
val url: String,
|
||||
@SerialName("r")
|
||||
val read: Int = 0,
|
||||
@SerialName("b")
|
||||
val bookmarked: Int = 0,
|
||||
@SerialName("l")
|
||||
val lastRead: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Track(
|
||||
@SerialName("l")
|
||||
val lastRead: Int,
|
||||
@SerialName("ml")
|
||||
val libraryId: Int,
|
||||
@SerialName("r")
|
||||
val mediaId: Int,
|
||||
@SerialName("s")
|
||||
val syncId: Int,
|
||||
@SerialName("t")
|
||||
val title: String,
|
||||
@SerialName("u")
|
||||
val url: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ca.gosyer.data.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BackupValidationResult(
|
||||
val missingSources: List<String>,
|
||||
val missingTrackers: List<String>
|
||||
)
|
||||
@@ -6,24 +6,21 @@
|
||||
|
||||
package ca.gosyer.data.server.interactions
|
||||
|
||||
import ca.gosyer.data.models.Backup
|
||||
import ca.gosyer.data.models.BackupValidationResult
|
||||
import ca.gosyer.data.server.Http
|
||||
import ca.gosyer.data.server.ServerPreferences
|
||||
import ca.gosyer.data.server.requests.backupExportRequest
|
||||
import ca.gosyer.data.server.requests.backupFileExportRequest
|
||||
import ca.gosyer.data.server.requests.backupFileImportRequest
|
||||
import ca.gosyer.data.server.requests.backupImportRequest
|
||||
import ca.gosyer.data.server.requests.validateBackupFileRequest
|
||||
import ca.gosyer.util.lang.withIOContext
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.forms.submitFormWithBinaryData
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.Headers
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.contentType
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -37,10 +34,10 @@ class BackupInteractionHandler @Inject constructor(
|
||||
serverUrl + backupFileImportRequest(),
|
||||
formData = formData {
|
||||
append(
|
||||
"backup.json", file.readBytes(),
|
||||
"backup.proto.gz", file.readBytes(),
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
append(HttpHeaders.ContentDisposition, "filename=backup.json")
|
||||
append(HttpHeaders.ContentType, ContentType.MultiPart.FormData.toString())
|
||||
append(HttpHeaders.ContentDisposition, "filename=backup.proto.gz")
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -48,14 +45,20 @@ class BackupInteractionHandler @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun importBackup(backup: Backup, block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
|
||||
client.post<HttpResponse>(
|
||||
serverUrl + backupImportRequest()
|
||||
) {
|
||||
contentType(ContentType.Application.Json)
|
||||
body = backup
|
||||
block()
|
||||
}
|
||||
suspend fun validateBackupFile(file: File, block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
|
||||
client.submitFormWithBinaryData<BackupValidationResult>(
|
||||
serverUrl + validateBackupFileRequest(),
|
||||
formData = formData {
|
||||
append(
|
||||
"backup.proto.gz", file.readBytes(),
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, ContentType.MultiPart.FormData.toString())
|
||||
append(HttpHeaders.ContentDisposition, "filename=backup.proto.gz")
|
||||
}
|
||||
)
|
||||
},
|
||||
block = block
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun exportBackupFile(block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
|
||||
@@ -64,11 +67,4 @@ class BackupInteractionHandler @Inject constructor(
|
||||
block
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun exportBackup(block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
|
||||
client.get<Backup>(
|
||||
serverUrl + backupExportRequest(),
|
||||
block
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,20 @@ package ca.gosyer.data.server.requests
|
||||
|
||||
@Post
|
||||
fun backupImportRequest() =
|
||||
"/api/v1/backup/legacy/import"
|
||||
"/api/v1/backup/import"
|
||||
|
||||
@Post
|
||||
fun backupFileImportRequest() =
|
||||
"/api/v1/backup/legacy/import/file"
|
||||
"/api/v1/backup/import/file"
|
||||
|
||||
@Post
|
||||
fun backupExportRequest() =
|
||||
"/api/v1/backup/legacy/export"
|
||||
"/api/v1/backup/export"
|
||||
|
||||
@Post
|
||||
fun backupFileExportRequest() =
|
||||
"/api/v1/backup/legacy/export/file"
|
||||
"/api/v1/backup/export/file"
|
||||
|
||||
@Post
|
||||
fun validateBackupFileRequest() =
|
||||
"/api/v1/backup/validate/file"
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
@@ -25,7 +26,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ca.gosyer.data.server.interactions.BackupInteractionHandler
|
||||
import ca.gosyer.data.server.interactions.SourceInteractionHandler
|
||||
import ca.gosyer.ui.base.WindowDialog
|
||||
import ca.gosyer.ui.base.components.Toolbar
|
||||
import ca.gosyer.ui.base.prefs.PreferenceRow
|
||||
@@ -40,6 +40,7 @@ import ca.gosyer.util.system.fileSaver
|
||||
import com.github.zsoltk.compose.router.BackStack
|
||||
import io.ktor.client.features.onDownload
|
||||
import io.ktor.client.features.onUpload
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.javaio.copyTo
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -47,17 +48,11 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class SettingsBackupViewModel @Inject constructor(
|
||||
private val backupHandler: BackupInteractionHandler,
|
||||
private val sourceHandler: SourceInteractionHandler
|
||||
private val backupHandler: BackupInteractionHandler
|
||||
) : ViewModel() {
|
||||
private val _restoring = MutableStateFlow(false)
|
||||
val restoring = _restoring.asStateFlow()
|
||||
@@ -74,6 +69,8 @@ class SettingsBackupViewModel @Inject constructor(
|
||||
val creatingProgress = _creatingProgress.asStateFlow()
|
||||
private val _creatingStatus = MutableStateFlow<Status>(Status.Nothing)
|
||||
internal val creatingStatus = _creatingStatus.asStateFlow()
|
||||
private val _createFlow = MutableSharedFlow<Pair<String, (File) -> Unit>>()
|
||||
val createFlow = _createFlow.asSharedFlow()
|
||||
|
||||
fun restoreFile(file: File?) {
|
||||
scope.launch {
|
||||
@@ -83,17 +80,11 @@ class SettingsBackupViewModel @Inject constructor(
|
||||
_restoring.value = false
|
||||
} else {
|
||||
try {
|
||||
val sources =
|
||||
Json.decodeFromString<JsonObject>(file.readText())["extensions"]!!.jsonArray.associate {
|
||||
val items = it.jsonPrimitive.content.split(":")
|
||||
items[0].toLong() to items[1]
|
||||
}
|
||||
val installedSources = sourceHandler.getSourceList().associateBy { it.id }
|
||||
val missingSources = sources.filter { installedSources[it.key] == null }
|
||||
val (missingSources) = backupHandler.validateBackupFile(file)
|
||||
if (missingSources.isEmpty()) {
|
||||
restoreBackup(file)
|
||||
} else {
|
||||
_missingSourceFlow.emit(file to missingSources.values.toList())
|
||||
_missingSourceFlow.emit(file to missingSources)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
info(e) { "Error importing backup" }
|
||||
@@ -131,33 +122,35 @@ class SettingsBackupViewModel @Inject constructor(
|
||||
_restoring.value = false
|
||||
}
|
||||
|
||||
fun createFile(file: File?) {
|
||||
fun exportBackup() {
|
||||
scope.launch {
|
||||
if (file == null) {
|
||||
info { "Invalid file ${file?.absolutePath}" }
|
||||
} else {
|
||||
if (file.exists()) file.delete()
|
||||
_creatingStatus.value = Status.Nothing
|
||||
_creatingProgress.value = null
|
||||
_creating.value = true
|
||||
try {
|
||||
val backup = backupHandler.exportBackupFile {
|
||||
onDownload { bytesSentTotal, contentLength ->
|
||||
_creatingProgress.value = (bytesSentTotal.toFloat() / contentLength).coerceAtMost(0.99F)
|
||||
_creatingStatus.value = Status.Nothing
|
||||
_creatingProgress.value = null
|
||||
_creating.value = true
|
||||
val backup = try {
|
||||
backupHandler.exportBackupFile {
|
||||
onDownload { bytesSentTotal, contentLength ->
|
||||
_creatingProgress.value = (bytesSentTotal.toFloat() / contentLength).coerceAtMost(0.99F)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
info(e) { "Error exporting backup" }
|
||||
_creatingStatus.value = Status.Error
|
||||
e.throwIfCancellation()
|
||||
null
|
||||
}
|
||||
_creatingProgress.value = 1.0F
|
||||
if (backup != null && backup.status.isSuccess()) {
|
||||
_createFlow.emit(
|
||||
(backup.headers["content-disposition"]?.substringAfter("filename=")?.trim('"') ?: "backup") to {
|
||||
scope.launch {
|
||||
it.outputStream().use {
|
||||
backup.content.copyTo(it)
|
||||
}
|
||||
_creatingStatus.value = Status.Success
|
||||
}
|
||||
}
|
||||
file.outputStream().use {
|
||||
backup.content.copyTo(it)
|
||||
}
|
||||
_creatingStatus.value = Status.Success
|
||||
} catch (e: Exception) {
|
||||
info(e) { "Error exporting backup" }
|
||||
_creatingStatus.value = Status.Error
|
||||
e.throwIfCancellation()
|
||||
} finally {
|
||||
_creatingProgress.value = 1.0F
|
||||
_creating.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,8 +174,17 @@ fun SettingsBackupScreen(navController: BackStack<Route>) {
|
||||
val creatingProgress by vm.creatingProgress.collectAsState()
|
||||
val creatingStatus by vm.creatingStatus.collectAsState()
|
||||
LaunchedEffect(Unit) {
|
||||
vm.missingSourceFlow.collect { (backup, sources) ->
|
||||
openMissingSourcesDialog(sources, { vm.restoreBackup(backup) }, vm::stopRestore)
|
||||
launch {
|
||||
vm.missingSourceFlow.collect { (backup, sources) ->
|
||||
openMissingSourcesDialog(sources, { vm.restoreBackup(backup) }, vm::stopRestore)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
vm.createFlow.collect { (filename, function) ->
|
||||
fileSaver(filename, "proto.gz") {
|
||||
function(it.selectedFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +199,7 @@ fun SettingsBackupScreen(navController: BackStack<Route>) {
|
||||
restoringProgress,
|
||||
restoreStatus
|
||||
) {
|
||||
filePicker("json") {
|
||||
filePicker("gz") {
|
||||
vm.restoreFile(it.selectedFile)
|
||||
}
|
||||
}
|
||||
@@ -206,12 +208,9 @@ fun SettingsBackupScreen(navController: BackStack<Route>) {
|
||||
stringResource("backup_create_sub"),
|
||||
creating,
|
||||
creatingProgress,
|
||||
creatingStatus
|
||||
) {
|
||||
fileSaver("backup.json", "json") {
|
||||
vm.createFile(it.selectedFile)
|
||||
}
|
||||
}
|
||||
creatingStatus,
|
||||
vm::exportBackup
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +224,7 @@ private fun openMissingSourcesDialog(missingSources: List<String>, onPositiveCli
|
||||
) {
|
||||
LazyColumn {
|
||||
item {
|
||||
Text(stringResource("missing_sources"))
|
||||
Text(stringResource("missing_sources"), style = MaterialTheme.typography.subtitle2)
|
||||
}
|
||||
items(missingSources) {
|
||||
Text(it)
|
||||
|
||||
@@ -31,12 +31,12 @@ val userDataDir: File by lazy {
|
||||
}
|
||||
|
||||
fun filePicker(
|
||||
extension: String? = null,
|
||||
vararg extensions: String,
|
||||
builder: JFileChooser.() -> Unit = {},
|
||||
onCancel: (JFileChooser) -> Unit = {},
|
||||
onError: (JFileChooser) -> Unit = {},
|
||||
onApprove: (JFileChooser) -> Unit
|
||||
) = fileChooser(false, builder, onCancel, onError, onApprove, extension)
|
||||
) = fileChooser(false, builder, onCancel, onError, onApprove, extensions = extensions)
|
||||
|
||||
fun fileSaver(
|
||||
defaultFileName: String,
|
||||
@@ -51,8 +51,8 @@ fun fileSaver(
|
||||
onCancel,
|
||||
onError,
|
||||
onApprove,
|
||||
extension,
|
||||
defaultFileName
|
||||
defaultFileName,
|
||||
extension
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -71,15 +71,15 @@ private fun fileChooser(
|
||||
onCancel: (JFileChooser) -> Unit = {},
|
||||
onError: (JFileChooser) -> Unit = {},
|
||||
onApprove: (JFileChooser) -> Unit,
|
||||
extension: String? = null,
|
||||
defaultFileName: String = ""
|
||||
defaultFileName: String = "",
|
||||
vararg extensions: String,
|
||||
) = launchUI {
|
||||
val fileChooser = JFileChooser()
|
||||
.apply {
|
||||
val details = actionMap.get("viewTypeDetails")
|
||||
details?.actionPerformed(null)
|
||||
if (extension != null) {
|
||||
fileFilter = FileNameExtensionFilter("$extension file", extension)
|
||||
if (extensions.isNotEmpty()) {
|
||||
fileFilter = FileNameExtensionFilter("${extensions.joinToString()} files", *extensions)
|
||||
}
|
||||
if (saving) {
|
||||
selectedFile = File(defaultFileName)
|
||||
|
||||
Reference in New Issue
Block a user