diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index b3aff728..1e6d325e 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -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 } \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/data/models/Backup.kt b/src/main/kotlin/ca/gosyer/data/models/Backup.kt deleted file mode 100644 index 3720ce2b..00000000 --- a/src/main/kotlin/ca/gosyer/data/models/Backup.kt +++ /dev/null @@ -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> = emptyList(), - val mangas: List, - val version: Int = 1 -) { - @Serializable - data class Manga( - val manga: List<@Contextual Any?>, - val chapters: List = emptyList(), - val categories: List = emptyList(), - val history: List> = emptyList(), - val track: List = 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 - ) -} diff --git a/src/main/kotlin/ca/gosyer/data/models/BackupValidationResult.kt b/src/main/kotlin/ca/gosyer/data/models/BackupValidationResult.kt new file mode 100644 index 00000000..f0503a10 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/BackupValidationResult.kt @@ -0,0 +1,9 @@ +package ca.gosyer.data.models + +import kotlinx.serialization.Serializable + +@Serializable +data class BackupValidationResult( + val missingSources: List, + val missingTrackers: List +) diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt index 3f3f46ed..82285983 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt @@ -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( - serverUrl + backupImportRequest() - ) { - contentType(ContentType.Application.Json) - body = backup - block() - } + suspend fun validateBackupFile(file: File, block: HttpRequestBuilder.() -> Unit = {}) = withIOContext { + client.submitFormWithBinaryData( + 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( - serverUrl + backupExportRequest(), - block - ) - } } diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt index ab6b1cc3..43dd6a9c 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt @@ -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" diff --git a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt index 0456b1bf..d52b54d8 100644 --- a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt @@ -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.Nothing) internal val creatingStatus = _creatingStatus.asStateFlow() + private val _createFlow = MutableSharedFlow 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(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) { 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) { restoringProgress, restoreStatus ) { - filePicker("json") { + filePicker("gz") { vm.restoreFile(it.selectedFile) } } @@ -206,12 +208,9 @@ fun SettingsBackupScreen(navController: BackStack) { 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, onPositiveCli ) { LazyColumn { item { - Text(stringResource("missing_sources")) + Text(stringResource("missing_sources"), style = MaterialTheme.typography.subtitle2) } items(missingSources) { Text(it) diff --git a/src/main/kotlin/ca/gosyer/util/system/File.kt b/src/main/kotlin/ca/gosyer/util/system/File.kt index d41061eb..59310bd0 100644 --- a/src/main/kotlin/ca/gosyer/util/system/File.kt +++ b/src/main/kotlin/ca/gosyer/util/system/File.kt @@ -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)