Update Backups to Protobuf, improve backup creation handling

This commit is contained in:
Syer10
2021-08-20 22:48:49 -04:00
parent e7d5852e47
commit a44dfa56cb
7 changed files with 96 additions and 143 deletions

View File

@@ -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
}

View File

@@ -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
)
}

View File

@@ -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>
)

View File

@@ -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
)
}
}

View File

@@ -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"

View 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)

View File

@@ -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)