diff --git a/build.gradle.kts b/build.gradle.kts index ba6440eb..40a898ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { kotlin("jvm") version "1.4.32" kotlin("kapt") version "1.4.32" kotlin("plugin.serialization") version "1.4.32" - id("org.jetbrains.compose") version "0.4.0-build185" + id("org.jetbrains.compose") version "0.4.0-build198" id("de.fuerstenau.buildconfig") version "1.1.8" id("org.jmailen.kotlinter") version "3.4.0" } @@ -27,8 +27,8 @@ dependencies { // UI (Compose) implementation(compose.desktop.currentOs) implementation("br.com.devsrsouza.compose.icons.jetbrains:font-awesome:0.2.0") - implementation("com.github.Syer10:compose-router:45a8c4fe83") - implementation("ca.gosyer:accompanist-pager:0.8.1") + implementation("ca.gosyer:compose-router:0.24.2-jetbrains-2") + implementation("ca.gosyer:accompanist-pager:0.9.1") // UI (Swing) implementation("com.github.weisj:darklaf-core:2.5.5") @@ -76,7 +76,7 @@ dependencies { tasks { withType { - dependsOn(formatKotlin) + dependsOn(formatKotlinMain) kotlinOptions { jvmTarget = "15" freeCompilerArgs = listOf( @@ -88,7 +88,8 @@ tasks { "-Xopt-in=com.russhwolf.settings.ExperimentalSettingsApi", "-Xopt-in=com.russhwolf.settings.ExperimentalSettingsImplementation", "-Xopt-in=com.google.accompanist.pager.ExperimentalPagerApi", - "-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi" + "-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi", + "-Xopt-in=androidx.compose.material.ExperimentalMaterialApi" ) } } @@ -157,8 +158,8 @@ buildConfig { packageName = project.group.toString() buildConfigField("boolean", "DEBUG", project.hasProperty("debugApp").toString()) - buildConfigField("String", "TACHIDESK_SP_VERSION", "0.2.7") - buildConfigField("String", "TACHIDESK_IM_VERSION", "0") + buildConfigField("String", "TACHIDESK_SP_VERSION", "v0.3.7") + buildConfigField("String", "TACHIDESK_IM_VERSION", "r66") } kotlinter { diff --git a/scripts/SetupUnix.sh b/scripts/SetupUnix.sh index 90756db1..5f31be39 100755 --- a/scripts/SetupUnix.sh +++ b/scripts/SetupUnix.sh @@ -7,13 +7,14 @@ fi mkdir -p "tmp" echo "Getting latest Tachidesk build files" -TARBALL_LINK="$(curl -s "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" | grep -o "https.*tarball\/[a-zA-Z0-9.]*")" +#TARBALL_LINK="$(curl -s "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" | grep -o "https.*tarball\/[a-zA-Z0-9.]*")" -curl -L "$TARBALL_LINK" -o tmp/Tachidesk.tar +#curl -L "$TARBALL_LINK" -o tmp/Tachidesk.tar +curl -L "https://github.com/Suwayomi/Tachidesk/archive/refs/tags/v0.3.7.tar.gz" -o tmp/Tachidesk.tar.gz -tar -xvf tmp/Tachidesk.tar -C tmp +tar -xvf tmp/Tachidesk.tar.gz -C tmp -TACHIDESK_FOLDER=$(find tmp -type d -regex ".*Tachidesk-[a-z0-9]*") +TACHIDESK_FOLDER=$(find tmp -type d -regex ".*Tachidesk-[a-z0-9\.]*") pushd "$TACHIDESK_FOLDER" || exit @@ -33,4 +34,4 @@ mv "$TACHIDESK_FOLDER/$TACHIDESK_JAR" src/main/resources/Tachidesk.jar echo "Cleaning up..." rm -rf "tmp" -echo "Done!" \ No newline at end of file +echo "Done!" diff --git a/scripts/SetupWindows.ps1 b/scripts/SetupWindows.ps1 index 4001359a..f740c70c 100644 --- a/scripts/SetupWindows.ps1 +++ b/scripts/SetupWindows.ps1 @@ -6,13 +6,15 @@ Remove-Item -Recurse -Force "tmp" -ErrorAction SilentlyContinue | Out-Null New-Item -ItemType Directory -Force -Path "tmp" Write-Output "Getting latest Tachidesk build files" -$zipball = (Invoke-WebRequest -Uri "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" -UseBasicParsing).content | Select-String -Pattern 'https[\.:\/A-Za-z0-9]*zipball\/[a-zA-Z0-9.]*' -CaseSensitive +#$zipball = (Invoke-WebRequest -Uri "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" -UseBasicParsing).content | Select-String -Pattern 'https[\.:\/A-Za-z0-9]*zipball\/[a-zA-Z0-9.]*' -CaseSensitive -Invoke-WebRequest -Uri $zipball.Matches.Value -OutFile tmp/Tachidesk.zip -UseBasicParsing +#Invoke-WebRequest -Uri $zipball.Matches.Value -OutFile tmp/Tachidesk.zip -UseBasicParsing + +Invoke-WebRequest -Uri "https://github.com/Suwayomi/Tachidesk/archive/refs/tags/v0.3.7.zip" -OutFile tmp/Tachidesk.zip -UseBasicParsing Expand-Archive -Path "tmp/Tachidesk.zip" -DestinationPath "tmp" -$tachidesk_folder = Get-ChildItem -Path "tmp" | Where-Object {$_.Name -match ".*Tachidesk-[a-z0-9]*"} | Select-Object FullName +$tachidesk_folder = Get-ChildItem -Path "tmp" | Where-Object {$_.Name -match ".*Tachidesk-[a-z0-9\.]*"} | Select-Object FullName Push-Location $tachidesk_folder.FullName @@ -27,7 +29,7 @@ $tachidesk_jar = $(Get-ChildItem "server/build" | Where-Object { $_.Name -match Pop-Location Write-Output "Copying Tachidesk.jar to resources folder..." -Move-Item -Force $tachidesk_jar "src/main/resources/Tachidesk.jar" +Move-Item -Force $tachidesk_jar "src/main/resources/Tachidesk.jar" -ErrorAction SilentlyContinue Write-Output "Cleaning up..." Remove-Item -Recurse -Force "tmp" | Out-Null diff --git a/src/main/kotlin/ca/gosyer/data/models/About.kt b/src/main/kotlin/ca/gosyer/data/models/About.kt new file mode 100644 index 00000000..1a370c64 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/About.kt @@ -0,0 +1,15 @@ +/* + * 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.Serializable + +@Serializable +data class About( + val version: String, + val revision: String, +) diff --git a/src/main/kotlin/ca/gosyer/data/models/Backup.kt b/src/main/kotlin/ca/gosyer/data/models/Backup.kt new file mode 100644 index 00000000..3720ce2b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/Backup.kt @@ -0,0 +1,55 @@ +/* + * 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/Chapter.kt b/src/main/kotlin/ca/gosyer/data/models/Chapter.kt index 1bc919af..209341f7 100644 --- a/src/main/kotlin/ca/gosyer/data/models/Chapter.kt +++ b/src/main/kotlin/ca/gosyer/data/models/Chapter.kt @@ -6,21 +6,20 @@ package ca.gosyer.data.models -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Chapter( - val id: Long, val url: String, val name: String, - @SerialName("date_upload") - val dateUpload: Long, - @SerialName("chapter_number") + val uploadDate: Long, val chapterNumber: Float, val scanlator: String?, val mangaId: Long, - val pageCount: Int? = null, - val chapterIndex: Int, - val chapterCount: Int + val read: Boolean, + val bookmarked: Boolean, + val lastPageRead: Int, + val index: Int, + val chapterCount: Int?, + val pageCount: Int?, ) diff --git a/src/main/kotlin/ca/gosyer/data/models/Extension.kt b/src/main/kotlin/ca/gosyer/data/models/Extension.kt index bb386ad2..6b841432 100644 --- a/src/main/kotlin/ca/gosyer/data/models/Extension.kt +++ b/src/main/kotlin/ca/gosyer/data/models/Extension.kt @@ -18,7 +18,8 @@ data class Extension( val apkName: String, val iconUrl: String, val installed: Boolean, - val classFQName: String, + val hasUpdate: Boolean, + val obsolete: Boolean, val nsfw: Boolean ) { fun iconUrl(serverUrl: String) = serverUrl + iconUrl diff --git a/src/main/kotlin/ca/gosyer/data/models/Manga.kt b/src/main/kotlin/ca/gosyer/data/models/Manga.kt index 782d9259..165d9e40 100644 --- a/src/main/kotlin/ca/gosyer/data/models/Manga.kt +++ b/src/main/kotlin/ca/gosyer/data/models/Manga.kt @@ -14,15 +14,16 @@ data class Manga( val sourceId: Long, val url: String, val title: String, - val thumbnailUrl: String? = null, - val initialized: Boolean = false, - val artist: String? = null, - val author: String? = null, - val description: String? = null, - val genre: String? = null, + val thumbnailUrl: String?, + val initialized: Boolean, + val artist: String?, + val author: String?, + val description: String?, + val genre: String?, val status: String, - val inLibrary: Boolean = false, - val source: Source? + val inLibrary: Boolean, + val source: Source?, + val freshData: Boolean ) { fun cover(serverUrl: String) = thumbnailUrl?.let { serverUrl + it } } diff --git a/src/main/kotlin/ca/gosyer/data/server/ServerService.kt b/src/main/kotlin/ca/gosyer/data/server/ServerService.kt index f633a4f7..79f8ec91 100644 --- a/src/main/kotlin/ca/gosyer/data/server/ServerService.kt +++ b/src/main/kotlin/ca/gosyer/data/server/ServerService.kt @@ -34,6 +34,7 @@ class ServerService @Inject constructor( ServerResult.UNUSED } ) + private val runtime = Runtime.getRuntime() var process: Process? = null private fun copyJar(jarFile: File) { @@ -45,6 +46,12 @@ class ServerService @Inject constructor( } init { + runtime.addShutdownHook( + thread(start = false) { + process?.destroy() + process = null + } + ) host.onEach { process?.destroy() initialized.value = if (host.value) { @@ -55,7 +62,6 @@ class ServerService @Inject constructor( } GlobalScope.launch { val logger = KotlinLogging.logger("Server") - val runtime = Runtime.getRuntime() val jarFile = File(userDataDir, "Tachidesk.jar") if (!jarFile.exists()) { @@ -71,19 +77,13 @@ class ServerService @Inject constructor( ) } } - // TODO remove 1.0 version check when we move onto 0.3.0 of Tachidesk + if ( - jarVersion.specification != null && - jarVersion.implementation != null && - jarVersion.implementation != "1.0" + jarVersion.specification != BuildConfig.TACHIDESK_SP_VERSION || + jarVersion.implementation != BuildConfig.TACHIDESK_IM_VERSION ) { - if ( - jarVersion.specification != BuildConfig.TACHIDESK_SP_VERSION || - jarVersion.implementation != BuildConfig.TACHIDESK_IM_VERSION - ) { - logger.info { "Updating server file from resources" } - copyJar(jarFile) - } + logger.info { "Updating server file from resources" } + copyJar(jarFile) } } catch (e: IOException) { logger.error(e) { @@ -106,12 +106,6 @@ class ServerService @Inject constructor( process = runtime.exec("""$javaExePath -jar "${jarFile.absolutePath}"""").also { reader = it.inputStream.bufferedReader() } - runtime.addShutdownHook( - thread(start = false) { - process?.destroy() - process = null - } - ) logger.info { "Server started successfully" } var line: String? while (reader.readLine().also { line = it } != null) { @@ -124,6 +118,9 @@ class ServerService @Inject constructor( } logger.info { line } } + if (initialized.value == ServerResult.STARTING) { + initialized.value = ServerResult.FAILED + } logger.info { "Server closed" } val exitVal = process?.waitFor() logger.info { "Process exitValue: $exitVal" } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt new file mode 100644 index 00000000..e8d11e7b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt @@ -0,0 +1,69 @@ +/* + * 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.server.interactions + +import ca.gosyer.data.models.Backup +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 io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +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.content.MultiPartData +import io.ktor.http.contentType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +class BackupInteractionHandler @Inject constructor( + client: Http, + serverPreferences: ServerPreferences +) : BaseInteractionHandler(client, serverPreferences) { + + suspend fun importBackupFile(file: File) = withContext(Dispatchers.IO) { + client.submitFormWithBinaryData( + serverUrl + backupFileImportRequest(), + formData = formData { + append( + "backup.json", file.readBytes(), + Headers.build { + append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + append(HttpHeaders.ContentDisposition, "filename=backup.json") + } + ) + } + ) + } + + suspend fun importBackup(backup: Backup) = withContext(Dispatchers.IO) { + client.postRepeat( + serverUrl + backupImportRequest() + ) { + contentType(ContentType.Application.Json) + body = backup + } + } + + suspend fun exportBackupFile() = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + backupFileExportRequest() + ) + } + + suspend fun exportBackup() = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + backupExportRequest() + ) + } +} diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt index 5af3fc0f..5c712fc8 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt @@ -13,6 +13,11 @@ import ca.gosyer.data.server.ServerPreferences import ca.gosyer.data.server.requests.getChapterQuery import ca.gosyer.data.server.requests.getMangaChaptersQuery import ca.gosyer.data.server.requests.getPageQuery +import ca.gosyer.data.server.requests.updateChapterRequest +import io.ktor.client.request.parameter +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpMethod +import io.ktor.http.Parameters import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -22,17 +27,19 @@ class ChapterInteractionHandler @Inject constructor( serverPreferences: ServerPreferences ) : BaseInteractionHandler(client, serverPreferences) { - suspend fun getChapters(mangaId: Long) = withContext(Dispatchers.IO) { + suspend fun getChapters(mangaId: Long, refresh: Boolean = false) = withContext(Dispatchers.IO) { client.getRepeat>( serverUrl + getMangaChaptersQuery(mangaId) - ) + ) { + url { + if (refresh) { + parameter("onlineFetch", true) + } + } + } } - suspend fun getChapters(manga: Manga) = withContext(Dispatchers.IO) { - client.getRepeat( - serverUrl + getMangaChaptersQuery(manga.id) - ) - } + suspend fun getChapters(manga: Manga, refresh: Boolean = false) = getChapters(manga.id, refresh) suspend fun getChapter(mangaId: Long, chapterIndex: Int) = withContext(Dispatchers.IO) { client.getRepeat( @@ -40,11 +47,72 @@ class ChapterInteractionHandler @Inject constructor( ) } - suspend fun getChapter(chapter: Chapter) = getChapter(chapter.mangaId, chapter.chapterIndex) + suspend fun getChapter(chapter: Chapter) = getChapter(chapter.mangaId, chapter.index) suspend fun getChapter(manga: Manga, chapterIndex: Int) = getChapter(manga.id, chapterIndex) - suspend fun getChapter(manga: Manga, chapter: Chapter) = getChapter(manga.id, chapter.chapterIndex) + suspend fun getChapter(manga: Manga, chapter: Chapter) = getChapter(manga.id, chapter.index) + + suspend fun updateChapter( + mangaId: Long, + chapterIndex: Int, + read: Boolean? = null, + bookmarked: Boolean? = null, + lastPageRead: Int? = null, + markPreviousRead: Boolean? = null + ) = withContext(Dispatchers.IO) { + client.submitFormRepeat( + serverUrl + updateChapterRequest(mangaId, chapterIndex), + formParameters = Parameters.build { + if (read != null) { + append("read", read.toString()) + } + if (bookmarked != null) { + append("bookmarked", bookmarked.toString()) + } + if (lastPageRead != null) { + append("lastPageRead", lastPageRead.toString()) + } + if (markPreviousRead != null) { + append("markPrevRead", markPreviousRead.toString()) + } + } + ) { + method = HttpMethod.Patch + } + } + + suspend fun updateChapter( + manga: Manga, + chapterIndex: Int, + read: Boolean? = null, + bookmarked: Boolean? = null, + lastPageRead: Int? = null, + markPreviousRead: Boolean? = null + ) = updateChapter( + manga.id, + chapterIndex, + read, + bookmarked, + lastPageRead, + markPreviousRead + ) + + suspend fun updateChapter( + manga: Manga, + chapter: Chapter, + read: Boolean? = null, + bookmarked: Boolean? = null, + lastPageRead: Int? = null, + markPreviousRead: Boolean? = null + ) = updateChapter( + manga.id, + chapter.index, + read, + bookmarked, + lastPageRead, + markPreviousRead + ) suspend fun getPage(mangaId: Long, chapterIndex: Int, pageNum: Int) = withContext(Dispatchers.IO) { imageFromUrl( @@ -53,9 +121,9 @@ class ChapterInteractionHandler @Inject constructor( ) } - suspend fun getPage(chapter: Chapter, pageNum: Int) = getPage(chapter.mangaId, chapter.chapterIndex, pageNum) + suspend fun getPage(chapter: Chapter, pageNum: Int) = getPage(chapter.mangaId, chapter.index, pageNum) suspend fun getPage(manga: Manga, chapterIndex: Int, pageNum: Int) = getPage(manga.id, chapterIndex, pageNum) - suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int) = getPage(manga.id, chapter.chapterIndex, pageNum) + suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int) = getPage(manga.id, chapter.index, pageNum) } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt index fd181dba..1815997e 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt @@ -12,6 +12,7 @@ import ca.gosyer.data.server.ServerPreferences import ca.gosyer.data.server.requests.apkIconQuery import ca.gosyer.data.server.requests.apkInstallQuery import ca.gosyer.data.server.requests.apkUninstallQuery +import ca.gosyer.data.server.requests.apkUpdateQuery import ca.gosyer.data.server.requests.extensionListQuery import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Dispatchers @@ -31,13 +32,19 @@ class ExtensionInteractionHandler @Inject constructor( suspend fun installExtension(extension: Extension) = withContext(Dispatchers.IO) { client.getRepeat( - serverUrl + apkInstallQuery(extension.apkName) + serverUrl + apkInstallQuery(extension.pkgName) + ) + } + + suspend fun updateExtension(extension: Extension) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + apkUpdateQuery(extension.pkgName) ) } suspend fun uninstallExtension(extension: Extension) = withContext(Dispatchers.IO) { client.getRepeat( - serverUrl + apkUninstallQuery(extension.apkName) + serverUrl + apkUninstallQuery(extension.pkgName) ) } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt index f4c343b4..0f08e367 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt @@ -11,6 +11,7 @@ import ca.gosyer.data.server.Http import ca.gosyer.data.server.ServerPreferences import ca.gosyer.data.server.requests.mangaQuery import ca.gosyer.data.server.requests.mangaThumbnailQuery +import io.ktor.client.request.parameter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -20,12 +21,20 @@ class MangaInteractionHandler @Inject constructor( serverPreferences: ServerPreferences ) : BaseInteractionHandler(client, serverPreferences) { - suspend fun getManga(mangaId: Long) = withContext(Dispatchers.IO) { + suspend fun getManga(mangaId: Long, refresh: Boolean = false) = withContext(Dispatchers.IO) { client.getRepeat( serverUrl + mangaQuery(mangaId) - ) + ) { + url { + if (refresh) { + parameter("onlineFetch", true) + } + } + } } + suspend fun getManga(manga: Manga, refresh: Boolean = false) = getManga(manga.id, refresh) + suspend fun getMangaThumbnail(mangaId: Long) = withContext(Dispatchers.IO) { imageFromUrl( client, diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt new file mode 100644 index 00000000..ab6b1cc3 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt @@ -0,0 +1,23 @@ +/* + * 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.server.requests + +@Post +fun backupImportRequest() = + "/api/v1/backup/legacy/import" + +@Post +fun backupFileImportRequest() = + "/api/v1/backup/legacy/import/file" + +@Post +fun backupExportRequest() = + "/api/v1/backup/legacy/export" + +@Post +fun backupFileExportRequest() = + "/api/v1/backup/legacy/export/file" diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt index 9f5a2624..8dd36f54 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt @@ -14,6 +14,10 @@ fun getMangaChaptersQuery(mangaId: Long) = fun getChapterQuery(mangaId: Long, chapterIndex: Int) = "/api/v1/manga/$mangaId/chapter/$chapterIndex" +@Patch +fun updateChapterRequest(mangaId: Long, chapterIndex: Int) = + "/api/v1/manga/$mangaId/chapter/$chapterIndex" + @Get fun getPageQuery(mangaId: Long, chapterIndex: Int, index: Int) = "/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$index" diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt index 4eb41c4a..9ba5086a 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt @@ -11,12 +11,16 @@ fun extensionListQuery() = "/api/v1/extension/list" @Get -fun apkInstallQuery(apkName: String) = - "/api/v1/extension/install/$apkName" +fun apkInstallQuery(pkgName: String) = + "/api/v1/extension/install/$pkgName" @Get -fun apkUninstallQuery(apkName: String) = - "/api/v1/extension/uninstall/$apkName" +fun apkUpdateQuery(pkgName: String) = + "/api/v1/extension/update/$pkgName" + +@Get +fun apkUninstallQuery(pkgName: String) = + "/api/v1/extension/uninstall/$pkgName" @Get fun apkIconQuery(apkName: String) = diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Meta.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Meta.kt new file mode 100644 index 00000000..7e83f521 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Meta.kt @@ -0,0 +1,11 @@ +/* + * 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.server.requests + +@Get +fun aboutQuery() = + "/api/v1/about/" diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt new file mode 100644 index 00000000..cbd7dd37 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt @@ -0,0 +1,106 @@ +/* + * 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.ui.base.components + +import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.IntOffset +import java.awt.event.MouseEvent + +private suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { + var event: PointerEvent + do { + event = awaitPointerEvent() + } while ( + !event.changes.all { it.changedToDown() } + ) + return event +} + +fun Modifier.combinedMouseClickable( + rightClickIsContextMenu: Boolean = true, + onClick: (IntOffset) -> Unit = {}, + onMiddleClick: (IntOffset) -> Unit = {}, + onRightClick: (IntOffset) -> Unit = {} +) = composed( + inspectorInfo = debugInspectorInfo { + name = "combinedMouseClickable" + properties["rightClickIsContextMenu"] = rightClickIsContextMenu + properties["onClick"] = onClick + properties["onMiddleClick"] = onMiddleClick + properties["onRightClick"] = onRightClick + } +) { + Modifier.combinedMouseClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + rightClickIsContextMenu = rightClickIsContextMenu, + onClick = onClick, + onMiddleClick = onMiddleClick, + onRightClick = onRightClick + ) +} + +fun Modifier.combinedMouseClickable( + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + rightClickIsContextMenu: Boolean = true, + onClick: (IntOffset) -> Unit, + onMiddleClick: (IntOffset) -> Unit, + onRightClick: (IntOffset) -> Unit +) = composed( + inspectorInfo = debugInspectorInfo { + name = "combinedMouseClickable" + properties["enabled"] = enabled + properties["onClickLabel"] = onClickLabel + properties["role"] = role + properties["rightClickIsContextMenu"] = rightClickIsContextMenu + properties["onClick"] = onClick + properties["onMiddleClick"] = onMiddleClick + properties["onRightClick"] = onRightClick + properties["indication"] = indication + properties["interactionSource"] = interactionSource + } +) { + var lastEvent by remember { mutableStateOf(null) } + Modifier + .clickable(interactionSource, indication, enabled, onClickLabel, role) { + val savedLastEvent = lastEvent ?: return@clickable + val offset = savedLastEvent.let { IntOffset(it.xOnScreen, it.yOnScreen) } + when { + rightClickIsContextMenu && savedLastEvent.isPopupTrigger -> onRightClick(offset) + savedLastEvent.button == MouseEvent.BUTTON1 -> onClick(offset) + savedLastEvent.button == MouseEvent.BUTTON2 -> onMiddleClick(offset) + savedLastEvent.button == MouseEvent.BUTTON3 -> onRightClick(offset) + } + } + .pointerInput(interactionSource) { + forEachGesture { + awaitPointerEventScope { + lastEvent = awaitEventFirstDown().mouseEvent + } + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt index 0ea8dcd3..e4e09f99 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt @@ -6,7 +6,6 @@ package ca.gosyer.ui.base.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -42,11 +41,11 @@ fun MangaGridItem( ) Surface( + onClick = onClick, modifier = Modifier .fillMaxWidth() .aspectRatio(mangaAspectRatio) - .padding(8.dp) - .clickable(onClick = onClick), + .padding(8.dp), elevation = 4.dp, shape = RoundedCornerShape(4.dp) ) { diff --git a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt index 84b066bf..90c1e12c 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt @@ -378,8 +378,9 @@ private fun ExpandableContent( shrinkVertically(animationSpec = tween(COLLAPSE_ANIMATION_DURATION)) } AnimatedVisibility( - visible = visible, - initiallyVisible = initiallyVisible, + remember { MutableTransitionState(initialState = initiallyVisible) } + .apply { targetState = visible }, + modifier = Modifier, enter = enterExpand + enterFadeIn, exit = exitCollapse + exitFadeOut ) { diff --git a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt index 7bd2d502..8390ae93 100644 --- a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt @@ -42,12 +42,17 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import ca.gosyer.BuildConfig import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.util.compose.ThemedWindow +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import mu.KotlinLogging fun openCategoriesMenu(notifyFinished: (() -> Unit)? = null) { val windowEvents = WindowEvents() - ThemedWindow("TachideskJUI - Categories", events = windowEvents) { + ThemedWindow("${BuildConfig.NAME} - Categories", events = windowEvents) { CategoriesMenu(notifyFinished, windowEvents) } } @@ -58,8 +63,14 @@ fun CategoriesMenu(notifyFinished: (() -> Unit)? = null, windowEvents: WindowEve val categories by vm.categories.collectAsState() remember { windowEvents.onClose = { - vm.updateCategories() - notifyFinished?.invoke() + val logger = KotlinLogging.logger {} + val handler = CoroutineExceptionHandler { _, throwable -> + logger.debug { throwable } + } + GlobalScope.launch(handler) { + vm.updateRemoteCategories() + notifyFinished?.invoke() + } } } diff --git a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt index 69361d2b..81707c1f 100644 --- a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt @@ -10,8 +10,6 @@ import ca.gosyer.data.models.Category import ca.gosyer.data.server.interactions.CategoryInteractionHandler import ca.gosyer.ui.base.vm.ViewModel import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -50,36 +48,31 @@ class CategoriesMenuViewModel @Inject constructor( } } - fun updateCategories(manualUpdate: Boolean = false) { - val handler = CoroutineExceptionHandler { _, throwable -> - logger.debug { throwable } + suspend fun updateRemoteCategories(manualUpdate: Boolean = false) { + val categories = _categories.value + val newCategories = categories.filter { it.id == null } + newCategories.forEach { + categoryHandler.createCategory(it.name) } - GlobalScope.launch(handler) { - val categories = _categories.value - val newCategories = categories.filter { it.id == null } - newCategories.forEach { - categoryHandler.createCategory(it.name) + originalCategories.forEach { originalCategory -> + val category = categories.find { it.id == originalCategory.id } + if (category == null) { + categoryHandler.deleteCategory(originalCategory) + } else if (category.name != originalCategory.name) { + categoryHandler.modifyCategory(originalCategory, category.name) } - originalCategories.forEach { originalCategory -> - val category = categories.find { it.id == originalCategory.id } - if (category == null) { - categoryHandler.deleteCategory(originalCategory) - } else if (category.name != originalCategory.name) { - categoryHandler.modifyCategory(originalCategory, category.name) - } - } - val updatedCategories = categoryHandler.getCategories() - updatedCategories.forEach { updatedCategory -> - val category = categories.find { it.id == updatedCategory.id || it.name == updatedCategory.name } ?: return@forEach - if (category.order != updatedCategory.order) { - logger.debug { "${category.order} to ${updatedCategory.order}" } - categoryHandler.reorderCategory(updatedCategory, category.order, updatedCategory.order) - } + } + val updatedCategories = categoryHandler.getCategories() + updatedCategories.forEach { updatedCategory -> + val category = categories.find { it.id == updatedCategory.id || it.name == updatedCategory.name } ?: return@forEach + if (category.order != updatedCategory.order) { + logger.debug { "${category.order} to ${updatedCategory.order}" } + categoryHandler.reorderCategory(updatedCategory, category.order, updatedCategory.order) } + } - if (manualUpdate) { - getCategories() - } + if (manualUpdate) { + getCategories() } } diff --git a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt index c2b758e2..1cae7d24 100644 --- a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt @@ -85,12 +85,9 @@ fun ExtensionsMenu() { ExtensionItem( extension, serverUrl, - onInstallClicked = { - vm.install(it) - }, - onUninstallClicked = { - vm.uninstall(it) - } + onInstallClicked = vm::install, + onUpdateClicked = vm::update, + onUninstallClicked = vm::uninstall ) Spacer(Modifier.height(8.dp)) } @@ -113,6 +110,7 @@ fun ExtensionItem( extension: Extension, serverUrl: String, onInstallClicked: (Extension) -> Unit, + onUpdateClicked: (Extension) -> Unit, onUninstallClicked: (Extension) -> Unit ) { Box(modifier = Modifier.fillMaxWidth().height(64.dp).background(MaterialTheme.colors.background)) { @@ -133,16 +131,30 @@ fun ExtensionItem( Spacer(Modifier.width(4.dp)) Text("18+", fontSize = 14.sp, color = Color.Red) } + if (extension.obsolete) { + Spacer(Modifier.width(4.dp)) + Text("Obsolete", fontSize = 14.sp, color = Color.Red) + } } } } Button( { - if (extension.installed) onUninstallClicked(extension) else onInstallClicked(extension) + when { + extension.hasUpdate -> onUpdateClicked(extension) + extension.installed -> onUninstallClicked(extension) + else -> onInstallClicked(extension) + } }, modifier = Modifier.align(Alignment.CenterEnd) ) { - Text(if (extension.installed) "Uninstall" else "Install") + Text( + when { + extension.hasUpdate -> "Update" + extension.installed -> "Uninstall" + else -> "Install" + } + ) } } } diff --git a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt index 65de8723..7f269e12 100644 --- a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt @@ -70,6 +70,18 @@ class ExtensionsMenuViewModel @Inject constructor( } } + fun update(extension: Extension) { + logger.info { "Update clicked" } + scope.launch { + try { + extensionHandler.updateExtension(extension) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + getExtensions() + } + } + fun uninstall(extension: Extension) { logger.info { "Uninstall clicked" } scope.launch { diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt index 99529e11..aa60611d 100644 --- a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt @@ -138,7 +138,7 @@ private fun LibraryPager( val state = remember(categories.size, selectedPage) { PagerState( currentPage = selectedPage, - pageCount = categories.lastIndex + pageCount = categories.size ) } LaunchedEffect(state.currentPage) { @@ -146,7 +146,7 @@ private fun LibraryPager( onPageChanged(state.currentPage) } } - HorizontalPager(state = state, offscreenLimit = 1) { + HorizontalPager(state = state) { val library by getLibraryForPage(it) when (displayMode) { DisplayMode.CompactGrid -> LibraryMangaCompactGrid( diff --git a/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt b/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt index 76084b54..c5ae2568 100644 --- a/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt @@ -7,7 +7,6 @@ package ca.gosyer.ui.main import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,11 +17,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +33,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ca.gosyer.BuildConfig +import ca.gosyer.data.ui.model.StartScreen import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.ui.extensions.ExtensionsMenu import ca.gosyer.ui.library.LibraryScreen @@ -47,78 +49,72 @@ import ca.gosyer.ui.settings.SettingsScreen import ca.gosyer.ui.settings.SettingsServerScreen import ca.gosyer.ui.sources.SourcesMenu import com.github.zsoltk.compose.router.Router +import com.github.zsoltk.compose.savedinstancestate.Bundle +import com.github.zsoltk.compose.savedinstancestate.BundleScope import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Regular import compose.icons.fontawesomeicons.regular.Bookmark import compose.icons.fontawesomeicons.regular.Compass import compose.icons.fontawesomeicons.regular.Edit import compose.icons.fontawesomeicons.regular.Map +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import mu.KotlinLogging +import kotlin.time.seconds @Composable -fun MainMenu() { +fun MainMenu(rootBundle: Bundle) { val vm = viewModel() Surface { - Router("TopLevel", Route.Library) { backStack -> + Router("TopLevel", vm.startScreen.toRoute()) { backStack -> Row { Surface(elevation = 2.dp) { Column(Modifier.width(200.dp).fillMaxHeight(),) { Box(Modifier.fillMaxWidth().height(60.dp)) { - Text(BuildConfig.NAME, fontSize = 30.sp, modifier = Modifier.align(Alignment.Center)) + Text( + BuildConfig.NAME, + fontSize = 30.sp, + modifier = Modifier.align(Alignment.Center) + ) } Spacer(Modifier.height(20.dp)) remember { TopLevelMenus.values() }.forEach { topLevelMenu -> - MainMenuItem(topLevelMenu, backStack.elements.first() == topLevelMenu.menu) { + MainMenuItem( + topLevelMenu, + backStack.elements.first() == topLevelMenu.menu + ) { backStack.newRoot(it) } } - - /*Button( - onClick = ::openExtensionsMenu - ) { - Text("Extensions") - } - Button( - onClick = ::openSourcesMenu - ) { - Text("Sources") - } - Button( - onClick = ::openLibraryMenu - ) { - Text("Library") - } - Button( - onClick = ::openCategoriesMenu - ) { - Text("Categories") - }*/ } } - Column(Modifier.fillMaxSize()) { - when (val routing = backStack.last()) { - is Route.Library -> LibraryScreen { - backStack.push(Route.Manga(it)) - } - is Route.Sources -> SourcesMenu { - backStack.push(Route.Manga(it)) - } - is Route.Extensions -> ExtensionsMenu() - is Route.Manga -> MangaMenu(routing.mangaId, backStack) + BundleScope("K${backStack.lastIndex}", rootBundle, false) { + when (val routing = backStack.last()) { + is Route.Library -> LibraryScreen { + backStack.push(Route.Manga(it)) + } + is Route.Sources -> SourcesMenu { + backStack.push(Route.Manga(it)) + } + is Route.Extensions -> ExtensionsMenu() + is Route.Manga -> MangaMenu(routing.mangaId, backStack) - is Route.Settings -> SettingsScreen(backStack) - is Route.SettingsGeneral -> SettingsGeneralScreen(backStack) - is Route.SettingsAppearance -> SettingsAppearance(backStack) - is Route.SettingsServer -> SettingsServerScreen(backStack) - is Route.SettingsLibrary -> SettingsLibraryScreen(backStack) - is Route.SettingsReader -> SettingsReaderScreen(backStack) - /*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack) - is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/ - is Route.SettingsBrowse -> SettingsBrowseScreen(backStack) - is Route.SettingsBackup -> SettingsBackupScreen(backStack) - /*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack) - is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/ - is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack) + is Route.Settings -> SettingsScreen(backStack) + is Route.SettingsGeneral -> SettingsGeneralScreen(backStack) + is Route.SettingsAppearance -> SettingsAppearance(backStack) + is Route.SettingsServer -> SettingsServerScreen(backStack) + is Route.SettingsLibrary -> SettingsLibraryScreen(backStack) + is Route.SettingsReader -> SettingsReaderScreen(backStack) + /*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack) + is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/ + is Route.SettingsBrowse -> SettingsBrowseScreen(backStack) + is Route.SettingsBackup -> SettingsBackupScreen(backStack) + /*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack) + is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/ + is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack) + } } } } @@ -129,14 +125,16 @@ fun MainMenu() { @Composable fun MainMenuItem(menu: TopLevelMenus, selected: Boolean, onClick: (Route) -> Unit) { Card( - Modifier.clickable { onClick(menu.menu) }.fillMaxWidth().height(40.dp), + { onClick(menu.menu) }, + Modifier.fillMaxWidth().height(40.dp), backgroundColor = if (!selected) { Color.Transparent } else { MaterialTheme.colors.primary.copy(0.30F) }, contentColor = Color.Transparent, - elevation = 0.dp + elevation = 0.dp, + shape = RoundedCornerShape(8.dp) ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) { Spacer(Modifier.width(16.dp)) @@ -147,6 +145,12 @@ fun MainMenuItem(menu: TopLevelMenus, selected: Boolean, onClick: (Route) -> Uni } } +fun StartScreen.toRoute() = when (this) { + StartScreen.Library -> Route.Library + StartScreen.Sources -> Route.Sources + StartScreen.Extensions -> Route.Extensions +} + enum class TopLevelMenus(val text: String, val icon: ImageVector, val menu: Route) { Library("Library", FontAwesomeIcons.Regular.Bookmark, Route.Library), Sources("Sources", FontAwesomeIcons.Regular.Compass, Route.Sources), diff --git a/src/main/kotlin/ca/gosyer/ui/main/main.kt b/src/main/kotlin/ca/gosyer/ui/main/main.kt index 4d04c2af..22c3ee22 100644 --- a/src/main/kotlin/ca/gosyer/ui/main/main.kt +++ b/src/main/kotlin/ca/gosyer/ui/main/main.kt @@ -28,6 +28,7 @@ import com.github.weisj.darklaf.theme.DarculaTheme import com.github.weisj.darklaf.theme.IntelliJTheme import com.github.zsoltk.compose.backpress.BackPressHandler import com.github.zsoltk.compose.backpress.LocalBackPressHandler +import com.github.zsoltk.compose.savedinstancestate.Bundle import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.launchIn import org.apache.logging.log4j.core.config.Configurator @@ -112,6 +113,7 @@ fun main() { ) } + val rootBundle = Bundle() window.show { AppTheme { CompositionLocalProvider( @@ -119,7 +121,7 @@ fun main() { ) { val initialized by serverService.initialized.collectAsState() if (initialized == ServerResult.STARTED || initialized == ServerResult.UNUSED) { - MainMenu() + MainMenu(rootBundle) } else if (initialized == ServerResult.STARTING) { LoadingScreen() } else if (initialized == ServerResult.FAILED) { diff --git a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt index 4f3a17f7..c419b153 100644 --- a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt @@ -8,7 +8,6 @@ package ca.gosyer.ui.manga import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -28,6 +27,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text @@ -39,21 +40,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import ca.gosyer.BuildConfig import ca.gosyer.data.models.Chapter import ca.gosyer.data.models.Manga import ca.gosyer.ui.base.components.KtorImage import ca.gosyer.ui.base.components.LoadingScreen import ca.gosyer.ui.base.components.Toolbar +import ca.gosyer.ui.base.components.combinedMouseClickable import ca.gosyer.ui.base.components.mangaAspectRatio import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.ui.main.Route import ca.gosyer.ui.reader.openReaderMenu import ca.gosyer.util.compose.ThemedWindow +import ca.gosyer.util.compose.contextMenu import com.github.zsoltk.compose.router.BackStack import java.util.Date fun openMangaMenu(mangaId: Long) { - ThemedWindow("TachideskJUI") { + ThemedWindow(BuildConfig.NAME) { MangaMenu(mangaId) } } @@ -87,9 +91,14 @@ fun MangaMenu(mangaId: Long, backStack: BackStack? = null) { MangaItem(manga, serverUrl) } items(chapters) { chapter -> - ChapterItem(chapter, dateFormat::format) { - openReaderMenu(it, manga.id) - } + ChapterItem( + chapter, + dateFormat::format, + onClick = { openReaderMenu(it, manga.id) }, + toggleRead = vm::toggleRead, + toggleBookmarked = vm::toggleBookmarked, + markPreviousAsRead = vm::markPreviousRead + ) } } VerticalScrollbar( @@ -178,20 +187,61 @@ private fun MangaInfo(manga: Manga, modifier: Modifier = Modifier) { } @Composable -fun ChapterItem(chapter: Chapter, format: (Date) -> String, onClick: (Int) -> Unit) { - Surface(modifier = Modifier.fillMaxWidth().clickable { onClick(chapter.chapterIndex) }.height(70.dp).padding(4.dp), elevation = 1.dp) { - Column(Modifier.padding(4.dp)) { - Text(chapter.name, fontSize = 20.sp, maxLines = 1) +fun ChapterItem( + chapter: Chapter, + format: (Date) -> String, + onClick: (Int) -> Unit, + toggleRead: (Int) -> Unit, + toggleBookmarked: (Int) -> Unit, + markPreviousAsRead: (Int) -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth().height(70.dp).padding(4.dp), + elevation = 1.dp + ) { + Column( + Modifier.padding(4.dp) + .combinedMouseClickable( + onClick = { + onClick(chapter.index) + }, + onRightClick = { + contextMenu( + it + ) { + menuItem("Toggle read") { toggleRead(chapter.index) } + menuItem("Mark previous as read") { markPreviousAsRead(chapter.index) } + separator() + menuItem("Toggle bookmarked") { toggleBookmarked(chapter.index) } + } + } + ) + ) { + Text( + chapter.name, fontSize = 20.sp, maxLines = 1, + color = if (!chapter.read) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) val description = mutableListOf() - if (chapter.dateUpload != 0L) { - description += format(Date(chapter.dateUpload)) + if (chapter.uploadDate != 0L) { + description += format(Date(chapter.uploadDate)) } if (!chapter.scanlator.isNullOrEmpty()) { description += chapter.scanlator } if (description.isNotEmpty()) { Spacer(Modifier.height(2.dp)) - Text(description.joinToString(" - "), maxLines = 1) + Text( + description.joinToString(" - "), maxLines = 1, + color = if (!chapter.read) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + } + ) } } } diff --git a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt index 54d36861..7a836d80 100644 --- a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt @@ -69,7 +69,7 @@ class MangaMenuViewModel @Inject constructor( } } - suspend fun refreshChaptersAsync(mangaId: Long) = withContext(Dispatchers.IO) { + private suspend fun refreshChaptersAsync(mangaId: Long) = withContext(Dispatchers.IO) { async { try { _chapters.value = chapterHandler.getChapters(mangaId) @@ -98,5 +98,32 @@ class MangaMenuViewModel @Inject constructor( else -> SimpleDateFormat(format, Locale.getDefault()) } + fun toggleRead(index: Int) { + scope.launch { + manga.value?.let { manga -> + chapterHandler.updateChapter(manga, index, read = !_chapters.value.first { it.index == index }.read) + _chapters.value = chapterHandler.getChapters(manga) + } + } + } + + fun toggleBookmarked(index: Int) { + scope.launch { + manga.value?.let { manga -> + chapterHandler.updateChapter(manga, index, bookmarked = !_chapters.value.first { it.index == index }.bookmarked) + _chapters.value = chapterHandler.getChapters(manga) + } + } + } + + fun markPreviousRead(index: Int) { + scope.launch { + manga.value?.let { manga -> + chapterHandler.updateChapter(manga, index, markPreviousRead = true) + _chapters.value = chapterHandler.getChapters(manga) + } + } + } + data class Params(val mangaId: Long) } diff --git a/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt b/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt index ac927ff0..f416e543 100644 --- a/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt @@ -7,7 +7,6 @@ package ca.gosyer.ui.settings import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -137,6 +136,7 @@ private fun ThemeItem( Color.White.copy(alpha = 0.15f) } Surface( + onClick = { onClick(theme) }, elevation = 4.dp, color = theme.colors.background, shape = borders, @@ -144,7 +144,6 @@ private fun ThemeItem( .size(100.dp, 160.dp) .padding(8.dp) .border(1.dp, borderColor, borders) - .clickable(onClick = { onClick(theme) }) ) { Column { Box( diff --git a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt index 35726ead..e9ff1973 100644 --- a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt @@ -6,28 +6,89 @@ package ca.gosyer.ui.settings +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.min +import ca.gosyer.data.server.interactions.BackupInteractionHandler import ca.gosyer.ui.base.components.Toolbar import ca.gosyer.ui.base.prefs.PreferenceRow import ca.gosyer.ui.base.vm.ViewModel import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.ui.main.Route import ca.gosyer.util.system.filePicker +import ca.gosyer.util.system.fileSaver import com.github.zsoltk.compose.router.BackStack +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import mu.KotlinLogging import java.io.File import javax.inject.Inject -class SettingsBackupViewModel @Inject constructor() : ViewModel() { +class SettingsBackupViewModel @Inject constructor( + private val backupHandler: BackupInteractionHandler +) : ViewModel() { private val logger = KotlinLogging.logger {} - fun setFile(file: File?) { - if (file == null || !file.exists()) { - logger.info { "Invalid file ${file?.absolutePath}" } - } else { - logger.info { file.absolutePath } + private val _restoring = MutableStateFlow(false) + val restoring = _restoring.asStateFlow() + private val _restoreError = MutableStateFlow(false) + val restoreError = _restoreError.asStateFlow() + + private val _creating = MutableStateFlow(false) + val creating = _creating.asStateFlow() + private val _creatingError = MutableStateFlow(false) + val creatingError = _creatingError.asStateFlow() + + fun restoreFile(file: File?) { + scope.launch { + if (file == null || !file.exists()) { + logger.info { "Invalid file ${file?.absolutePath}" } + } else { + _restoreError.value = false + _restoring.value = true + try { + backupHandler.importBackupFile(file) + } catch (e: Exception) { + logger.info(e) { "Error importing backup" } + _restoreError.value = true + } finally { + _restoring.value = false + } + } + } + } + + fun createFile(file: File?) { + scope.launch { + if (file == null) { + logger.info { "Invalid file ${file?.absolutePath}" } + } else { + if (file.exists()) file.delete() + _creatingError.value = false + _creating.value = true + try { + val backup = backupHandler.exportBackupFile() + } catch (e: Exception) { + logger.info(e) { "Error importing backup" } + _creatingError.value = true + } finally { + _creating.value = false + } + } } } } @@ -35,17 +96,63 @@ class SettingsBackupViewModel @Inject constructor() : ViewModel() { @Composable fun SettingsBackupScreen(navController: BackStack) { val vm = viewModel() + val restoring by vm.restoring.collectAsState() + val restoreError by vm.restoreError.collectAsState() + val creating by vm.creating.collectAsState() + val creatingError by vm.creatingError.collectAsState() Column { Toolbar("Backup Settings", navController, true) LazyColumn { item { - PreferenceRow( + PreferenceFile( "Restore Backup", - onClick = { - filePicker { - vm.setFile(it.selectedFile) - } + "Restore a backup into Tachidesk", + restoring, + restoreError + ) { + filePicker("json") { + vm.restoreFile(it.selectedFile) } + } + PreferenceFile( + "Create Backup", + "Create a backup from Tachidesk", + creating, + creatingError + ) { + fileSaver("test.json", "json") { + vm.createFile(it.selectedFile) + } + } + } + } + } +} + +@Composable +fun PreferenceFile(title: String, subtitle: String, working: Boolean, error: Boolean, onClick: () -> Unit) { + PreferenceRow( + title = title, + onClick = onClick, + enabled = !working, + subtitle = subtitle + ) { + BoxWithConstraints { + val size = remember(maxHeight, maxWidth) { + min(maxHeight, maxWidth) / 2 + } + val modifier = Modifier.align(Alignment.Center) + .size(size) + if (working) { + CircularProgressIndicator( + modifier + ) + } else if (error) { + Icon( + Icons.Default.Warning, + contentDescription = null, + modifier = modifier, + tint = Color.Red ) } } diff --git a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt index 90fc6f24..627e4066 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import ca.gosyer.data.models.Source @@ -33,7 +34,7 @@ import ca.gosyer.ui.sources.components.SourceHomeScreen import ca.gosyer.ui.sources.components.SourceScreen import ca.gosyer.util.compose.ThemedWindow import com.github.zsoltk.compose.savedinstancestate.Bundle -import com.github.zsoltk.compose.savedinstancestate.BundleScope +import com.github.zsoltk.compose.savedinstancestate.LocalSavedInstanceState fun openSourcesMenu() { ThemedWindow(title = "TachideskJUI - Sources") { @@ -47,9 +48,7 @@ private const val SOURCE_MENU_KEY = "source_menu" @Composable fun SourcesMenu(onMangaClick: (Long) -> Unit) { - BundleScope(SOURCE_MENU_KEY, autoDispose = false) { - SourcesMenu(it, onMangaClick) - } + SourcesMenu(LocalSavedInstanceState.current, onMangaClick) } @Composable @@ -87,12 +86,10 @@ fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) { } val selectedSource: Source? = selectedSourceTab - BundleScope("Sources") { - if (selectedSource != null) { - SourceScreen(selectedSource, onMangaClick) - } else { - SourceHomeScreen(isLoading, sources, serverUrl, vm::addTab) - } + if (selectedSource != null) { + SourceScreen(selectedSource, onMangaClick) + } else { + SourceHomeScreen(isLoading, sources, serverUrl, vm::addTab) } } } diff --git a/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt b/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt new file mode 100644 index 00000000..93a04105 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt @@ -0,0 +1,49 @@ +/* + * 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.util.compose + +import androidx.compose.ui.unit.IntOffset +import mu.KotlinLogging +import javax.swing.Icon +import javax.swing.JMenuItem +import javax.swing.JPopupMenu +import javax.swing.JSeparator + +class ContextMenu internal constructor() { + val logger = KotlinLogging.logger {} + internal val list = mutableListOf Unit)?>>() + + fun popupMenu() = JPopupMenu().apply { + fun (() -> Unit)?.andClose() { + isVisible = false + this?.invoke() + } + list.forEach { (item, block) -> + when (item) { + is JMenuItem -> add(item).apply { + addActionListener { + logger.info { it.actionCommand } + logger.info { it.modifiers } + block.andClose() + } + } + is JSeparator -> add(item) + } + } + } + + fun menuItem(name: String, icon: Icon? = null, builder: JMenuItem.() -> Unit = {}, action: () -> Unit) { + list += JMenuItem(name, icon).apply(builder) to action + } + fun separator() { + list += JSeparator() to null + } +} + +fun contextMenu(offset: IntOffset, contextMenu: ContextMenu.() -> Unit) { + ContextMenu().apply(contextMenu).popupMenu().show(null, offset.x, offset.y) +} diff --git a/src/main/kotlin/ca/gosyer/util/system/File.kt b/src/main/kotlin/ca/gosyer/util/system/File.kt index 754c7d9a..bfbd9176 100644 --- a/src/main/kotlin/ca/gosyer/util/system/File.kt +++ b/src/main/kotlin/ca/gosyer/util/system/File.kt @@ -12,6 +12,7 @@ import net.harawata.appdirs.AppDirsFactory import java.io.File import javax.swing.JFileChooser import javax.swing.SwingUtilities +import javax.swing.filechooser.FileNameExtensionFilter val appDirs: AppDirs by lazy { AppDirsFactory.getInstance() @@ -23,29 +24,69 @@ val userDataDir: File by lazy { } } +fun filePicker( + extension: String? = null, + builder: JFileChooser.() -> Unit = {}, + onCancel: (JFileChooser) -> Unit = {}, + onError: (JFileChooser) -> Unit = {}, + onApprove: (JFileChooser) -> Unit +) = fileChooser(false, builder, onCancel, onError, onApprove, extension) + +fun fileSaver( + defaultFileName: String, + extension: String, + builder: JFileChooser.() -> Unit = {}, + onCancel: (JFileChooser) -> Unit = {}, + onError: (JFileChooser) -> Unit = {}, + onApprove: (JFileChooser) -> Unit +) = fileChooser( + true, + builder, + onCancel, + onError, + onApprove, + extension, + defaultFileName +) + /** * Opens a swing file picker, in the details view by default * + * @param saving true if the dialog is going to save a file, false if its going to open a file * @param builder invokes this builder before launching the file picker, such as adding a action listener * @param onCancel the listener that is called when picking a file is canceled * @param onError the listener that is called when picking a file exited with a error * @param onApprove the listener that is called when picking a file is completed */ -fun filePicker( +private fun fileChooser( + saving: Boolean = false, builder: JFileChooser.() -> Unit = {}, onCancel: (JFileChooser) -> Unit = {}, onError: (JFileChooser) -> Unit = {}, - onApprove: (JFileChooser) -> Unit + onApprove: (JFileChooser) -> Unit, + extension: String? = null, + defaultFileName: String = "" ) = SwingUtilities.invokeLater { val fileChooser = JFileChooser() .apply { val details = actionMap.get("viewTypeDetails") details?.actionPerformed(null) + if (extension != null) { + fileFilter = FileNameExtensionFilter("$extension file", extension) + } + if (saving) { + selectedFile = File(defaultFileName) + } } .apply(builder) - val result = fileChooser - .showOpenDialog(null) + val result = fileChooser.let { + if (saving) { + it.showSaveDialog(null) + } else { + it.showOpenDialog(null) + } + } when (result) { JFileChooser.APPROVE_OPTION -> onApprove(fileChooser)