Compare commits
No commits in common. "8e94eb3850a959cbb0bfd3cd25ebba1a6dc5e08b" and "5f64f3d722e635ac8ae50c5b24e9994fe25128b7" have entirely different histories.
8e94eb3850
...
5f64f3d722
385 changed files with 53192 additions and 2964 deletions
36
AGENTS.md
36
AGENTS.md
|
|
@ -62,39 +62,15 @@
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required env on this server (proxy + Android SDK)
|
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
|
|
||||||
export ANDROID_HOME=/home/dueattendant149/android-sdk
|
|
||||||
export ANDROID_SDK_ROOT=/home/dueattendant149/android-sdk
|
|
||||||
./gradlew :app:assembleOssDebug
|
./gradlew :app:assembleOssDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
**Known build workarounds (Phase 1 baseline):**
|
|
||||||
- Gradle wrapper pinned to 8.13 (8.11.1 unavailable behind proxy; 9.5.1 incompatible with AGP 8.9).
|
|
||||||
- `externalNativeBuild` temporarily disabled because NDK 27.0.12077973 is requested but only NDK 28.2.x is installed.
|
|
||||||
- Network proxy is configured in `gradle.properties` (`systemProp.http[s].proxyHost/Port`).
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- Phase 0: clone + package rename — completed
|
- Phase 0: clone + package rename — completed
|
||||||
- Phase 1: KMP → Android-only — completed
|
- Phase 1: KMP → Android-only — pending
|
||||||
- Phase 2: backend swap (bookshelf-api + ABS) — completed
|
- Phase 2: backend swap (bookshelf-api + ABS) — pending
|
||||||
- Phase 2.1: bookshelf-api + ABS API services/models, Hilt DI, Retrofit/OkHttp
|
- Phase 3: UI redesign (Myne-style) — pending
|
||||||
- Phase 2.2: BookshelfViewModel + BookshelfLibraryScreen + nav entry
|
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
|
||||||
- Phase 2.3: ServerSettingsScreen (URL/token config)
|
- Phase 5: reader engine integration — pending
|
||||||
- Phase 2.4: download/open ebook from server in reader
|
- Phase 6: build, test, deploy — pending
|
||||||
- Phase 2.5: removed OPDS/Gutenberg/LocalFolder/CloudSync code + stubs
|
|
||||||
- Phase 3: UI redesign (Myne-style) — completed
|
|
||||||
- Material You 3 shape scheme (round cards 8-32dp)
|
|
||||||
- Dynamic color (Android 12+ dynamicColorScheme + materialkolor seed)
|
|
||||||
- BookshelfLibraryScreen + ServerSettingsScreen redesigned with round cards, CenterAlignedTopAppBar
|
|
||||||
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — completed
|
|
||||||
- RSVP engine (RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText)
|
|
||||||
- Audio player (AudioPlaybackService, ExoPlayer, MediaSession, PlaybackModule)
|
|
||||||
- TTS repository (RemoteTtsRepository + impl wrapping BookshelfApiRepository)
|
|
||||||
- Workers (no-op stubs for CacheDownload, ProgressSync, TtsDownload)
|
|
||||||
- Phase 5: reader engine integration — completed
|
|
||||||
- Checkpoint model (serializable reading position)
|
|
||||||
- ChapterDrawer (ModalBottomSheet chapter list)
|
|
||||||
- Volume keys + chapter navigation already present
|
|
||||||
- Phase 6: build, test, deploy — in progress
|
|
||||||
|
|
@ -11,7 +11,6 @@ plugins {
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.kotlin.ksp)
|
alias(libs.plugins.kotlin.ksp)
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
id("com.diffplug.spotless") version "8.2.1"
|
id("com.diffplug.spotless") version "8.2.1"
|
||||||
alias(libs.plugins.kover)
|
alias(libs.plugins.kover)
|
||||||
}
|
}
|
||||||
|
|
@ -47,9 +46,7 @@ fun configuredAppLocaleTags(): Set<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain {
|
jvmToolchain(21)
|
||||||
languageVersion.set(JavaLanguageVersion.of(21))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -67,13 +64,11 @@ android {
|
||||||
.map { it.toAndroidResourceConfiguration() }
|
.map { it.toAndroidResourceConfiguration() }
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
/*
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
cppFlags += ""
|
cppFlags += ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
buildConfigField("boolean", "IS_PRO", "false")
|
buildConfigField("boolean", "IS_PRO", "false")
|
||||||
buildConfigField("boolean", "IS_OFFLINE", "false")
|
buildConfigField("boolean", "IS_OFFLINE", "false")
|
||||||
}
|
}
|
||||||
|
|
@ -167,14 +162,12 @@ android {
|
||||||
singleVariant("release") {
|
singleVariant("release") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
path = file("src/main/cpp/CMakeLists.txt")
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
version = "3.22.1"
|
version = "3.22.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
unitTests.all {
|
unitTests.all {
|
||||||
|
|
@ -218,6 +211,8 @@ kover {
|
||||||
//noinspection UseTomlInstead
|
//noinspection UseTomlInstead
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
|
implementation(project(":shared"))
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
@ -229,16 +224,6 @@ dependencies {
|
||||||
implementation(libs.androidx.material3.window.size.class1.android)
|
implementation(libs.androidx.material3.window.size.class1.android)
|
||||||
implementation(libs.androidx.credentials)
|
implementation(libs.androidx.credentials)
|
||||||
|
|
||||||
// Hilt
|
|
||||||
implementation(libs.hilt.android)
|
|
||||||
ksp(libs.hilt.compiler)
|
|
||||||
implementation(libs.hilt.navigation.compose)
|
|
||||||
|
|
||||||
// Networking
|
|
||||||
implementation(libs.retrofit)
|
|
||||||
implementation(libs.retrofit.kotlinx.serialization)
|
|
||||||
implementation(libs.okhttp.logging)
|
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
|
@ -61,7 +59,6 @@ import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen
|
|
||||||
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
|
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
|
||||||
import org.dueattendant149.bookreader.feedback.FeedbackScreen
|
import org.dueattendant149.bookreader.feedback.FeedbackScreen
|
||||||
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
|
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
|
||||||
|
|
@ -82,8 +79,6 @@ object AppDestinations {
|
||||||
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
||||||
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
||||||
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
|
||||||
const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library"
|
|
||||||
const val SERVER_SETTINGS_ROUTE = "server_settings"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shouldInterceptAppNavBack(
|
fun shouldInterceptAppNavBack(
|
||||||
|
|
@ -432,34 +427,6 @@ fun AppNavigation(
|
||||||
onBackClick = { navController.popBackStackIfReady() }
|
onBackClick = { navController.popBackStackIfReady() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) {
|
|
||||||
val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel()
|
|
||||||
LaunchedEffect(bookshelfViewModel) {
|
|
||||||
bookshelfViewModel.downloadedFile.collect { uri ->
|
|
||||||
Timber.d("Downloaded book $uri, opening in reader")
|
|
||||||
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BookshelfLibraryScreen(
|
|
||||||
viewModel = bookshelfViewModel,
|
|
||||||
onItemClick = { item ->
|
|
||||||
Timber.d("Bookshelf item selected: ${item.id}")
|
|
||||||
bookshelfViewModel.downloadBook(item)
|
|
||||||
},
|
|
||||||
onOpenSettings = {
|
|
||||||
navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) {
|
|
||||||
val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel()
|
|
||||||
org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen(
|
|
||||||
viewModel = settingsViewModel,
|
|
||||||
onBackClick = { navController.popBackStackIfReady() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package org.dueattendant149.bookreader
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import org.dueattendant149.bookreader.data.BookMetadata
|
||||||
|
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||||
|
import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
|
||||||
|
import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
internal const val CloudSyncTraceTag = "EpistemeCloudSync"
|
||||||
|
internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
|
||||||
|
|
||||||
|
internal fun logCloudSyncTrace(message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.d(CloudSyncTraceTag, text)
|
||||||
|
Timber.tag(CloudSyncTraceTag).d(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logCloudSyncError(error: Throwable, message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.e(CloudSyncTraceTag, text, error)
|
||||||
|
Timber.tag(CloudSyncTraceTag).e(error, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logCloudAnnotationSyncTrace(message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.d(CloudAnnotationSyncTraceTag, text)
|
||||||
|
Timber.tag(CloudAnnotationSyncTraceTag).d(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
val text = message()
|
||||||
|
Log.e(CloudAnnotationSyncTraceTag, text, error)
|
||||||
|
Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
|
||||||
|
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
|
||||||
|
"contentTs=$fileContentModifiedTimestamp " +
|
||||||
|
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
|
||||||
|
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
|
||||||
|
"bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
|
||||||
|
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
|
||||||
|
"annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
|
||||||
|
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
|
||||||
|
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
|
||||||
|
"hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
|
||||||
|
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
|
||||||
|
val value = this ?: return "null"
|
||||||
|
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.cloudSyncAnnotationSummary(): String {
|
||||||
|
val value = this?.trim() ?: return "null"
|
||||||
|
return when {
|
||||||
|
value.isEmpty() -> "blank"
|
||||||
|
value == "[]" -> "empty"
|
||||||
|
else -> "present(${value.length})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package org.dueattendant149.bookreader
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubs for removed CloudSyncTrace.kt. All calls are no-ops.
|
|
||||||
*/
|
|
||||||
|
|
||||||
internal inline fun logCloudSyncTrace(crossinline message: () -> String) {
|
|
||||||
// no-op: cloud sync removed
|
|
||||||
}
|
|
||||||
|
|
||||||
internal inline fun logCloudSyncError(throwable: Throwable, crossinline message: () -> String) {
|
|
||||||
// no-op: cloud sync removed
|
|
||||||
}
|
|
||||||
|
|
||||||
internal inline fun logCloudSyncError(crossinline message: () -> String) {
|
|
||||||
// no-op: cloud sync removed
|
|
||||||
}
|
|
||||||
|
|
||||||
internal inline fun logCloudAnnotationSyncTrace(crossinline message: () -> String) {
|
|
||||||
// no-op: cloud annotation sync removed
|
|
||||||
}
|
|
||||||
|
|
||||||
internal inline fun logCloudAnnotationSyncError(throwable: Throwable, crossinline message: () -> String) {
|
|
||||||
// no-op: cloud annotation sync removed
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Any?.cloudSyncTraceSummary(label: String = ""): String = ""
|
|
||||||
|
|
||||||
internal fun Any?.cloudSyncAnnotationSummary(): String = ""
|
|
||||||
|
|
||||||
internal fun Any?.cloudSyncPreview(): String = this?.toString() ?: ""
|
|
||||||
|
|
||||||
internal fun String.cloudSyncPreview(): String = this
|
|
||||||
|
|
||||||
internal fun String.cloudSyncPreview(maxLen: Int): String = take(maxLen)
|
|
||||||
|
|
||||||
internal fun String?.cloudSyncPreviewOrEmpty(): String = this ?: ""
|
|
||||||
|
|
@ -0,0 +1,773 @@
|
||||||
|
/*
|
||||||
|
* Episteme Reader - A native Android document reader.
|
||||||
|
* Copyright (C) 2026 Episteme
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* mail: epistemereader@gmail.com
|
||||||
|
*/
|
||||||
|
// FolderSyncWorker.kt
|
||||||
|
package org.dueattendant149.bookreader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import timber.log.Timber
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||||
|
import org.dueattendant149.bookreader.data.RecentFilesRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import org.dueattendant149.bookreader.data.LocalSyncUtils
|
||||||
|
import org.dueattendant149.bookreader.data.FolderBookMetadata
|
||||||
|
import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
|
||||||
|
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
|
||||||
|
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
|
||||||
|
import org.dueattendant149.bookreader.shared.EpubBookmark
|
||||||
|
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
||||||
|
import java.io.File
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
|
||||||
|
class FolderSyncWorker(
|
||||||
|
private val appContext: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
|
private val recentFilesRepository = RecentFilesRepository(appContext)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WORK_NAME = "FolderSyncWorker"
|
||||||
|
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
|
||||||
|
const val KEY_METADATA_ONLY = "key_metadata_only"
|
||||||
|
const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
|
||||||
|
private val syncMutex = Mutex()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val workerStart = ReaderPerfLog.nowNanos()
|
||||||
|
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
||||||
|
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
||||||
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
|
||||||
|
val folders = SyncedFolderPrefs.decodeSyncedFolders(
|
||||||
|
jsonString = jsonString,
|
||||||
|
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||||
|
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
if (folders.isEmpty()) {
|
||||||
|
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
val enabledFolders = folders.filter { it.localSyncEnabled }
|
||||||
|
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
||||||
|
enabledFolders
|
||||||
|
} else {
|
||||||
|
enabledFolders.filter { it.uriString == targetFolderUri }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foldersToProcess.isEmpty()) {
|
||||||
|
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
|
||||||
|
"target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
|
||||||
|
)
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
syncMutex.withLock {
|
||||||
|
var allSuccess = true
|
||||||
|
|
||||||
|
for (folderConfig in foldersToProcess) {
|
||||||
|
val success = performSyncForFolder(folderConfig, isMetadataOnly)
|
||||||
|
if (!success) allSuccess = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonString != null) {
|
||||||
|
try {
|
||||||
|
val array = org.json.JSONArray(jsonString)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val obj = array.getJSONObject(i)
|
||||||
|
if (obj.optString("uri") in processedUris) {
|
||||||
|
obj.put("lastScanTime", now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
val elapsed = ReaderPerfLog.elapsedMs(workerStart)
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
|
||||||
|
"folders=${foldersToProcess.size} elapsed=${elapsed}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allSuccess) Result.success() else Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
|
||||||
|
val folderUriString = folderConfig.uriString
|
||||||
|
val allowedFileTypes = folderConfig.allowedFileTypes
|
||||||
|
if (folderUriString.isBlank()) return true
|
||||||
|
val folderUri = folderUriString.toUri()
|
||||||
|
val folderStart = ReaderPerfLog.nowNanos()
|
||||||
|
var dirsScanned = 0
|
||||||
|
var filesSeen = 0
|
||||||
|
var supportedBooksSeen = 0
|
||||||
|
var dbFlushes = 0
|
||||||
|
var sidecarsImported = 0
|
||||||
|
var stoppedForUnlinkedFolder = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isFolderStillLinked(folderUriString)) {
|
||||||
|
ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appContext.contentResolver.takePersistableUriPermission(
|
||||||
|
folderUri,
|
||||||
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
||||||
|
if (documentTree == null || !documentTree.isDirectory) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
|
||||||
|
|
||||||
|
val folderMetadataMap = ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase metadata-sidecars",
|
||||||
|
minLogMs = 25L,
|
||||||
|
details = { "metadataOnly=$metadataOnly" }
|
||||||
|
) {
|
||||||
|
LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
|
||||||
|
}
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
|
||||||
|
)
|
||||||
|
|
||||||
|
val existingFolderBooks = ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase load-existing-db",
|
||||||
|
minLogMs = 25L
|
||||||
|
) {
|
||||||
|
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
|
}
|
||||||
|
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
|
||||||
|
|
||||||
|
val scanResult = if (metadataOnly) {
|
||||||
|
AndroidFolderScanResult()
|
||||||
|
} else {
|
||||||
|
ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase scan-folder",
|
||||||
|
minLogMs = 25L
|
||||||
|
) {
|
||||||
|
scanFolderFiles(
|
||||||
|
folderUri = folderUri,
|
||||||
|
folderUriString = folderUriString,
|
||||||
|
allowedFileTypes = allowedFileTypes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirsScanned = scanResult.dirsScanned
|
||||||
|
filesSeen = scanResult.filesSeen
|
||||||
|
supportedBooksSeen = scanResult.files.size
|
||||||
|
stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder
|
||||||
|
|
||||||
|
if (isStopped || stoppedForUnlinkedFolder) {
|
||||||
|
ReaderPerfLog.w(
|
||||||
|
"FolderSync folder aborted before shared engine stopped=$isStopped " +
|
||||||
|
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val nowMillis = System.currentTimeMillis()
|
||||||
|
val folder = SyncedFolder(
|
||||||
|
uriString = folderUriString,
|
||||||
|
name = documentTree.name ?: folderConfig.name,
|
||||||
|
lastScanTime = nowMillis,
|
||||||
|
allowedFileTypes = allowedFileTypes,
|
||||||
|
localSyncEnabled = true
|
||||||
|
)
|
||||||
|
val sharedState = SharedReaderScreenState(
|
||||||
|
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
||||||
|
syncedFolders = listOf(folder)
|
||||||
|
)
|
||||||
|
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||||
|
state = sharedState,
|
||||||
|
folder = folder,
|
||||||
|
files = scanResult.files,
|
||||||
|
remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() },
|
||||||
|
nowMillis = nowMillis,
|
||||||
|
metadataOnly = metadataOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
if (syncResult.idMigrations.isNotEmpty()) {
|
||||||
|
val preloadedSidecars = ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase migration-sidecars",
|
||||||
|
minLogMs = 25L
|
||||||
|
) {
|
||||||
|
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap()
|
||||||
|
}
|
||||||
|
syncResult.idMigrations.forEach { (oldId, newId) ->
|
||||||
|
Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId")
|
||||||
|
migrateFolderBookId(
|
||||||
|
folderUriString = folderUriString,
|
||||||
|
oldId = oldId,
|
||||||
|
newId = newId,
|
||||||
|
folderMetadataMap = folderMetadataMap,
|
||||||
|
preloadedSidecars = preloadedSidecars,
|
||||||
|
existingItemsMap = existingItemsMap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFolderStillLinked(folderUriString)) {
|
||||||
|
ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString")
|
||||||
|
stoppedForUnlinkedFolder = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val scannedFilesById = scanResult.files.associateBy { it.stableBookId }
|
||||||
|
val syncedItems = syncResult.state.rawLibraryBooks.map { book ->
|
||||||
|
val existing = existingItemsMap[book.id]
|
||||||
|
val metadata = appliedMetadataFor(
|
||||||
|
book = book,
|
||||||
|
existing = existing,
|
||||||
|
metadata = folderMetadataMap[book.id]
|
||||||
|
)
|
||||||
|
book.toFolderSyncRecentFileItem(
|
||||||
|
existing = existing,
|
||||||
|
appliedMetadata = metadata,
|
||||||
|
scannedFile = scannedFilesById[book.id],
|
||||||
|
nowMillis = nowMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item }
|
||||||
|
|
||||||
|
changedItems
|
||||||
|
.filter { item ->
|
||||||
|
val previous = existingItemsMap[item.bookId]
|
||||||
|
previous != null && folderFileContentChanged(previous, item)
|
||||||
|
}
|
||||||
|
.forEach { item ->
|
||||||
|
Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.")
|
||||||
|
recentFilesRepository.clearLocalCachesForBook(item.bookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedItems.isNotEmpty()) {
|
||||||
|
recentFilesRepository.addRecentFiles(changedItems)
|
||||||
|
dbFlushes++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) {
|
||||||
|
Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
|
||||||
|
recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val booksForAnnotationSync = if (metadataOnly) {
|
||||||
|
syncedItems
|
||||||
|
} else {
|
||||||
|
ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase load-post-scan-db",
|
||||||
|
minLogMs = 25L
|
||||||
|
) {
|
||||||
|
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||||
|
folderUri = folderUri,
|
||||||
|
folderUriString = folderUriString,
|
||||||
|
books = booksForAnnotationSync,
|
||||||
|
phase = if (metadataOnly) "metadata-only" else "post-scan"
|
||||||
|
)
|
||||||
|
|
||||||
|
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
|
||||||
|
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
|
||||||
|
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
|
||||||
|
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " +
|
||||||
|
"removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " +
|
||||||
|
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
|
||||||
|
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
|
||||||
|
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
|
||||||
|
ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
|
||||||
|
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
|
||||||
|
.setInputData(
|
||||||
|
androidx.work.Data.Builder()
|
||||||
|
.putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||||
|
MetadataExtractionWorker.WORK_NAME,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
metaRequest
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun importAnnotationSidecarsForBooks(
|
||||||
|
folderUri: android.net.Uri,
|
||||||
|
folderUriString: String,
|
||||||
|
books: List<RecentFileItem>,
|
||||||
|
phase: String
|
||||||
|
): Int {
|
||||||
|
if (books.isEmpty()) {
|
||||||
|
ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val preloadedSidecars = ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase annotation-sidecars",
|
||||||
|
minLogMs = 25L,
|
||||||
|
details = { "phase=$phase" }
|
||||||
|
) {
|
||||||
|
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (preloadedSidecars.isEmpty()) return 0
|
||||||
|
|
||||||
|
var imported = 0
|
||||||
|
Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
|
||||||
|
for (book in books) {
|
||||||
|
if (isStopped || !isFolderStillLinked(folderUriString)) break
|
||||||
|
|
||||||
|
val sidecarData = preloadedSidecars[book.bookId] ?: continue
|
||||||
|
val (remoteTs, jsonPayload) = sidecarData
|
||||||
|
|
||||||
|
val safeSlashBookId = book.bookId.replace("/", "_")
|
||||||
|
val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
|
||||||
|
val localFiles = listOf(
|
||||||
|
File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
|
||||||
|
File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
|
||||||
|
File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
|
||||||
|
File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
|
||||||
|
File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
|
||||||
|
)
|
||||||
|
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
|
||||||
|
|
||||||
|
if (remoteTs > (localTs + 1000)) {
|
||||||
|
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
|
||||||
|
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
|
||||||
|
)
|
||||||
|
return imported
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AndroidFolderScanResult(
|
||||||
|
val files: List<SharedFolderScannedFile> = emptyList(),
|
||||||
|
val dirsScanned: Int = 0,
|
||||||
|
val filesSeen: Int = 0,
|
||||||
|
val stoppedForUnlinkedFolder: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun scanFolderFiles(
|
||||||
|
folderUri: android.net.Uri,
|
||||||
|
folderUriString: String,
|
||||||
|
allowedFileTypes: Set<FileType>
|
||||||
|
): AndroidFolderScanResult {
|
||||||
|
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
|
||||||
|
val contentResolver = appContext.contentResolver
|
||||||
|
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
||||||
|
val dirQueue = ArrayDeque<String>()
|
||||||
|
val scannedFiles = mutableListOf<SharedFolderScannedFile>()
|
||||||
|
var dirsScanned = 0
|
||||||
|
var filesSeen = 0
|
||||||
|
var stoppedForUnlinkedFolder = false
|
||||||
|
dirQueue.add(rootDocId)
|
||||||
|
|
||||||
|
val projection = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
DocumentsContract.Document.COLUMN_SIZE,
|
||||||
|
DocumentsContract.Document.COLUMN_LAST_MODIFIED
|
||||||
|
)
|
||||||
|
|
||||||
|
while (dirQueue.isNotEmpty()) {
|
||||||
|
if (isStopped) break
|
||||||
|
if (!isFolderStillLinked(folderUriString)) {
|
||||||
|
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
|
||||||
|
stoppedForUnlinkedFolder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val currentDocId = dirQueue.removeFirst()
|
||||||
|
dirsScanned++
|
||||||
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||||
|
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||||
|
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||||
|
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
|
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
|
||||||
|
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||||
|
|
||||||
|
while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
|
||||||
|
val docId = cursor.getString(idCol)
|
||||||
|
val name = cursor.getString(nameCol) ?: ""
|
||||||
|
val mimeType = cursor.getString(mimeCol)
|
||||||
|
filesSeen++
|
||||||
|
|
||||||
|
if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
|
||||||
|
ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
|
||||||
|
stoppedForUnlinkedFolder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||||
|
if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) {
|
||||||
|
dirQueue.add(docId)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val type = getFileType(name, mimeType)
|
||||||
|
if (
|
||||||
|
type == null ||
|
||||||
|
type !in allowedFileTypes ||
|
||||||
|
!isLocalFolderSyncEligibleFile(name, mimeType) ||
|
||||||
|
name.endsWith(".json") ||
|
||||||
|
name.startsWith(".")
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||||
|
val relativePath = buildRelativePath(rootDocId, docId, name)
|
||||||
|
scannedFiles += SharedFolderScannedFile(
|
||||||
|
name = name,
|
||||||
|
path = docUri.toString(),
|
||||||
|
sourceFolder = folderUriString,
|
||||||
|
relativePath = relativePath,
|
||||||
|
type = type,
|
||||||
|
size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L,
|
||||||
|
lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stoppedForUnlinkedFolder) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return AndroidFolderScanResult(
|
||||||
|
files = scannedFiles,
|
||||||
|
dirsScanned = dirsScanned,
|
||||||
|
filesSeen = filesSeen,
|
||||||
|
stoppedForUnlinkedFolder = stoppedForUnlinkedFolder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem {
|
||||||
|
return SharedBookItem(
|
||||||
|
id = bookId,
|
||||||
|
path = uriString,
|
||||||
|
type = type,
|
||||||
|
displayName = displayName,
|
||||||
|
timestamp = lastModifiedTimestamp,
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description,
|
||||||
|
originalTitle = originalTitle,
|
||||||
|
originalAuthor = originalAuthor,
|
||||||
|
originalSeriesName = originalSeriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex,
|
||||||
|
originalDescription = originalDescription,
|
||||||
|
progressPercentage = progressPercentage,
|
||||||
|
isRecent = isRecent,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
|
sourceFolder = sourceFolderUri,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
lastPageIndex = lastPage,
|
||||||
|
readerPosition = readerPositionOrNull(),
|
||||||
|
readerBookmarks = parseReaderBookmarks(),
|
||||||
|
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
|
||||||
|
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SharedBookItem.toFolderSyncRecentFileItem(
|
||||||
|
existing: RecentFileItem?,
|
||||||
|
appliedMetadata: FolderBookMetadata?,
|
||||||
|
scannedFile: SharedFolderScannedFile?,
|
||||||
|
nowMillis: Long
|
||||||
|
): RecentFileItem {
|
||||||
|
val contentChanged = existing != null && folderFileContentChanged(existing, this)
|
||||||
|
val localModifiedTimestamp = when {
|
||||||
|
appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp
|
||||||
|
contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp
|
||||||
|
timestamp > 0L -> timestamp
|
||||||
|
else -> nowMillis
|
||||||
|
}
|
||||||
|
val legacyPosition = readerPosition
|
||||||
|
val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id)
|
||||||
|
val mappedHighlightsJson = readerHighlights
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||||
|
val bookmarksJson = if (appliedMetadata != null || existing == null) {
|
||||||
|
mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson
|
||||||
|
} else {
|
||||||
|
existing.bookmarksJson
|
||||||
|
}
|
||||||
|
val highlightsJson = if (appliedMetadata != null || existing == null) {
|
||||||
|
mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson
|
||||||
|
} else {
|
||||||
|
existing.highlightsJson
|
||||||
|
}
|
||||||
|
|
||||||
|
return RecentFileItem(
|
||||||
|
bookId = id,
|
||||||
|
uriString = path,
|
||||||
|
type = type,
|
||||||
|
displayName = scannedFile?.name ?: existing?.displayName ?: displayName,
|
||||||
|
timestamp = when {
|
||||||
|
existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp
|
||||||
|
appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp
|
||||||
|
else -> existing.timestamp
|
||||||
|
},
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
||||||
|
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
||||||
|
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
||||||
|
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
||||||
|
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
||||||
|
progressPercentage = progressPercentage,
|
||||||
|
isRecent = isRecent,
|
||||||
|
isAvailable = true,
|
||||||
|
lastModifiedTimestamp = localModifiedTimestamp,
|
||||||
|
isDeleted = false,
|
||||||
|
bookmarksJson = bookmarksJson,
|
||||||
|
sourceFolderUri = sourceFolder,
|
||||||
|
isReflowPreferred = existing?.isReflowPreferred ?: false,
|
||||||
|
customName = appliedMetadata?.customName ?: existing?.customName,
|
||||||
|
highlightsJson = highlightsJson,
|
||||||
|
fileSize = fileSize,
|
||||||
|
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
description = description,
|
||||||
|
originalTitle = originalTitle,
|
||||||
|
originalAuthor = originalAuthor,
|
||||||
|
originalSeriesName = originalSeriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex,
|
||||||
|
originalDescription = originalDescription,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
|
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
||||||
|
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
||||||
|
tags = existing?.tags.orEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appliedMetadataFor(
|
||||||
|
book: SharedBookItem,
|
||||||
|
existing: RecentFileItem?,
|
||||||
|
metadata: FolderBookMetadata?
|
||||||
|
): FolderBookMetadata? {
|
||||||
|
if (metadata == null) return null
|
||||||
|
val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE
|
||||||
|
return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? {
|
||||||
|
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
|
||||||
|
return ReaderLocator.fromLegacy(
|
||||||
|
chapterIndex = lastChapterIndex,
|
||||||
|
cfi = lastPositionCfi,
|
||||||
|
pageIndex = lastPage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RecentFileItem.parseReaderBookmarks(): List<ReaderBookmark> {
|
||||||
|
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||||
|
.mapIndexed { index, bookmark ->
|
||||||
|
val locator = bookmark.locator.withFallbacks(
|
||||||
|
chapterIndex = bookmark.chapterIndex,
|
||||||
|
cfi = bookmark.cfi,
|
||||||
|
pageIndex = bookmark.pageInChapter?.minus(1),
|
||||||
|
textQuote = bookmark.snippet
|
||||||
|
)
|
||||||
|
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
|
||||||
|
ReaderBookmark(
|
||||||
|
id = "bookmark_${bookId}_$index",
|
||||||
|
pageIndex = pageIndex.coerceAtLeast(0),
|
||||||
|
chapterTitle = bookmark.chapterTitle,
|
||||||
|
preview = bookmark.snippet,
|
||||||
|
locator = locator
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ReaderBookmark>.toAndroidBookmarksJson(bookId: String): String? {
|
||||||
|
val bookmarks = mapIndexed { index, bookmark ->
|
||||||
|
val locator = bookmark.locator
|
||||||
|
val chapterIndex = locator.chapterIndex ?: 0
|
||||||
|
val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}"
|
||||||
|
EpubBookmark(
|
||||||
|
cfi = cfi,
|
||||||
|
chapterTitle = bookmark.chapterTitle,
|
||||||
|
label = null,
|
||||||
|
snippet = bookmark.preview,
|
||||||
|
pageInChapter = bookmark.pageIndex + 1,
|
||||||
|
totalPagesInChapter = null,
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
locator = locator.withFallbacks(
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
cfi = cfi,
|
||||||
|
pageIndex = bookmark.pageIndex,
|
||||||
|
textQuote = bookmark.preview
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean {
|
||||||
|
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
|
||||||
|
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
|
||||||
|
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
|
||||||
|
return sizeChanged || modifiedChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean {
|
||||||
|
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
|
||||||
|
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
|
||||||
|
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
|
||||||
|
return sizeChanged || modifiedChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||||
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
return SyncedFolderPrefs.isLocalSyncEnabled(
|
||||||
|
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
||||||
|
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||||
|
folderUriString = folderUriString,
|
||||||
|
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||||
|
return resolveFileTypeFromMetadata(name, mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
|
||||||
|
val rootPath = rootDocId.substringAfter(':', "")
|
||||||
|
val docPath = docId.substringAfter(':', "")
|
||||||
|
if (docPath.isBlank()) return fallbackName
|
||||||
|
val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) {
|
||||||
|
docPath.removePrefix(rootPath).trimStart('/')
|
||||||
|
} else {
|
||||||
|
docPath.substringAfterLast('/', fallbackName)
|
||||||
|
}
|
||||||
|
return relative.ifBlank { fallbackName }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateFolderBookId(
|
||||||
|
folderUriString: String,
|
||||||
|
oldId: String,
|
||||||
|
newId: String,
|
||||||
|
folderMetadataMap: MutableMap<String, FolderBookMetadata>,
|
||||||
|
preloadedSidecars: MutableMap<String, Pair<Long, String>>,
|
||||||
|
existingItemsMap: MutableMap<String, RecentFileItem>
|
||||||
|
) {
|
||||||
|
if (oldId == newId) return
|
||||||
|
|
||||||
|
recentFilesRepository.migrateBookIdLocally(oldId, newId)
|
||||||
|
|
||||||
|
val oldMetadata = folderMetadataMap.remove(oldId)
|
||||||
|
if (oldMetadata != null && newId !in folderMetadataMap) {
|
||||||
|
val migratedMetadata = oldMetadata.copy(bookId = newId)
|
||||||
|
LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata)
|
||||||
|
folderMetadataMap[newId] = migratedMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
val oldSidecar = preloadedSidecars.remove(oldId)
|
||||||
|
if (oldSidecar != null && newId !in preloadedSidecars) {
|
||||||
|
LocalSyncUtils.saveAnnotationSidecar(
|
||||||
|
context = appContext,
|
||||||
|
sourceFolderUri = folderUriString.toUri(),
|
||||||
|
bookId = newId,
|
||||||
|
jsonPayload = oldSidecar.second,
|
||||||
|
timestamp = oldSidecar.first
|
||||||
|
)
|
||||||
|
preloadedSidecars[newId] = oldSidecar
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId)
|
||||||
|
|
||||||
|
existingItemsMap.remove(oldId)
|
||||||
|
recentFilesRepository.getFileByBookId(newId)?.let {
|
||||||
|
existingItemsMap[newId] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package org.dueattendant149.bookreader
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub for removed FolderSyncWorker. Keeps constants and a no-op CoroutineWorker
|
|
||||||
* so existing WorkManager enqueue calls compile.
|
|
||||||
*/
|
|
||||||
const val KEY_METADATA_ONLY = "metadata_only"
|
|
||||||
const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
|
|
||||||
const val KEY_TRIGGER_REASON = "trigger_reason"
|
|
||||||
|
|
||||||
class FolderSyncWorker(
|
|
||||||
appContext: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(appContext, params) {
|
|
||||||
override suspend fun doWork(): Result = Result.success()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val WORK_NAME = "folder_sync_work"
|
|
||||||
const val WORK_NAME_ONETIME = "folder_sync_work_onetime"
|
|
||||||
const val KEY_METADATA_ONLY = "metadata_only"
|
|
||||||
const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
|
|
||||||
const val KEY_TRIGGER_REASON = "trigger_reason"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -24,11 +24,9 @@ import android.webkit.WebView
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
import coil.decode.SvgDecoder
|
import coil.decode.SvgDecoder
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
|
||||||
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
||||||
import timber.log.Timber // Add this
|
import timber.log.Timber // Add this
|
||||||
|
|
||||||
@HiltAndroidApp
|
|
||||||
class MyApplication : Application(), ImageLoaderFactory {
|
class MyApplication : Application(), ImageLoaderFactory {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.dueattendant149.bookreader.opds
|
||||||
|
|
||||||
|
typealias OpdsCatalog = org.dueattendant149.bookreader.shared.opds.OpdsCatalog
|
||||||
|
typealias OpdsFacet = org.dueattendant149.bookreader.shared.opds.OpdsFacet
|
||||||
|
typealias OpdsFeed = org.dueattendant149.bookreader.shared.opds.OpdsFeed
|
||||||
|
typealias OpdsAuthor = org.dueattendant149.bookreader.shared.opds.OpdsAuthor
|
||||||
|
typealias OpdsAcquisition = org.dueattendant149.bookreader.shared.opds.OpdsAcquisition
|
||||||
|
typealias OpdsEntry = org.dueattendant149.bookreader.shared.opds.OpdsEntry
|
||||||
|
typealias OpdsDownloadState = org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState
|
||||||
|
typealias OpdsScreenState = org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package org.dueattendant149.bookreader.opds
|
||||||
|
|
||||||
|
typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
package org.dueattendant149.bookreader.opds
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs
|
||||||
|
import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class OpdsRepository(context: Context) : SharedOpdsRepository {
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
|
||||||
|
private val parser = OpdsParser()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
|
||||||
|
|
||||||
|
val sharedHttpClient: OkHttpClient by lazy {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
val requestWithUserAgent = originalRequest.newBuilder()
|
||||||
|
.header("User-Agent", "EpistemeReader/1.0 (Android)")
|
||||||
|
.build()
|
||||||
|
chain.proceed(requestWithUserAgent)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val httpClient = sharedHttpClient
|
||||||
|
|
||||||
|
override fun loadCatalogs(): List<OpdsCatalog> {
|
||||||
|
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
|
||||||
|
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
|
||||||
|
val catalogs = decodedCatalogs.ifEmpty {
|
||||||
|
SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
|
||||||
|
}
|
||||||
|
if (decodedCatalogs.isEmpty()) {
|
||||||
|
saveCatalogs(catalogs)
|
||||||
|
}
|
||||||
|
return catalogs
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCatalogs(): List<OpdsCatalog> = loadCatalogs()
|
||||||
|
|
||||||
|
override suspend fun getSearchTemplate(
|
||||||
|
openSearchUrl: String,
|
||||||
|
username: String?,
|
||||||
|
password: String?
|
||||||
|
): String? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder().url(openSearchUrl).build()
|
||||||
|
val response = getAuthenticatedClient(username, password).newCall(request).execute()
|
||||||
|
val body = response.body?.string() ?: return@withContext null
|
||||||
|
parser.extractOpenSearchTemplate(body, openSearchUrl)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to fetch OpenSearch template")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
|
||||||
|
saveCatalogs(
|
||||||
|
SharedOpdsCatalogs.addCatalog(
|
||||||
|
catalogs = loadCatalogs(),
|
||||||
|
title = title,
|
||||||
|
url = url,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
idFactory = { UUID.randomUUID().toString() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||||
|
saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCatalog(id: String) {
|
||||||
|
saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
||||||
|
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
|
||||||
|
return httpClient.newBuilder()
|
||||||
|
.authenticator(OpdsAuthenticator(username, password))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpdsAuthenticator(private val user: String?, private val pass: String?) : okhttp3.Authenticator {
|
||||||
|
private var cnonceCount = 0
|
||||||
|
|
||||||
|
override fun authenticate(route: okhttp3.Route?, response: okhttp3.Response): Request? {
|
||||||
|
if (user.isNullOrBlank() || pass.isNullOrBlank()) return null
|
||||||
|
|
||||||
|
if (response.request.header("Authorization") != null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val wwwAuth = response.header("WWW-Authenticate") ?: return null
|
||||||
|
|
||||||
|
if (wwwAuth.startsWith("Basic", ignoreCase = true)) {
|
||||||
|
val credential = okhttp3.Credentials.basic(user, pass)
|
||||||
|
return response.request.newBuilder().header("Authorization", credential).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
|
||||||
|
val realm = extractParam(wwwAuth, "realm") ?: ""
|
||||||
|
val nonce = extractParam(wwwAuth, "nonce") ?: ""
|
||||||
|
val qop = selectAuthQop(extractParam(wwwAuth, "qop"))
|
||||||
|
val opaque = extractParam(wwwAuth, "opaque")
|
||||||
|
|
||||||
|
cnonceCount++
|
||||||
|
val nc = String.format("%08x", cnonceCount)
|
||||||
|
val cnonce = UUID.randomUUID().toString().replace("-", "")
|
||||||
|
|
||||||
|
val url = response.request.url
|
||||||
|
val uri = url.encodedPath + (if (url.encodedQuery != null) "?${url.encodedQuery}" else "")
|
||||||
|
|
||||||
|
val ha1 = md5("$user:$realm:$pass")
|
||||||
|
val ha2 = md5("${response.request.method}:$uri")
|
||||||
|
|
||||||
|
val responseHash = if (qop != null) {
|
||||||
|
md5("$ha1:$nonce:$nc:$cnonce:$qop:$ha2")
|
||||||
|
} else {
|
||||||
|
md5("$ha1:$nonce:$ha2")
|
||||||
|
}
|
||||||
|
|
||||||
|
val digestHeader = buildString {
|
||||||
|
append("Digest username=\"$user\", ")
|
||||||
|
append("realm=\"$realm\", ")
|
||||||
|
append("nonce=\"$nonce\", ")
|
||||||
|
append("uri=\"$uri\", ")
|
||||||
|
append("response=\"$responseHash\"")
|
||||||
|
if (qop != null) {
|
||||||
|
append(", qop=$qop, nc=$nc, cnonce=\"$cnonce\"")
|
||||||
|
}
|
||||||
|
if (opaque != null) {
|
||||||
|
append(", opaque=\"$opaque\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.request.newBuilder()
|
||||||
|
.header("Authorization", digestHeader)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractParam(header: String, param: String): String? {
|
||||||
|
val match = Regex("$param=\"([^\"]+)\"").find(header) ?: Regex("$param=([^,\\s]+)").find(header)
|
||||||
|
return match?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectAuthQop(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.split(',')
|
||||||
|
?.map { it.trim().trim('"') }
|
||||||
|
?.firstOrNull { it.equals("auth", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun md5(input: String): String {
|
||||||
|
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
||||||
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> = withContext(Dispatchers.IO) {
|
||||||
|
Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
|
||||||
|
try {
|
||||||
|
val client = getAuthenticatedClient(username, password)
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url.trim())
|
||||||
|
.header("User-Agent", "EpistemeReader/1.0 (Android)")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Timber.tag("OpdsDebug").d("Executing network call...")
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
Timber.tag("OpdsDebug").d("Response Code: ${response.code}")
|
||||||
|
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorMsg = "HTTP ${response.code}: ${response.message}"
|
||||||
|
Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg")
|
||||||
|
return@withContext Result.failure(Exception(errorMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyString = response.body?.string()
|
||||||
|
if (bodyString.isNullOrBlank()) {
|
||||||
|
return@withContext Result.failure(Exception("Empty response body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val feed = parser.parse(bodyString, url)
|
||||||
|
|
||||||
|
Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.")
|
||||||
|
Result.success(feed)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url")
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
package org.dueattendant149.bookreader.opds
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import org.dueattendant149.bookreader.R
|
||||||
|
import org.dueattendant149.bookreader.shared.opds.SharedOpdsController
|
||||||
|
import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val repository = OpdsRepository(application)
|
||||||
|
private val controller = SharedOpdsController(
|
||||||
|
repository = repository,
|
||||||
|
feedLoadErrorMessage = { error ->
|
||||||
|
application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
|
||||||
|
},
|
||||||
|
idFactory = { UUID.randomUUID().toString() }
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(controller.state)
|
||||||
|
val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun loadNextPage() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
controller.loadNextPage(::emitState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
|
||||||
|
val downloadUrl = acquisition.url
|
||||||
|
val catalog = _uiState.value.currentCatalog
|
||||||
|
viewModelScope.launch {
|
||||||
|
updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
|
||||||
|
try {
|
||||||
|
val tempFile = withContext(Dispatchers.IO) {
|
||||||
|
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
|
||||||
|
val request = Request.Builder().url(downloadUrl).build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw OpdsDownloadFailedException(
|
||||||
|
context.getString(R.string.opds_error_download_failed, response.message)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body
|
||||||
|
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||||
|
val contentLength = body.contentLength()
|
||||||
|
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||||
|
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
|
||||||
|
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
||||||
|
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
tempFile.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(8 * 1024)
|
||||||
|
var totalRead = 0L
|
||||||
|
var lastProgressUpdate = System.currentTimeMillis()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val bytesRead = input.read(buffer)
|
||||||
|
if (bytesRead == -1) break
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
totalRead += bytesRead
|
||||||
|
|
||||||
|
if (contentLength > 0) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastProgressUpdate > 200) {
|
||||||
|
val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateDownloadState(
|
||||||
|
entry.id,
|
||||||
|
OpdsDownloadState(isDownloading = true, progress = progress)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lastProgressUpdate = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tempFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownloaded(Uri.fromFile(tempFile))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Download error")
|
||||||
|
val message = if (e is OpdsDownloadFailedException) {
|
||||||
|
e.message.orEmpty()
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.opds_error_download_error, e.message.orEmpty())
|
||||||
|
}
|
||||||
|
emitState(controller.setErrorMessage(message))
|
||||||
|
} finally {
|
||||||
|
updateDownloadState(entry.id, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
|
||||||
|
return SharedOpdsDownloadNamer.resolveExtension(
|
||||||
|
acquisition = acquisition,
|
||||||
|
contentDisposition = response.header("Content-Disposition"),
|
||||||
|
urlPathSegment = Uri.parse(acquisition.url).lastPathSegment
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCatalog(title: String, url: String, username: String?, password: String?) {
|
||||||
|
emitState(controller.addCatalog(title, url, username, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCatalog(id: String) {
|
||||||
|
emitState(controller.removeCatalog(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openCatalog(catalog: OpdsCatalog) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
controller.openCatalog(catalog, ::emitState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFeedUrl(url: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
controller.openFeedUrl(url, ::emitState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateBack(): Boolean {
|
||||||
|
val returnsToPreviousFeed = controller.hasFeedHistory()
|
||||||
|
viewModelScope.launch {
|
||||||
|
controller.navigateBack(::emitState)
|
||||||
|
}
|
||||||
|
return returnsToPreviousFeed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||||
|
emitState(controller.updateCatalog(id, title, url, username, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
controller.search(query, ::emitState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
emitState(controller.clearError())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
|
||||||
|
emitState(controller.updateDownloadState(entryId, downloadState))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitState(state: OpdsScreenState) {
|
||||||
|
_uiState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OpdsDownloadFailedException(message: String) : Exception(message)
|
||||||
|
}
|
||||||
|
|
@ -783,7 +783,20 @@ class OpdsStreamDocumentWrapper(
|
||||||
) : ReaderDocument {
|
) : ReaderDocument {
|
||||||
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
|
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
|
||||||
|
|
||||||
private val client = okhttp3.OkHttpClient.Builder().build()
|
private val catalog = catalogId?.let {
|
||||||
|
org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder()
|
||||||
|
.apply {
|
||||||
|
val streamCatalog = catalog
|
||||||
|
val username = streamCatalog?.username
|
||||||
|
val password = streamCatalog?.password
|
||||||
|
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
|
||||||
|
authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
private fun createErrorPageBytes(): ByteArray {
|
private fun createErrorPageBytes(): ByteArray {
|
||||||
val bitmap = createBitmap(800, 1200)
|
val bitmap = createBitmap(800, 1200)
|
||||||
|
|
@ -814,7 +827,18 @@ class OpdsStreamDocumentWrapper(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val finalUrlTemplate = urlTemplate
|
val streamCatalog = catalog
|
||||||
|
val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
|
||||||
|
try {
|
||||||
|
val oldUrl = java.net.URL(urlTemplate)
|
||||||
|
val newUrl = java.net.URL(streamCatalog.url)
|
||||||
|
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
|
||||||
|
val newBase = "${newUrl.protocol}://${newUrl.authority}"
|
||||||
|
urlTemplate.replace(oldBase, newBase)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
urlTemplate
|
||||||
|
}
|
||||||
|
} else urlTemplate
|
||||||
|
|
||||||
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
|
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
|
||||||
.replace("{maxWidth}", "1600")
|
.replace("{maxWidth}", "1600")
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Shapes
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Myne-style Material You 3 shape scheme: generous rounded corners.
|
|
||||||
*/
|
|
||||||
val AppShapes = Shapes(
|
|
||||||
extraSmall = RoundedCornerShape(8.dp),
|
|
||||||
small = RoundedCornerShape(12.dp),
|
|
||||||
medium = RoundedCornerShape(16.dp),
|
|
||||||
large = RoundedCornerShape(24.dp),
|
|
||||||
extraLarge = RoundedCornerShape(32.dp),
|
|
||||||
)
|
|
||||||
|
|
@ -178,7 +178,6 @@ fun AppTheme(
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = finalColorScheme,
|
colorScheme = finalColorScheme,
|
||||||
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
|
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
|
||||||
shapes = AppShapes,
|
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.bookshelf
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ServerSettingsScreen(
|
|
||||||
viewModel: ServerSettingsViewModel,
|
|
||||||
onBackClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
var bookshelfUrl by remember(uiState.bookshelfUrl) { mutableStateOf(uiState.bookshelfUrl) }
|
|
||||||
var absUrl by remember(uiState.absUrl) { mutableStateOf(uiState.absUrl) }
|
|
||||||
var absToken by remember(uiState.absToken) { mutableStateOf(uiState.absToken) }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
androidx.compose.material3.CenterAlignedTopAppBar(
|
|
||||||
title = { Text("Server settings") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBackClick) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
SettingsSectionCard(title = "Bookshelf API") {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = bookshelfUrl,
|
|
||||||
onValueChange = {
|
|
||||||
bookshelfUrl = it
|
|
||||||
viewModel.updateBookshelfUrl(it)
|
|
||||||
},
|
|
||||||
label = { Text("https://books.dueattendant149.org/") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsSectionCard(title = "Audiobookshelf") {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = absUrl,
|
|
||||||
onValueChange = {
|
|
||||||
absUrl = it
|
|
||||||
viewModel.updateAbsUrl(it)
|
|
||||||
},
|
|
||||||
label = { Text("https://abs.dueattendant149.org/") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = absToken,
|
|
||||||
onValueChange = {
|
|
||||||
absToken = it
|
|
||||||
viewModel.updateAbsToken(it)
|
|
||||||
},
|
|
||||||
label = { Text("Bearer token") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = viewModel::save,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = MaterialTheme.shapes.large
|
|
||||||
) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.saved) {
|
|
||||||
Text(
|
|
||||||
"Saved",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SettingsSectionCard(
|
|
||||||
title: String,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
|
||||||
)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.bookshelf
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
data class ServerSettingsUiState(
|
|
||||||
val bookshelfUrl: String = "",
|
|
||||||
val absUrl: String = "",
|
|
||||||
val absToken: String = "",
|
|
||||||
val saved: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class ServerSettingsViewModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ServerSettingsUiState())
|
|
||||||
val uiState: StateFlow<ServerSettingsUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
_uiState.value = ServerSettingsUiState(
|
|
||||||
bookshelfUrl = serverSettings.getBookshelfUrl() ?: "",
|
|
||||||
absUrl = serverSettings.getAbsUrl() ?: "",
|
|
||||||
absToken = serverSettings.getAbsToken() ?: "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateBookshelfUrl(url: String) {
|
|
||||||
_uiState.value = _uiState.value.copy(bookshelfUrl = url, saved = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateAbsUrl(url: String) {
|
|
||||||
_uiState.value = _uiState.value.copy(absUrl = url, saved = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateAbsToken(token: String) {
|
|
||||||
_uiState.value = _uiState.value.copy(absToken = token, saved = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
serverSettings.setBookshelfUrl(_uiState.value.bookshelfUrl.trim())
|
|
||||||
serverSettings.setAbsUrl(_uiState.value.absUrl.trim())
|
|
||||||
serverSettings.setAbsToken(_uiState.value.absToken.trim())
|
|
||||||
_uiState.value = _uiState.value.copy(saved = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookreader.audio
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.session.MediaSession
|
|
||||||
import androidx.media3.session.MediaSessionService
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.dueattendant149.bookreader.R
|
|
||||||
import org.dueattendant149.bookreader.audio.Book
|
|
||||||
import org.dueattendant149.bookreader.MainActivity
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
class AudioPlaybackService : MediaSessionService() {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var exoPlayer: ExoPlayer
|
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel()
|
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
||||||
}
|
|
||||||
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
} else {
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
}
|
|
||||||
val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
|
|
||||||
mediaSession = MediaSession.Builder(this, exoPlayer)
|
|
||||||
.setSessionActivity(sessionActivity)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
intent?.getParcelableExtra(EXTRA_BOOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = buildNotification(book)
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
|
||||||
return mediaSession
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
val player = mediaSession?.player ?: exoPlayer
|
|
||||||
if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
mediaSession?.run {
|
|
||||||
player.release()
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
mediaSession = null
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
getString(R.string.audio_playback_channel),
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = getString(R.string.audio_playback_channel_description)
|
|
||||||
}
|
|
||||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildNotification(book: Book?): Notification {
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(book?.title ?: getString(R.string.app_name))
|
|
||||||
.setContentText(book?.author ?: "")
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setSilent(true)
|
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val EXTRA_BOOK = "extra_book"
|
|
||||||
private const val CHANNEL_ID = "audio_playback"
|
|
||||||
private const val NOTIFICATION_ID = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookreader.audio
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for the audio player progress state.
|
|
||||||
*
|
|
||||||
* TODO: When implementing the ExoPlayer/MediaSession audio player,
|
|
||||||
* hook [org.dueattendant149.bookreader.domain.use_case.remote.SyncPlaybackProgressUseCase]
|
|
||||||
* into the playback position update flow, e.g.:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* val book = ...
|
|
||||||
* val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
|
|
||||||
* val position = player.currentPosition.coerceAtLeast(0L)
|
|
||||||
* val duration = player.duration.coerceAtLeast(book.audioDuration)
|
|
||||||
* syncPlaybackProgressUseCase(book, currentFile, position, duration)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
data class AudioPlayerProgress(
|
|
||||||
val bookId: Int,
|
|
||||||
val currentFile: String,
|
|
||||||
val position: Long,
|
|
||||||
val duration: Long,
|
|
||||||
)
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.audio
|
|
||||||
|
|
||||||
data class AudioTrack(
|
|
||||||
val fileId: String,
|
|
||||||
val title: String,
|
|
||||||
val durationMs: Long,
|
|
||||||
val order: Int,
|
|
||||||
)
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.audio
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal Book model stub for AudioPlaybackService.
|
|
||||||
* Ported from Book's Story domain model.
|
|
||||||
*/
|
|
||||||
data class Book(
|
|
||||||
val id: Long = 0,
|
|
||||||
val title: String = "",
|
|
||||||
val author: String = "",
|
|
||||||
val remoteId: String = "",
|
|
||||||
val libraryId: String = "",
|
|
||||||
val hasAudio: Boolean = false,
|
|
||||||
val audioDuration: Long = 0L,
|
|
||||||
val audioCurrentFile: String = "",
|
|
||||||
val audioCurrentPosition: Long = 0L,
|
|
||||||
val coverUrl: String = "",
|
|
||||||
)
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.bookshelf
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun BookshelfLibraryScreen(
|
|
||||||
viewModel: BookshelfViewModel,
|
|
||||||
onItemClick: (UnifiedItemResponse) -> Unit,
|
|
||||||
onOpenSettings: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (uiState.libraries.isEmpty()) {
|
|
||||||
viewModel.loadLibraries()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(viewModel) {
|
|
||||||
viewModel.downloadedFile.collect { uri ->
|
|
||||||
Timber.d("Downloaded book to $uri, opening in reader")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
androidx.compose.material3.CenterAlignedTopAppBar(
|
|
||||||
title = { Text("Server Library") },
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onOpenSettings) {
|
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Server settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading && uiState.libraries.isEmpty()) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
return@Scaffold
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.error?.let { error ->
|
|
||||||
Text(
|
|
||||||
text = error,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val selected = uiState.selectedLibrary
|
|
||||||
if (selected == null) {
|
|
||||||
Text(
|
|
||||||
text = "Select a library",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
LazyColumn(
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
items(uiState.libraries) { library ->
|
|
||||||
LibraryCard(
|
|
||||||
library = library,
|
|
||||||
onClick = { viewModel.selectLibrary(library) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = selected.name,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
if (uiState.downloadingItem != null) {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
LazyColumn(
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
items(uiState.items) { item ->
|
|
||||||
ItemCard(
|
|
||||||
item = item,
|
|
||||||
isDownloading = uiState.downloadingItem?.id == item.id,
|
|
||||||
onClick = {
|
|
||||||
viewModel.downloadBook(item)
|
|
||||||
onItemClick(item)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LibraryCard(
|
|
||||||
library: LibraryResponse,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 6.dp),
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
|
||||||
Text(
|
|
||||||
text = library.name,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${library.itemCount} items · ${library.mediaType}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ItemCard(
|
|
||||||
item: UnifiedItemResponse,
|
|
||||||
isDownloading: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 6.dp),
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
|
||||||
Text(
|
|
||||||
text = item.title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (item.author.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = item.author,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = item.type,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
if (isDownloading) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 12.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Downloading...",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.bookshelf
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BookshelfViewModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
application: Application,
|
|
||||||
private val repository: BookshelfApiRepository,
|
|
||||||
) : AndroidViewModel(application) {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(BookshelfUiState())
|
|
||||||
val uiState: StateFlow<BookshelfUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private val _downloadedFile = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
|
|
||||||
val downloadedFile: SharedFlow<Uri> = _downloadedFile.asSharedFlow()
|
|
||||||
|
|
||||||
fun loadLibraries() {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.getLibraries()
|
|
||||||
.onSuccess { libraries ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
libraries = libraries,
|
|
||||||
isLoading = false,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = error.message ?: "Failed to load libraries"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectLibrary(library: LibraryResponse) {
|
|
||||||
_uiState.value = _uiState.value.copy(selectedLibrary = library, items = emptyList())
|
|
||||||
loadItems(library.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadItems(libraryId: String) {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.getLibraryItems(libraryId)
|
|
||||||
.onSuccess { response ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
items = response.items,
|
|
||||||
isLoading = false,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = error.message ?: "Failed to load items"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun search(libraryId: String, query: String) {
|
|
||||||
if (query.isBlank()) {
|
|
||||||
loadItems(libraryId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.searchLibrary(libraryId, query)
|
|
||||||
.onSuccess { searchItems ->
|
|
||||||
val mapped = searchItems.map { bookItem ->
|
|
||||||
UnifiedItemResponse(
|
|
||||||
id = bookItem.id,
|
|
||||||
title = bookItem.title,
|
|
||||||
author = bookItem.author,
|
|
||||||
type = bookItem.mediaType,
|
|
||||||
mediaType = bookItem.mediaType,
|
|
||||||
libraryId = bookItem.libraryId,
|
|
||||||
coverUrl = bookItem.coverUrl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
items = mapped,
|
|
||||||
isLoading = false,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = error.message ?: "Search failed"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadBook(item: UnifiedItemResponse) {
|
|
||||||
_uiState.value = _uiState.value.copy(downloadingItem = item)
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.downloadEbook(item.id)
|
|
||||||
.onSuccess { body ->
|
|
||||||
val file = saveToCache(item, body)
|
|
||||||
if (file != null) {
|
|
||||||
_downloadedFile.tryEmit(Uri.fromFile(file))
|
|
||||||
}
|
|
||||||
_uiState.value = _uiState.value.copy(downloadingItem = null)
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
downloadingItem = null,
|
|
||||||
error = error.message ?: "Download failed"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? {
|
|
||||||
return runCatching {
|
|
||||||
val cacheDir = getApplication<Application>().cacheDir
|
|
||||||
val dir = File(cacheDir, "bookshelf_downloads").apply { mkdirs() }
|
|
||||||
val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60)
|
|
||||||
val ext = guessExtension(item)
|
|
||||||
val file = File(dir, "${safeName}_${item.id}.$ext")
|
|
||||||
file.outputStream().use { out -> body.byteStream().copyTo(out) }
|
|
||||||
file
|
|
||||||
}.getOrElse {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
downloadingItem = null,
|
|
||||||
error = "Failed to save file: ${it.message}"
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun guessExtension(item: UnifiedItemResponse): String = when {
|
|
||||||
item.mediaType.contains("epub", ignoreCase = true) -> "epub"
|
|
||||||
item.mediaType.contains("pdf", ignoreCase = true) -> "pdf"
|
|
||||||
item.mediaType.contains("fb2", ignoreCase = true) -> "fb2"
|
|
||||||
item.mediaType.contains("mobi", ignoreCase = true) -> "mobi"
|
|
||||||
item.mediaType.contains("azw3", ignoreCase = true) -> "azw3"
|
|
||||||
item.mediaType.contains("docx", ignoreCase = true) -> "docx"
|
|
||||||
item.mediaType.contains("odt", ignoreCase = true) -> "odt"
|
|
||||||
item.mediaType.contains("txt", ignoreCase = true) -> "txt"
|
|
||||||
item.mediaType.contains("md", ignoreCase = true) -> "md"
|
|
||||||
else -> "epub"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
data class BookshelfUiState(
|
|
||||||
val libraries: List<LibraryResponse> = emptyList(),
|
|
||||||
val selectedLibrary: LibraryResponse? = null,
|
|
||||||
val items: List<UnifiedItemResponse> = emptyList(),
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
val downloadingItem: UnifiedItemResponse? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.audiobookshelf
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import org.dueattendant149.bookreader.domain.util.fixUriScheme
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds (and caches) a Retrofit-backed [AudiobookshelfApiService] for the configured
|
|
||||||
* Audiobookshelf instance. All requests are authenticated with the provided bearer token.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class AudiobookshelfApiClientFactory
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val json: Json,
|
|
||||||
private val okHttpClient: OkHttpClient,
|
|
||||||
) {
|
|
||||||
private var cachedUrl: String? = null
|
|
||||||
private var cachedToken: String? = null
|
|
||||||
private var cachedClient: AudiobookshelfApiService? = null
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun provideClient(
|
|
||||||
url: String,
|
|
||||||
token: String,
|
|
||||||
): AudiobookshelfApiService? {
|
|
||||||
val fixedUrl = url.fixUriScheme()
|
|
||||||
if (fixedUrl == cachedUrl && token == cachedToken && cachedClient != null) {
|
|
||||||
return cachedClient
|
|
||||||
}
|
|
||||||
|
|
||||||
val authClient =
|
|
||||||
okHttpClient
|
|
||||||
.newBuilder()
|
|
||||||
.addInterceptor { chain ->
|
|
||||||
val request =
|
|
||||||
chain
|
|
||||||
.request()
|
|
||||||
.newBuilder()
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
chain.proceed(request)
|
|
||||||
}
|
|
||||||
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
Retrofit
|
|
||||||
.Builder()
|
|
||||||
.client(authClient)
|
|
||||||
.baseUrl(fixedUrl)
|
|
||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
|
||||||
.build()
|
|
||||||
.create(AudiobookshelfApiService::class.java)
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("AudiobookshelfApiFactory", "Failed to create client for $fixedUrl", it)
|
|
||||||
}.getOrNull().also {
|
|
||||||
cachedUrl = fixedUrl
|
|
||||||
cachedToken = token
|
|
||||||
cachedClient = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearCache() {
|
|
||||||
cachedUrl = null
|
|
||||||
cachedToken = null
|
|
||||||
cachedClient = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.audiobookshelf
|
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import org.dueattendant149.bookreader.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
|
|
||||||
import retrofit2.Response
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.Path
|
|
||||||
import retrofit2.http.Streaming
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Direct Audiobookshelf API calls for audio files and covers.
|
|
||||||
* bookshelf-api does not proxy audio streams, so the app talks to ABS directly.
|
|
||||||
*/
|
|
||||||
interface AudiobookshelfApiService {
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/items/{itemId}/file/{fileId}")
|
|
||||||
suspend fun downloadAudioFile(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Path("fileId") fileId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/items/{itemId}/download")
|
|
||||||
suspend fun downloadBook(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@GET("api/items/{itemId}/cover")
|
|
||||||
suspend fun getCover(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@GET("api/me/progress/{itemId}")
|
|
||||||
suspend fun getProgress(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<String>
|
|
||||||
|
|
||||||
@GET("api/items/{itemId}")
|
|
||||||
suspend fun getItem(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<AbsItemResponse>
|
|
||||||
|
|
||||||
@POST("api/me/progress/{itemId}")
|
|
||||||
suspend fun updateProgress(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Body request: PlaybackProgressUpdateRequest,
|
|
||||||
): Response<String>
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.audiobookshelf
|
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
|
|
||||||
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
|
||||||
import retrofit2.Response
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin repository over [AudiobookshelfApiService]. Direct ABS calls for audio,
|
|
||||||
* cover and progress. bookshelf-api does not proxy audio streams.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class AudiobookshelfRepository
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val clientFactory: AudiobookshelfApiClientFactory,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) {
|
|
||||||
private suspend fun client() =
|
|
||||||
serverSettings.getAbsUrl()?.let { url ->
|
|
||||||
serverSettings.getAbsToken()?.let { token ->
|
|
||||||
clientFactory.provideClient(url, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getItem(itemId: String): Result<AbsItemResponse> {
|
|
||||||
val client = client() ?: return Result.failure(absNotConfigured())
|
|
||||||
return safe { client.getItem(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getCover(itemId: String): Result<ResponseBody> {
|
|
||||||
val client = client() ?: return Result.failure(absNotConfigured())
|
|
||||||
return safe { client.getCover(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadBook(itemId: String): Result<ResponseBody> {
|
|
||||||
val client = client() ?: return Result.failure(absNotConfigured())
|
|
||||||
return safe { client.downloadBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadAudioFile(
|
|
||||||
itemId: String,
|
|
||||||
fileId: String,
|
|
||||||
): Result<ResponseBody> {
|
|
||||||
val client = client() ?: return Result.failure(absNotConfigured())
|
|
||||||
return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getProgress(itemId: String): Result<String> {
|
|
||||||
val client = client() ?: return Result.failure(absNotConfigured())
|
|
||||||
return safe { client.getProgress(itemId).unwrapBody() ?: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateProgress(
|
|
||||||
itemId: String,
|
|
||||||
request: PlaybackProgressUpdateRequest,
|
|
||||||
): Result<String> {
|
|
||||||
val client = client() ?: return Result.failure(absNotConfigured())
|
|
||||||
return safe { client.updateProgress(itemId, request).unwrapBody() ?: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun absNotConfigured() = IllegalStateException("Audiobookshelf URL/token not configured")
|
|
||||||
|
|
||||||
private inline fun <T> safe(block: () -> T): Result<T> = runCatching(block)
|
|
||||||
|
|
||||||
private fun <T> Response<T>.unwrapBody(): T? {
|
|
||||||
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
|
|
||||||
return body()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.audiobookshelf
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for updating media progress directly on an Audiobookshelf server.
|
|
||||||
*
|
|
||||||
* Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlaybackProgressUpdateRequest(
|
|
||||||
@SerialName("currentTime")
|
|
||||||
val currentTime: Double,
|
|
||||||
@SerialName("duration")
|
|
||||||
val duration: Double,
|
|
||||||
@SerialName("progress")
|
|
||||||
val progress: Double,
|
|
||||||
@SerialName("episodeId")
|
|
||||||
val episodeId: String? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.audiobookshelf.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsItemResponse(
|
|
||||||
val id: String = "",
|
|
||||||
val media: AbsMedia? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsMedia(
|
|
||||||
val metadata: AbsMediaMetadata? = null,
|
|
||||||
@SerialName("audioFiles")
|
|
||||||
val audioFiles: List<AbsAudioFile> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsMediaMetadata(
|
|
||||||
val title: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsAudioFile(
|
|
||||||
val ino: String = "",
|
|
||||||
val metadata: AbsAudioFileMetadata? = null,
|
|
||||||
val duration: Double = 0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsAudioFileMetadata(
|
|
||||||
val filename: String? = null,
|
|
||||||
val ext: String? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import org.dueattendant149.bookreader.domain.util.fixUriScheme
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds (and caches) a Retrofit-backed [BookshelfApiService] for the configured
|
|
||||||
* bookshelf-api instance.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class BookshelfApiClientFactory
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val json: Json,
|
|
||||||
private val okHttpClient: OkHttpClient,
|
|
||||||
) {
|
|
||||||
private var cachedUrl: String? = null
|
|
||||||
private var cachedClient: BookshelfApiService? = null
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun provideClient(url: String): BookshelfApiService? {
|
|
||||||
val fixedUrl = url.fixUriScheme()
|
|
||||||
if (fixedUrl == cachedUrl && cachedClient != null) {
|
|
||||||
return cachedClient
|
|
||||||
}
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
Retrofit
|
|
||||||
.Builder()
|
|
||||||
.client(
|
|
||||||
okHttpClient
|
|
||||||
.newBuilder()
|
|
||||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BASIC
|
|
||||||
})
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.baseUrl(fixedUrl)
|
|
||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
|
||||||
.build()
|
|
||||||
.create(BookshelfApiService::class.java)
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("BookshelfApiClientFactory", "Failed to create BookshelfApiService for $fixedUrl", it)
|
|
||||||
}.getOrNull().also {
|
|
||||||
cachedUrl = fixedUrl
|
|
||||||
cachedClient = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearCache() {
|
|
||||||
cachedUrl = null
|
|
||||||
cachedClient = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi
|
|
||||||
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
|
|
||||||
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
|
||||||
import retrofit2.Response
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin repository over [BookshelfApiService]. Returns raw Retrofit responses
|
|
||||||
* so callers can decide how to map them to local entities.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class BookshelfApiRepository
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val clientFactory: BookshelfApiClientFactory,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) {
|
|
||||||
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
|
||||||
|
|
||||||
suspend fun health(): Boolean {
|
|
||||||
val client = client() ?: return false
|
|
||||||
return runCatching { client.health().isSuccessful }.getOrDefault(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLibraries(): Result<List<LibraryResponse>> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.getLibraries().unwrapBody().orEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLibraryItems(libraryId: String): Result<LibraryItemsResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.getLibraryItems(libraryId).unwrapBody() ?: LibraryItemsResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchLibrary(
|
|
||||||
libraryId: String,
|
|
||||||
query: String,
|
|
||||||
): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse>> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.searchLibrary(libraryId, query).unwrapBody().orEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun search(request: SearchRequest): Result<SearchResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.search(request).unwrapBody() ?: SearchResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getBook(itemId: String): Result<okhttp3.ResponseBody> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.getBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadEbook(itemId: String): Result<okhttp3.ResponseBody> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.downloadEbook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAudioTracks(itemId: String): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse>> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.getAudioTracks(itemId).unwrapBody().orEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadAudioFile(
|
|
||||||
itemId: String,
|
|
||||||
fileId: String,
|
|
||||||
): Result<okhttp3.ResponseBody> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateReadingProgress(
|
|
||||||
itemId: String,
|
|
||||||
request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest,
|
|
||||||
): Result<Unit> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.updateReadingProgress(itemId, request).let {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updatePlaybackProgress(
|
|
||||||
itemId: String,
|
|
||||||
request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest,
|
|
||||||
): Result<Unit> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.updatePlaybackProgress(itemId, request).let {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getTtsEngines(): Result<TtsEnginesResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.getTtsEngines().unwrapBody() ?: TtsEnginesResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getTtsVoices(engine: String? = null): Result<TtsVoicesResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.getTtsVoices(engine).unwrapBody() ?: TtsVoicesResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createTtsJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result<TtsJobResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.createTtsJob(request).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getTtsJobStatus(jobId: String): Result<TtsJobResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.getTtsJobStatus(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun listTtsJobs(): Result<TtsJobsResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.listTtsJobs().unwrapBody() ?: TtsJobsResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun downloadTtsAudio(jobId: String): Result<okhttp3.ResponseBody> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.downloadTtsAudio(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun startDownload(request: DownloadRequest): Result<DownloadResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.startDownload(request).unwrapBody() ?: DownloadResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun listDownloads(): Result<DownloadsListResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.listDownloads().unwrapBody() ?: DownloadsListResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun uploadBook(
|
|
||||||
file: MultipartBody.Part,
|
|
||||||
bookType: RequestBody,
|
|
||||||
): Result<UploadBookResponse> {
|
|
||||||
val client = client() ?: return Result.failure(serverNotConfigured())
|
|
||||||
return safe { client.uploadBook(file, bookType).unwrapBody() ?: UploadBookResponse(success = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun serverNotConfigured() = IllegalStateException("Bookshelf API URL not configured")
|
|
||||||
|
|
||||||
private inline fun <T> safe(block: () -> T): Result<T> = runCatching(block)
|
|
||||||
|
|
||||||
private fun <T> Response<T>.unwrapBody(): T? {
|
|
||||||
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
|
|
||||||
return body()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi
|
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexResponse
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import retrofit2.Response
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.Multipart
|
|
||||||
import retrofit2.http.Part
|
|
||||||
import retrofit2.http.Path
|
|
||||||
import retrofit2.http.Query
|
|
||||||
import retrofit2.http.Streaming
|
|
||||||
import retrofit2.http.POST
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
|
|
||||||
* All endpoints are relative to the configured base URL.
|
|
||||||
*/
|
|
||||||
interface BookshelfApiService {
|
|
||||||
|
|
||||||
// Health
|
|
||||||
@GET("health")
|
|
||||||
suspend fun health(): Response<Unit>
|
|
||||||
|
|
||||||
// Libraries / books
|
|
||||||
@GET("api/v1/books/libraries")
|
|
||||||
suspend fun getLibraries(): Response<List<LibraryResponse>>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/v1/books/{itemId}/ebook")
|
|
||||||
suspend fun downloadEbook(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@GET("api/v1/books/{itemId}/tracks")
|
|
||||||
suspend fun getAudioTracks(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<List<AudioTrackResponse>>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/v1/books/{itemId}/file/{fileId}")
|
|
||||||
suspend fun downloadAudioFile(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Path("fileId") fileId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/v1/books/{itemId}")
|
|
||||||
suspend fun getBook(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@POST("api/v1/books/{itemId}/progress")
|
|
||||||
suspend fun updateReadingProgress(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Body request: ProgressUpdateRequest,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@POST("api/v1/books/{itemId}/playback-progress")
|
|
||||||
suspend fun updatePlaybackProgress(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Body request: PlaybackProgressUpdateRequest,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@GET("api/v1/books/library/{libraryId}/items")
|
|
||||||
suspend fun getLibraryItems(
|
|
||||||
@Path("libraryId") libraryId: String,
|
|
||||||
): Response<LibraryItemsResponse>
|
|
||||||
|
|
||||||
@GET("api/v1/books/library/{libraryId}/search")
|
|
||||||
suspend fun searchLibrary(
|
|
||||||
@Path("libraryId") libraryId: String,
|
|
||||||
@Query("q") query: String,
|
|
||||||
): Response<List<BookItemResponse>>
|
|
||||||
|
|
||||||
@POST("api/v1/search")
|
|
||||||
suspend fun search(
|
|
||||||
@Body request: SearchRequest,
|
|
||||||
): Response<SearchResponse>
|
|
||||||
|
|
||||||
// TTS
|
|
||||||
@GET("api/v1/tts/engines")
|
|
||||||
suspend fun getTtsEngines(): Response<TtsEnginesResponse>
|
|
||||||
|
|
||||||
@GET("api/v1/tts/voices")
|
|
||||||
suspend fun getTtsVoices(
|
|
||||||
@Query("engine") engine: String? = null,
|
|
||||||
): Response<TtsVoicesResponse>
|
|
||||||
|
|
||||||
@POST("api/v1/tts")
|
|
||||||
suspend fun createTtsJob(
|
|
||||||
@Body request: TtsCreateRequest,
|
|
||||||
): Response<TtsJobResponse>
|
|
||||||
|
|
||||||
@GET("api/v1/tts/{jobId}")
|
|
||||||
suspend fun getTtsJobStatus(
|
|
||||||
@Path("jobId") jobId: String,
|
|
||||||
): Response<TtsJobResponse>
|
|
||||||
|
|
||||||
@GET("api/v1/tts/jobs/list")
|
|
||||||
suspend fun listTtsJobs(): Response<TtsJobsResponse>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/v1/tts/{jobId}/download")
|
|
||||||
suspend fun downloadTtsAudio(
|
|
||||||
@Path("jobId") jobId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@Multipart
|
|
||||||
@POST("api/v1/upload/book")
|
|
||||||
suspend fun uploadBook(
|
|
||||||
@Part file: MultipartBody.Part,
|
|
||||||
@Part("book_type") bookType: RequestBody,
|
|
||||||
): Response<UploadBookResponse>
|
|
||||||
|
|
||||||
// Downloads / sources
|
|
||||||
@POST("api/v1/download")
|
|
||||||
suspend fun startDownload(
|
|
||||||
@Body request: DownloadRequest,
|
|
||||||
): Response<DownloadResponse>
|
|
||||||
|
|
||||||
@GET("api/v1/download/list")
|
|
||||||
suspend fun listDownloads(): Response<DownloadsListResponse>
|
|
||||||
|
|
||||||
@POST("api/v1/podcasts")
|
|
||||||
suspend fun downloadPodcast(
|
|
||||||
@Body request: PodcastRequest,
|
|
||||||
): Response<PodcastResponse>
|
|
||||||
|
|
||||||
@POST("api/v1/sources/yandex")
|
|
||||||
suspend fun downloadYandex(
|
|
||||||
@Body request: YandexRequest,
|
|
||||||
): Response<YandexResponse>
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ServerStatusMonitor
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val clientFactory: BookshelfApiClientFactory,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) {
|
|
||||||
private val _isOnline = MutableStateFlow(false)
|
|
||||||
val isOnline = _isOnline.asStateFlow()
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
private var pingJob: Job? = null
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (pingJob != null) return
|
|
||||||
pingJob = scope.launch {
|
|
||||||
while (true) {
|
|
||||||
val online = checkHealth()
|
|
||||||
_isOnline.value = online
|
|
||||||
delay(30_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
pingJob?.cancel()
|
|
||||||
pingJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkHealth(): Boolean {
|
|
||||||
val url = serverSettings.getBookshelfUrl() ?: return false
|
|
||||||
val client = clientFactory.provideClient(url) ?: return false
|
|
||||||
return runCatching {
|
|
||||||
val response = client.health()
|
|
||||||
response.isSuccessful
|
|
||||||
}.onFailure {
|
|
||||||
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AudioTrackResponse(
|
|
||||||
@SerialName("file_id")
|
|
||||||
val fileId: String,
|
|
||||||
val title: String = "",
|
|
||||||
val duration: Double = 0.0,
|
|
||||||
val size: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class BookItemResponse(
|
|
||||||
val id: String,
|
|
||||||
val title: String = "",
|
|
||||||
val author: String = "",
|
|
||||||
@SerialName("media_type")
|
|
||||||
val mediaType: String = "",
|
|
||||||
@SerialName("library_id")
|
|
||||||
val libraryId: String = "",
|
|
||||||
@SerialName("cover_url")
|
|
||||||
val coverUrl: String = "",
|
|
||||||
val duration: Double = 0.0,
|
|
||||||
val size: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DownloadRequest(
|
|
||||||
val source: String = "",
|
|
||||||
val title: String = "",
|
|
||||||
val author: String = "",
|
|
||||||
@SerialName("download_url")
|
|
||||||
val downloadUrl: String? = null,
|
|
||||||
@SerialName("magnet_url")
|
|
||||||
val magnetUrl: String? = null,
|
|
||||||
@SerialName("info_hash")
|
|
||||||
val infoHash: String? = null,
|
|
||||||
val md5: String? = null,
|
|
||||||
val url: String? = null,
|
|
||||||
@SerialName("media_type")
|
|
||||||
val mediaType: String = "ebook",
|
|
||||||
@SerialName("download_protocol")
|
|
||||||
val downloadProtocol: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DownloadResponse(
|
|
||||||
val success: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
@SerialName("download_id")
|
|
||||||
val downloadId: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DownloadStatusResponse(
|
|
||||||
val title: String = "",
|
|
||||||
val status: String = "",
|
|
||||||
val progress: Double = 0.0,
|
|
||||||
val speed: String = "",
|
|
||||||
val error: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DownloadsListResponse(
|
|
||||||
val downloads: List<DownloadStatusResponse> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PodcastRequest(
|
|
||||||
val url: String,
|
|
||||||
val title: String? = null,
|
|
||||||
val format: String = "m4a",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PodcastResponse(
|
|
||||||
val url: String = "",
|
|
||||||
val title: String = "",
|
|
||||||
val tracks: Int = 0,
|
|
||||||
val files: List<String> = emptyList(),
|
|
||||||
@SerialName("abs_scan_triggered")
|
|
||||||
val absScanTriggered: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class YandexRequest(
|
|
||||||
val bookid: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class YandexResponse(
|
|
||||||
val bookid: String,
|
|
||||||
val files: List<String> = emptyList(),
|
|
||||||
@SerialName("abs_scan_triggered")
|
|
||||||
val absScanTriggered: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LibraryResponse(
|
|
||||||
val id: String,
|
|
||||||
val name: String = "",
|
|
||||||
@SerialName("media_type")
|
|
||||||
val mediaType: String = "",
|
|
||||||
@SerialName("item_count")
|
|
||||||
val itemCount: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for updating the audio playback progress of a book on the bookshelf-api.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlaybackProgressUpdateRequest(
|
|
||||||
val itemId: String,
|
|
||||||
val libraryId: String,
|
|
||||||
@SerialName("current_file")
|
|
||||||
val currentFile: String,
|
|
||||||
@SerialName("current_position")
|
|
||||||
val currentPosition: Long,
|
|
||||||
val duration: Long,
|
|
||||||
@SerialName("updated_at")
|
|
||||||
val updatedAt: Long,
|
|
||||||
)
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for updating the local reading progress of a book on the bookshelf-api.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ProgressUpdateRequest(
|
|
||||||
val itemId: String,
|
|
||||||
val libraryId: String,
|
|
||||||
val scrollIndex: Int,
|
|
||||||
val scrollOffset: Int,
|
|
||||||
val progress: Float,
|
|
||||||
@SerialName("last_chapter")
|
|
||||||
val lastChapter: String? = null,
|
|
||||||
@SerialName("updated_at")
|
|
||||||
val updatedAt: Long,
|
|
||||||
)
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SearchResponse(
|
|
||||||
val results: List<SearchResultItemResponse> = emptyList(),
|
|
||||||
@SerialName("search_time_ms")
|
|
||||||
val searchTimeMs: Int = 0,
|
|
||||||
val total: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SearchResultItemResponse(
|
|
||||||
val source: String = "",
|
|
||||||
val title: String = "",
|
|
||||||
val author: String = "",
|
|
||||||
val format: String = "",
|
|
||||||
@SerialName("media_type")
|
|
||||||
val mediaType: String = "",
|
|
||||||
@SerialName("size_human")
|
|
||||||
val sizeHuman: String = "",
|
|
||||||
val seeders: Int? = null,
|
|
||||||
val score: Double = 0.0,
|
|
||||||
val guid: String = "",
|
|
||||||
val md5: String = "",
|
|
||||||
@SerialName("magnet_url")
|
|
||||||
val magnetUrl: String = "",
|
|
||||||
@SerialName("download_url")
|
|
||||||
val downloadUrl: String = "",
|
|
||||||
val url: String = "",
|
|
||||||
@SerialName("cover_url")
|
|
||||||
val coverUrl: String = "",
|
|
||||||
@SerialName("info_hash")
|
|
||||||
val infoHash: String = "",
|
|
||||||
@SerialName("download_protocol")
|
|
||||||
val downloadProtocol: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SearchRequest(
|
|
||||||
val query: String,
|
|
||||||
val author: String? = null,
|
|
||||||
@SerialName("media_type")
|
|
||||||
val mediaType: String? = null,
|
|
||||||
val format: String? = null,
|
|
||||||
val language: String? = null,
|
|
||||||
@SerialName("year_from")
|
|
||||||
val yearFrom: Int? = null,
|
|
||||||
@SerialName("year_to")
|
|
||||||
val yearTo: Int? = null,
|
|
||||||
val limit: Int = 50,
|
|
||||||
)
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsCreateRequest(
|
|
||||||
@SerialName("book_id")
|
|
||||||
val bookId: String,
|
|
||||||
val engine: String = "silero",
|
|
||||||
@SerialName("voice_id")
|
|
||||||
val voiceId: String = "",
|
|
||||||
val speed: Double = 1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsJobResponse(
|
|
||||||
@SerialName("job_id")
|
|
||||||
val jobId: String,
|
|
||||||
@SerialName("book_id")
|
|
||||||
val bookId: String,
|
|
||||||
val title: String = "",
|
|
||||||
val author: String = "",
|
|
||||||
val engine: String = "",
|
|
||||||
@SerialName("voice_id")
|
|
||||||
val voiceId: String = "",
|
|
||||||
val speed: Double = 1.0,
|
|
||||||
val status: String = "",
|
|
||||||
val progress: Double = 0.0,
|
|
||||||
@SerialName("current_chapter")
|
|
||||||
val currentChapter: String = "",
|
|
||||||
@SerialName("total_chapters")
|
|
||||||
val totalChapters: Int = 0,
|
|
||||||
@SerialName("completed_chapters")
|
|
||||||
val completedChapters: Int = 0,
|
|
||||||
@SerialName("output_path")
|
|
||||||
val outputPath: String = "",
|
|
||||||
val error: String = "",
|
|
||||||
@SerialName("created_at")
|
|
||||||
val createdAt: Double = 0.0,
|
|
||||||
@SerialName("started_at")
|
|
||||||
val startedAt: Double = 0.0,
|
|
||||||
@SerialName("completed_at")
|
|
||||||
val completedAt: Double = 0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsJobsResponse(
|
|
||||||
val jobs: List<TtsJobResponse> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsEnginesResponse(
|
|
||||||
val engines: List<TtsEngineResponse> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsVoicesResponse(
|
|
||||||
val voices: List<TtsVoiceResponse> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsEngineResponse(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val capabilities: TtsEngineCapabilitiesResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsEngineCapabilitiesResponse(
|
|
||||||
@SerialName("supports_streaming")
|
|
||||||
val supportsStreaming: Boolean = false,
|
|
||||||
@SerialName("supports_cloning")
|
|
||||||
val supportsCloning: Boolean = false,
|
|
||||||
@SerialName("max_text_length")
|
|
||||||
val maxTextLength: Int = 5000,
|
|
||||||
@SerialName("needs_network")
|
|
||||||
val needsNetwork: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TtsVoiceResponse(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val language: String,
|
|
||||||
val engine: String,
|
|
||||||
val gender: String = "",
|
|
||||||
val quality: String = "",
|
|
||||||
@SerialName("requires_reference")
|
|
||||||
val requiresReference: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LibraryItemsResponse(
|
|
||||||
val library: LibraryResponse = LibraryResponse(""),
|
|
||||||
val items: List<UnifiedItemResponse> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UnifiedItemResponse(
|
|
||||||
val id: String,
|
|
||||||
val title: String = "",
|
|
||||||
val authors: List<String> = emptyList(),
|
|
||||||
val author: String = "",
|
|
||||||
val type: String = "",
|
|
||||||
@SerialName("media_type")
|
|
||||||
val mediaType: String = "",
|
|
||||||
@SerialName("library_id")
|
|
||||||
val libraryId: String = "",
|
|
||||||
@SerialName("cover_url")
|
|
||||||
val coverUrl: String = "",
|
|
||||||
val duration: Double = 0.0,
|
|
||||||
val size: Long = 0L,
|
|
||||||
@SerialName("progress")
|
|
||||||
val progress: UnifiedItemProgressResponse? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UnifiedItemProgressResponse(
|
|
||||||
val reading: ReadingProgressResponse? = null,
|
|
||||||
val playback: PlaybackProgressResponse? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ReadingProgressResponse(
|
|
||||||
@SerialName("itemId")
|
|
||||||
val itemId: String = "",
|
|
||||||
@SerialName("libraryId")
|
|
||||||
val libraryId: String = "",
|
|
||||||
@SerialName("scrollIndex")
|
|
||||||
val scrollIndex: Int = 0,
|
|
||||||
@SerialName("scrollOffset")
|
|
||||||
val scrollOffset: Int = 0,
|
|
||||||
val progress: Float = 0f,
|
|
||||||
@SerialName("last_chapter")
|
|
||||||
val lastChapter: String? = null,
|
|
||||||
@SerialName("updatedAt")
|
|
||||||
val updatedAt: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PlaybackProgressResponse(
|
|
||||||
@SerialName("itemId")
|
|
||||||
val itemId: String = "",
|
|
||||||
@SerialName("libraryId")
|
|
||||||
val libraryId: String = "",
|
|
||||||
@SerialName("current_file")
|
|
||||||
val currentFile: String = "",
|
|
||||||
@SerialName("current_position")
|
|
||||||
val currentPosition: Long = 0L,
|
|
||||||
val duration: Long = 0L,
|
|
||||||
@SerialName("updatedAt")
|
|
||||||
val updatedAt: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UploadBookResponse(
|
|
||||||
val success: Boolean = false,
|
|
||||||
val filename: String = "",
|
|
||||||
val type: String = "",
|
|
||||||
val path: String = "",
|
|
||||||
@SerialName("error")
|
|
||||||
val errorMessage: String? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.data.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
private const val PREFS_NAME = "book_reader_server_settings"
|
|
||||||
private const val KEY_BOOKSHELF_URL = "bookshelf_url"
|
|
||||||
private const val KEY_ABS_URL = "abs_url"
|
|
||||||
private const val KEY_ABS_TOKEN = "abs_token"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal server URL/token storage. Replaces the full DataStore-based settings
|
|
||||||
* from Book's Story for Phase 2 backend scaffolding.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ServerSettings
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) {
|
|
||||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
fun getBookshelfUrl(): String? = prefs.getString(KEY_BOOKSHELF_URL, null)
|
|
||||||
|
|
||||||
fun setBookshelfUrl(url: String) {
|
|
||||||
prefs.edit { putString(KEY_BOOKSHELF_URL, url) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAbsUrl(): String? = prefs.getString(KEY_ABS_URL, null)
|
|
||||||
|
|
||||||
fun setAbsUrl(url: String) {
|
|
||||||
prefs.edit { putString(KEY_ABS_URL, url) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAbsToken(): String? = prefs.getString(KEY_ABS_TOKEN, null)
|
|
||||||
|
|
||||||
fun setAbsToken(token: String) {
|
|
||||||
prefs.edit { putString(KEY_ABS_TOKEN, token) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
|
|
||||||
import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfRepository
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiClientFactory
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
|
||||||
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object BackendModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideServerSettings(@ApplicationContext context: Context): ServerSettings = ServerSettings(context)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideBookshelfApiClientFactory(
|
|
||||||
json: Json,
|
|
||||||
okHttpClient: OkHttpClient,
|
|
||||||
): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideBookshelfApiRepository(
|
|
||||||
clientFactory: BookshelfApiClientFactory,
|
|
||||||
serverSettings: ServerSettings,
|
|
||||||
): BookshelfApiRepository = BookshelfApiRepository(clientFactory, serverSettings)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideAudiobookshelfApiClientFactory(
|
|
||||||
json: Json,
|
|
||||||
okHttpClient: OkHttpClient,
|
|
||||||
): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideAudiobookshelfRepository(
|
|
||||||
clientFactory: AudiobookshelfApiClientFactory,
|
|
||||||
serverSettings: ServerSettings,
|
|
||||||
): AudiobookshelfRepository = AudiobookshelfRepository(clientFactory, serverSettings)
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.di
|
|
||||||
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object NetworkModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideJson(): Json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
explicitNulls = false
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideOkHttpClient(): OkHttpClient = OkHttpClient
|
|
||||||
.Builder()
|
|
||||||
.addInterceptor(
|
|
||||||
HttpLoggingInterceptor().apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BASIC
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object PlaybackModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer =
|
|
||||||
ExoPlayer.Builder(context).build()
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.domain.util
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
|
|
||||||
private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the string is non-blank, has an http/https scheme,
|
|
||||||
* and can be parsed by [Uri.parse] into a host-bearing URI.
|
|
||||||
*/
|
|
||||||
fun String.isValidUri(): Boolean {
|
|
||||||
val trimmed = trim()
|
|
||||||
if (trimmed.isBlank()) return false
|
|
||||||
if (!trimmed.hasUriScheme()) return false
|
|
||||||
|
|
||||||
val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return false
|
|
||||||
return !uri.host.isNullOrBlank()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the string starts with http:// or https:// (case-insensitive).
|
|
||||||
*/
|
|
||||||
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
|
|
||||||
|
|
||||||
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
|
|
||||||
*/
|
|
||||||
fun String.ensureUriScheme(): String {
|
|
||||||
val trimmed = trim()
|
|
||||||
if (trimmed.hasUriScheme()) return trimmed
|
|
||||||
val hostPart = trimmed.substringBefore("/")
|
|
||||||
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
|
|
||||||
return "$scheme$trimmed"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a trailing slash if missing.
|
|
||||||
*/
|
|
||||||
fun String.ensureTrailingSlash(): String {
|
|
||||||
val trimmed = trim()
|
|
||||||
return if (trimmed.endsWith("/")) trimmed else "$trimmed/"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a URL for Retrofit: trims whitespace, adds a scheme if missing,
|
|
||||||
* and ensures a trailing slash.
|
|
||||||
*/
|
|
||||||
fun String.normalizeUri(): String = ensureUriScheme().ensureTrailingSlash()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy alias for [normalizeUri].
|
|
||||||
*/
|
|
||||||
fun String.fixUriScheme(): String = normalizeUri()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives a likely Audiobookshelf URL from a Bookshelf API URL.
|
|
||||||
* Replaces a known bookshelf-api port (8073) with the default ABS port (13378),
|
|
||||||
* or appends :13378 when no port is present. Returns null if the input is invalid.
|
|
||||||
*/
|
|
||||||
fun String.deriveAbsUrl(): String? {
|
|
||||||
if (!isValidUri()) return null
|
|
||||||
val normalized = normalizeUri()
|
|
||||||
val uri = Uri.parse(normalized)
|
|
||||||
val host = uri.host ?: return null
|
|
||||||
val port = uri.port
|
|
||||||
val scheme = uri.scheme?.lowercase() ?: "https"
|
|
||||||
|
|
||||||
val derivedPort = when (port) {
|
|
||||||
8073 -> 13378
|
|
||||||
-1 -> 13378
|
|
||||||
else -> port
|
|
||||||
}
|
|
||||||
return "$scheme://$host:$derivedPort/"
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.reader
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
data class ChapterItem(
|
|
||||||
val title: String,
|
|
||||||
val pageIndex: Int,
|
|
||||||
val progress: Float = 0f,
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ChapterDrawer(
|
|
||||||
chapters: List<ChapterItem>,
|
|
||||||
currentChapterIndex: Int,
|
|
||||||
onChapterSelected: (Int) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = sheetState,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Chapters",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
LazyColumn {
|
|
||||||
itemsIndexed(chapters) { index, chapter ->
|
|
||||||
val isCurrent = index == currentChapterIndex
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onChapterSelected(index) }
|
|
||||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = chapter.title,
|
|
||||||
style = if (isCurrent) MaterialTheme.typography.titleMedium
|
|
||||||
else MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (isCurrent) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${(chapter.progress * 100).toInt()}%",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isCurrent) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "●",
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.reader
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Checkpoint(
|
|
||||||
val bookId: String,
|
|
||||||
val chapterIndex: Int = 0,
|
|
||||||
val pageIndex: Int = 0,
|
|
||||||
val scrollOffset: Int = 0,
|
|
||||||
val progress: Float = 0f,
|
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.rsvp
|
|
||||||
|
|
||||||
sealed class ReaderText {
|
|
||||||
data class Paragraph(val text: String) : ReaderText()
|
|
||||||
data class Heading(val text: String) : ReaderText()
|
|
||||||
data class Chapter(val title: String) : ReaderText()
|
|
||||||
data class Separator(val text: String = "") : ReaderText()
|
|
||||||
data class Image(val alt: String = "") : ReaderText()
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookreader.rsvp
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives RSVP playback for a fixed token stream.
|
|
||||||
*
|
|
||||||
* @param scope scope used for the playback loop (typically `viewModelScope`).
|
|
||||||
*/
|
|
||||||
class RsvpEngine(private val scope: CoroutineScope) {
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val currentIndex: Int = 0,
|
|
||||||
val isPlaying: Boolean = false,
|
|
||||||
val tokens: List<RsvpToken> = emptyList(),
|
|
||||||
val wpm: Int = 350,
|
|
||||||
val pauseOnParagraphEnd: Boolean = true,
|
|
||||||
val pauseOnChapterEnd: Boolean = true,
|
|
||||||
val pauseOnLongWords: Boolean = true,
|
|
||||||
) {
|
|
||||||
val progress: Float
|
|
||||||
get() = if (tokens.isEmpty()) 0f
|
|
||||||
else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1)
|
|
||||||
|
|
||||||
val currentToken: RsvpToken?
|
|
||||||
get() = tokens.getOrNull(currentIndex)
|
|
||||||
|
|
||||||
val isAtEnd: Boolean
|
|
||||||
get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(State())
|
|
||||||
val state: StateFlow<State> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var playbackJob: Job? = null
|
|
||||||
|
|
||||||
fun setTokens(tokens: List<RsvpToken>) {
|
|
||||||
stop()
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
tokens = tokens,
|
|
||||||
currentIndex = 0,
|
|
||||||
isPlaying = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWpm(wpm: Int) {
|
|
||||||
_state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPauseOnParagraphEnd(value: Boolean) {
|
|
||||||
_state.value = _state.value.copy(pauseOnParagraphEnd = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPauseOnChapterEnd(value: Boolean) {
|
|
||||||
_state.value = _state.value.copy(pauseOnChapterEnd = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPauseOnLongWords(value: Boolean) {
|
|
||||||
_state.value = _state.value.copy(pauseOnLongWords = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekToIndex(index: Int) {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.tokens.isEmpty()) return
|
|
||||||
val clamped = index.coerceIn(0, s.tokens.lastIndex)
|
|
||||||
_state.value = s.copy(currentIndex = clamped)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekToProgress(progress: Float) {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.tokens.isEmpty()) return
|
|
||||||
val clamped = progress.coerceIn(0f, 1f)
|
|
||||||
val idx = (clamped * s.tokens.lastIndex).toInt()
|
|
||||||
_state.value = s.copy(currentIndex = idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipForward(count: Int = 10) {
|
|
||||||
seekToIndex(_state.value.currentIndex + count)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipBackward(count: Int = 10) {
|
|
||||||
seekToIndex(_state.value.currentIndex - count)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play() {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.tokens.isEmpty()) return
|
|
||||||
if (s.isAtEnd) {
|
|
||||||
seekToIndex(0)
|
|
||||||
}
|
|
||||||
if (_state.value.isPlaying) return
|
|
||||||
_state.value = _state.value.copy(isPlaying = true)
|
|
||||||
startPlaybackLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause() {
|
|
||||||
_state.value = _state.value.copy(isPlaying = false)
|
|
||||||
playbackJob?.cancel()
|
|
||||||
playbackJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggle() {
|
|
||||||
if (_state.value.isPlaying) pause() else play()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startPlaybackLoop() {
|
|
||||||
playbackJob?.cancel()
|
|
||||||
playbackJob = scope.launch {
|
|
||||||
while (isActive && _state.value.isPlaying) {
|
|
||||||
val snapshot = _state.value
|
|
||||||
if (snapshot.isAtEnd) {
|
|
||||||
_state.value = snapshot.copy(isPlaying = false)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val token = snapshot.tokens[snapshot.currentIndex]
|
|
||||||
delay(delayMsFor(token, snapshot))
|
|
||||||
|
|
||||||
val after = _state.value
|
|
||||||
if (!after.isPlaying) return@launch
|
|
||||||
if (after.tokens.isEmpty()) return@launch
|
|
||||||
|
|
||||||
_state.value = after.copy(
|
|
||||||
currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the delay (in ms) before advancing past [token].
|
|
||||||
*
|
|
||||||
* Base = 60_000 / wpm, adjusted by:
|
|
||||||
* - per-word multiplier (long words / digits)
|
|
||||||
* - paragraph-end bonus (configurable, default +200 ms)
|
|
||||||
* - chapter-end bonus (configurable, default +400 ms)
|
|
||||||
*/
|
|
||||||
private fun delayMsFor(token: RsvpToken, snapshot: State): Long {
|
|
||||||
val wpm = snapshot.wpm.coerceAtLeast(50)
|
|
||||||
val baseMs = 60_000.0 / wpm
|
|
||||||
var ms = baseMs * token.multiplier
|
|
||||||
|
|
||||||
if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) {
|
|
||||||
ms += 200.0
|
|
||||||
}
|
|
||||||
if (token.isChapterEnd && snapshot.pauseOnChapterEnd) {
|
|
||||||
ms += 400.0
|
|
||||||
}
|
|
||||||
if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) {
|
|
||||||
// multiplier already applied above; keep it
|
|
||||||
} else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) {
|
|
||||||
ms = baseMs
|
|
||||||
}
|
|
||||||
return ms.toLong().coerceAtLeast(20L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookreader.rsvp
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One word/segment in the RSVP stream.
|
|
||||||
*
|
|
||||||
* @param word the original word as it appears in the book.
|
|
||||||
* @param pivotIndex index of the optimal recognition point within [word];
|
|
||||||
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
|
|
||||||
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
|
|
||||||
* @param isChapterEnd true if the token follows a chapter heading.
|
|
||||||
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class RsvpToken(
|
|
||||||
val word: String,
|
|
||||||
val pivotIndex: Int,
|
|
||||||
val isParagraphEnd: Boolean,
|
|
||||||
val isChapterEnd: Boolean,
|
|
||||||
val multiplier: Float = 1f,
|
|
||||||
) {
|
|
||||||
val prefix: String
|
|
||||||
get() = word.substring(0, pivotIndex)
|
|
||||||
|
|
||||||
val pivot: String
|
|
||||||
get() = word.substring(pivotIndex, pivotIndex + 1)
|
|
||||||
|
|
||||||
val suffix: String
|
|
||||||
get() = word.substring(pivotIndex + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
|
|
||||||
* Indices are 0-based positions of the focal character.
|
|
||||||
* Values follow the heuristic table from Spritz/speed-reading literature:
|
|
||||||
* 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
|
|
||||||
*/
|
|
||||||
object OrpTable {
|
|
||||||
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
|
|
||||||
|
|
||||||
fun pivotFor(word: String): Int {
|
|
||||||
val len = word.length
|
|
||||||
if (len == 0) return 0
|
|
||||||
if (len <= TABLE.size) return TABLE[len - 1]
|
|
||||||
return (len * 0.3f).toInt().coerceIn(1, len - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookreader.rsvp
|
|
||||||
|
|
||||||
import org.dueattendant149.bookreader.rsvp.ReaderText
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
|
|
||||||
* into a flat list of [RsvpToken]s suitable for RSVP playback.
|
|
||||||
*
|
|
||||||
* - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
|
|
||||||
* - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
|
|
||||||
* - Marks the first word after a chapter heading as `isChapterEnd = true`.
|
|
||||||
* - Strips punctuation-only tokens and empty whitespace tokens.
|
|
||||||
*/
|
|
||||||
object RsvpTokenizer {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
|
|
||||||
*/
|
|
||||||
fun tokenizeReaderText(blocks: List<ReaderText>): List<RsvpToken> {
|
|
||||||
val tokens = ArrayList<RsvpToken>(blocks.size * 8)
|
|
||||||
var lastWasParagraphEnd = false
|
|
||||||
var pendingChapterEnd = false
|
|
||||||
|
|
||||||
for (block in blocks) {
|
|
||||||
when (block) {
|
|
||||||
is ReaderText.Chapter -> {
|
|
||||||
pendingChapterEnd = true
|
|
||||||
lastWasParagraphEnd = false
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Paragraph -> {
|
|
||||||
val raw = block.text
|
|
||||||
if (raw.isBlank()) {
|
|
||||||
lastWasParagraphEnd = true
|
|
||||||
} else {
|
|
||||||
tokenizeLine(
|
|
||||||
line = raw,
|
|
||||||
isParagraphEnd = lastWasParagraphEnd,
|
|
||||||
isChapterEnd = pendingChapterEnd,
|
|
||||||
out = tokens,
|
|
||||||
)
|
|
||||||
lastWasParagraphEnd = false
|
|
||||||
pendingChapterEnd = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Heading -> {
|
|
||||||
val raw = block.text
|
|
||||||
if (raw.isNotBlank()) {
|
|
||||||
tokenizeLine(
|
|
||||||
line = raw,
|
|
||||||
isParagraphEnd = lastWasParagraphEnd,
|
|
||||||
isChapterEnd = pendingChapterEnd,
|
|
||||||
out = tokens,
|
|
||||||
)
|
|
||||||
lastWasParagraphEnd = false
|
|
||||||
pendingChapterEnd = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Separator -> lastWasParagraphEnd = true
|
|
||||||
is ReaderText.Image -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tokenizeLine(
|
|
||||||
line: String,
|
|
||||||
isParagraphEnd: Boolean,
|
|
||||||
isChapterEnd: Boolean,
|
|
||||||
out: MutableList<RsvpToken>,
|
|
||||||
) {
|
|
||||||
val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() }
|
|
||||||
if (words.isEmpty()) return
|
|
||||||
|
|
||||||
val lastIndex = words.lastIndex
|
|
||||||
for ((idx, word) in words.withIndex()) {
|
|
||||||
val cleaned = cleanWord(word)
|
|
||||||
if (cleaned.isEmpty()) continue
|
|
||||||
val pivot = OrpTable.pivotFor(cleaned)
|
|
||||||
val paragraphEnd = isParagraphEnd && idx == lastIndex
|
|
||||||
val multiplier = computeMultiplier(cleaned)
|
|
||||||
out.add(
|
|
||||||
RsvpToken(
|
|
||||||
word = cleaned,
|
|
||||||
pivotIndex = pivot,
|
|
||||||
isParagraphEnd = paragraphEnd,
|
|
||||||
isChapterEnd = isChapterEnd && idx == 0,
|
|
||||||
multiplier = multiplier,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val WORD_SPLIT_REGEX = Regex("\\s+")
|
|
||||||
|
|
||||||
private fun cleanWord(raw: String): String {
|
|
||||||
val sb = StringBuilder(raw.length)
|
|
||||||
var i = 0
|
|
||||||
val len = raw.length
|
|
||||||
while (i < len) {
|
|
||||||
val c = raw[i]
|
|
||||||
if (c.isLetterOrDigit() || c == '-' || c == '\'') {
|
|
||||||
sb.append(c)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-word pacing multiplier.
|
|
||||||
*
|
|
||||||
* - Long words (>9 chars) get +30% time for recognition.
|
|
||||||
* - Numbers/digits get +20% time.
|
|
||||||
* - Baseline 1.0 for normal words.
|
|
||||||
*/
|
|
||||||
private fun computeMultiplier(word: String): Float {
|
|
||||||
val len = word.length
|
|
||||||
val base = when {
|
|
||||||
len > 9 -> 1.3f
|
|
||||||
len > 6 -> 1.1f
|
|
||||||
else -> 1.0f
|
|
||||||
}
|
|
||||||
val hasDigit = word.any { it.isDigit() }
|
|
||||||
return if (hasDigit) base + 0.2f else base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.tts
|
|
||||||
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
|
|
||||||
interface RemoteTtsRepository {
|
|
||||||
suspend fun fetchEngines(): Result<TtsEnginesResponse>
|
|
||||||
suspend fun fetchVoices(engine: String? = null): Result<TtsVoicesResponse>
|
|
||||||
suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result<TtsJobResponse>
|
|
||||||
suspend fun getJob(jobId: String): Result<TtsJobResponse>
|
|
||||||
suspend fun downloadAudio(jobId: String): Result<ResponseBody>
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.tts
|
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
|
|
||||||
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class RemoteTtsRepositoryImpl
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val bookshelfApi: BookshelfApiRepository,
|
|
||||||
) : RemoteTtsRepository {
|
|
||||||
override suspend fun fetchEngines(): Result<TtsEnginesResponse> = bookshelfApi.getTtsEngines()
|
|
||||||
override suspend fun fetchVoices(engine: String?): Result<TtsVoicesResponse> = bookshelfApi.getTtsVoices(engine)
|
|
||||||
override suspend fun createJob(request: TtsCreateRequest): Result<TtsJobResponse> = bookshelfApi.createTtsJob(request)
|
|
||||||
override suspend fun getJob(jobId: String): Result<TtsJobResponse> = bookshelfApi.getTtsJobStatus(jobId)
|
|
||||||
override suspend fun downloadAudio(jobId: String): Result<ResponseBody> = bookshelfApi.downloadTtsAudio(jobId)
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookreader.tts
|
|
||||||
|
|
||||||
data class TtsEngine(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val supportsStreaming: Boolean,
|
|
||||||
val supportsCloning: Boolean,
|
|
||||||
val maxTextLength: Int,
|
|
||||||
val needsNetwork: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TtsVoice(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val language: String,
|
|
||||||
val engine: String,
|
|
||||||
val gender: String,
|
|
||||||
val quality: String,
|
|
||||||
val requiresReference: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TtsJob(
|
|
||||||
val jobId: String,
|
|
||||||
val bookId: String,
|
|
||||||
val title: String,
|
|
||||||
val author: String,
|
|
||||||
val engine: String,
|
|
||||||
val voiceId: String,
|
|
||||||
val speed: Double,
|
|
||||||
val status: String,
|
|
||||||
val progress: Double,
|
|
||||||
val currentChapter: String,
|
|
||||||
val totalChapters: Int,
|
|
||||||
val completedChapters: Int,
|
|
||||||
val outputPath: String,
|
|
||||||
val error: String,
|
|
||||||
val createdAt: Double,
|
|
||||||
val startedAt: Double,
|
|
||||||
val completedAt: Double,
|
|
||||||
) {
|
|
||||||
val isCompleted: Boolean get() = status == "completed"
|
|
||||||
val isFailed: Boolean get() = status == "failed" || status == "error"
|
|
||||||
val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.work
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub for CacheDownloadWorker. No-op until full Book's Story data layer is ported.
|
|
||||||
*/
|
|
||||||
class CacheDownloadWorker(
|
|
||||||
appContext: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(appContext, params) {
|
|
||||||
override suspend fun doWork(): Result = Result.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub for ProgressSyncWorker. No-op until full Book's Story data layer is ported.
|
|
||||||
*/
|
|
||||||
class ProgressSyncWorker(
|
|
||||||
appContext: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(appContext, params) {
|
|
||||||
override suspend fun doWork(): Result = Result.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub for TtsDownloadWorker. No-op until full Book's Story data layer is ported.
|
|
||||||
*/
|
|
||||||
class TtsDownloadWorker(
|
|
||||||
appContext: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(appContext, params) {
|
|
||||||
override suspend fun doWork(): Result = Result.success()
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.shared
|
|
||||||
|
|
||||||
fun currentTimestamp(): Long = System.currentTimeMillis()
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.shared.reader
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import org.dueattendant149.bookreader.BuildConfig
|
|
||||||
|
|
||||||
const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
|
|
||||||
const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
|
||||||
const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff"
|
|
||||||
|
|
||||||
val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG
|
|
||||||
|
|
||||||
fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean {
|
|
||||||
if (!BuildConfig.DEBUG) return false
|
|
||||||
return tag == SharedEpubCutoffDiagnosticsTag ||
|
|
||||||
runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeSharedReaderDiagnostic(tag: String, message: String) {
|
|
||||||
if (!BuildConfig.DEBUG) return
|
|
||||||
Log.d(tag, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
|
|
||||||
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
|
|
||||||
writeSharedReaderDiagnostic(tag, message())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2066,6 +2066,4 @@
|
||||||
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
|
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
|
||||||
<!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". -->
|
<!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". -->
|
||||||
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
||||||
<string name="audio_playback_channel">Audio Playback</string>
|
|
||||||
<string name="audio_playback_channel_description">Audio playback controls</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
alias(libs.plugins.kotlin.ksp) apply false
|
|
||||||
alias(libs.plugins.hilt) apply false
|
|
||||||
alias(libs.plugins.kover) apply false
|
alias(libs.plugins.kover) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,6 +18,7 @@ subprojects {
|
||||||
val rootTest = rootProject.tasks.named("test")
|
val rootTest = rootProject.tasks.named("test")
|
||||||
tasks.matching {
|
tasks.matching {
|
||||||
it.name == "allTests" ||
|
it.name == "allTests" ||
|
||||||
|
it.name == "desktopTest" ||
|
||||||
it.name.endsWith("DebugUnitTest")
|
it.name.endsWith("DebugUnitTest")
|
||||||
}.configureEach {
|
}.configureEach {
|
||||||
rootTest.configure {
|
rootTest.configure {
|
||||||
|
|
@ -29,14 +29,3 @@ subprojects {
|
||||||
maxHeapSize = "4g"
|
maxHeapSize = "4g"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allprojects {
|
|
||||||
configurations.all {
|
|
||||||
resolutionStrategy {
|
|
||||||
force("org.jetbrains.kotlin:kotlin-stdlib:2.1.20")
|
|
||||||
force("org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20")
|
|
||||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.20")
|
|
||||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
1621
desktopApp/build.gradle.kts
Normal file
1621
desktopApp/build.gradle.kts
Normal file
File diff suppressed because it is too large
Load diff
26
desktopApp/compose-desktop.pro
Normal file
26
desktopApp/compose-desktop.pro
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-keep class io.ktor.serialization.kotlinx.** { *; }
|
||||||
|
-keep class io.ktor.serialization.kotlinx.json.** { *; }
|
||||||
|
-keep class com.sun.jna.** { *; }
|
||||||
|
-keep class * implements com.sun.jna.Library { *; }
|
||||||
|
-keep class * extends com.sun.jna.Structure { *; }
|
||||||
|
-keep class kotlinx.coroutines.swing.SwingDispatcherFactory
|
||||||
|
|
||||||
|
# Desktop release shrinking sees optional integrations from JOGL, Commons Compress
|
||||||
|
# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers.
|
||||||
|
-dontwarn com.jetbrains.JBR
|
||||||
|
-dontwarn com.jogamp.**
|
||||||
|
-dontwarn jogamp.**
|
||||||
|
-dontwarn org.apache.commons.compress.harmony.pack200.**
|
||||||
|
-dontwarn org.objectweb.asm.**
|
||||||
|
-dontwarn io.ktor.serialization.kotlinx.**
|
||||||
|
-dontwarn com.sun.jna.**
|
||||||
|
-dontwarn org.eclipse.swt.**
|
||||||
|
-dontwarn javafx.**
|
||||||
|
-dontwarn com.sun.javafx.**
|
||||||
|
-dontwarn okhttp3.internal.platform.**
|
||||||
|
-dontwarn org.bouncycastle.**
|
||||||
|
-dontwarn org.conscrypt.**
|
||||||
|
-dontwarn org.openjsse.**
|
||||||
|
-dontwarn android.**
|
||||||
|
-dontwarn com.github.luben.zstd.**
|
||||||
|
-dontwarn org.brotli.dec.**
|
||||||
229
desktopApp/packaging/README.md
Normal file
229
desktopApp/packaging/README.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# Desktop package builds
|
||||||
|
|
||||||
|
Build Linux packages on the matching distro VM when testing manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Reader
|
||||||
|
./gradlew :desktopApp:packageDeb -x test
|
||||||
|
./gradlew :desktopApp:packageRpm -x test
|
||||||
|
./gradlew :desktopApp:packageAur -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a Windows MSIX locally on Windows with the Windows SDK installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\aryan\Desktop\Reader
|
||||||
|
.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the newest generated MSIX to your desktop:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\"
|
||||||
|
```
|
||||||
|
|
||||||
|
The MSIX task is separate from MSI packaging. It stages the release app image at
|
||||||
|
`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and
|
||||||
|
writes the MSIX to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktopApp/build/compose/binaries/main-release/msix
|
||||||
|
```
|
||||||
|
|
||||||
|
For Microsoft Store submission, set the package identity values from Partner
|
||||||
|
Center so `AppxManifest.xml` matches the reserved app identity:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat `
|
||||||
|
-PdesktopOnly=true `
|
||||||
|
-PdesktopMsixIdentityName=<Partner Center package identity name> `
|
||||||
|
-PdesktopMsixPublisher=<Partner Center publisher CN> `
|
||||||
|
-PdesktopMsixPublisherDisplayName=<Publisher display name> `
|
||||||
|
:desktopApp:packageReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
If Windows SDK tools are not on `PATH`, pass them explicitly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat `
|
||||||
|
-PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\<sdk-version>\x64\makeappx.exe" `
|
||||||
|
:desktopApp:packageReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Local signing is optional and separate:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew.bat `
|
||||||
|
-PdesktopMsixCertificatePath=C:\path\to\certificate.pfx `
|
||||||
|
-PdesktopMsixCertificatePassword=<password> `
|
||||||
|
:desktopApp:signReleaseMsix -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended VM split:
|
||||||
|
|
||||||
|
- Ubuntu: `./gradlew :desktopApp:packageDeb -x test`
|
||||||
|
- Fedora: `./gradlew :desktopApp:packageRpm -x test`
|
||||||
|
- Arch: `./gradlew :desktopApp:packageAur -x test`
|
||||||
|
|
||||||
|
Desktop-only Gradle invocations automatically skip the Android app module and the
|
||||||
|
Android target in `:shared`, so desktop packaging does not require `sdk.dir`,
|
||||||
|
Android SDK installation, or Android release signing values. You can force that
|
||||||
|
mode for unusual command shapes with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Desktop release values are centralized in `gradle.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
desktopVersion=1.0.1
|
||||||
|
desktopPackageVersion=1.0.1
|
||||||
|
desktopAurPackageRelease=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`.
|
||||||
|
`packageAur` first creates a Linux app tarball, then generates an AUR worktree at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktopApp/build/aur/episteme-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
On Arch, install/test the generated package with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst
|
||||||
|
episteme
|
||||||
|
```
|
||||||
|
|
||||||
|
For the OSS/offline flavor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test
|
||||||
|
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst
|
||||||
|
episteme-oss
|
||||||
|
```
|
||||||
|
|
||||||
|
To inspect the AUR recipe manually instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :desktopApp:prepareAurPackage -x test
|
||||||
|
cd ~/Reader/desktopApp/build/aur/episteme-bin
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
For publish-ready AUR metadata, pass the release tarball URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :desktopApp:prepareAurPackage \
|
||||||
|
-PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \
|
||||||
|
-x test
|
||||||
|
```
|
||||||
|
|
||||||
|
Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory.
|
||||||
|
|
||||||
|
The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root
|
||||||
|
`LICENSE` file into `/usr/share/licenses/$pkgname/`.
|
||||||
|
|
||||||
|
## AUR repository setup
|
||||||
|
|
||||||
|
Create an account at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://aur.archlinux.org/register/
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your public SSH key in the account settings, then confirm SSH works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh aur@aur.archlinux.org
|
||||||
|
```
|
||||||
|
|
||||||
|
The command should authenticate and print AUR help text. It will not open a
|
||||||
|
normal shell.
|
||||||
|
|
||||||
|
Create the package repos by cloning their not-yet-existing names:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://aur@aur.archlinux.org/episteme-bin.git
|
||||||
|
git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git
|
||||||
|
```
|
||||||
|
|
||||||
|
If a name already exists, inspect it first. If it is abandoned, follow the AUR
|
||||||
|
orphan/adoption process instead of creating a duplicate package name.
|
||||||
|
|
||||||
|
For each release, extract the matching `aur-<package>-<version>.tar.gz` metadata
|
||||||
|
archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching
|
||||||
|
AUR clone, then commit and push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin
|
||||||
|
cd episteme-bin
|
||||||
|
git add PKGBUILD .SRCINFO
|
||||||
|
git commit -m "Update to 1.0.1"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the same flow for `episteme-oss-bin`.
|
||||||
|
|
||||||
|
## CI release workflow
|
||||||
|
|
||||||
|
`Desktop release` in GitHub Actions builds desktop artifacts for standard and
|
||||||
|
OSS flavors:
|
||||||
|
|
||||||
|
- Windows MSI
|
||||||
|
- Ubuntu/Debian DEB
|
||||||
|
- Fedora RPM
|
||||||
|
- Linux tarball used by AUR
|
||||||
|
- Direct Arch `.pkg.tar.zst`
|
||||||
|
- AUR metadata archives containing `PKGBUILD` and `.SRCINFO`
|
||||||
|
- `SHA256SUMS.txt`
|
||||||
|
|
||||||
|
Before running it, publish Pdfium once from a machine that has the ignored
|
||||||
|
`third_party/pdfium` folders:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\desktop\publish-pdfium-release.ps1 `
|
||||||
|
-Repository Aryan-Raj3112/episteme `
|
||||||
|
-Tag pdfium-desktop-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
That release must contain:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pdfium-linux-x64-v8.zip
|
||||||
|
pdfium-win-x64-v8.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The desktop release workflow downloads those assets with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Required GitHub Secrets for standard desktop packages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DESKTOP_FIREBASE_PROJECT_ID
|
||||||
|
DESKTOP_FIREBASE_WEB_API_KEY
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_ID
|
||||||
|
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped
|
||||||
|
for `:desktopApp:*` tasks.
|
||||||
|
|
||||||
|
AUR publishing still needs the two AUR repos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episteme-bin
|
||||||
|
episteme-oss-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload the generated `PKGBUILD` and `.SRCINFO` from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aur-episteme-bin-<version>.tar.gz
|
||||||
|
aur-episteme-oss-bin-<version>.tar.gz
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
internal data class DesktopAccountProfile(
|
||||||
|
val isProUser: Boolean = false,
|
||||||
|
val credits: Int = 0,
|
||||||
|
val fetchedAtEpochMillis: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credits and Pro status are server-owned, so startup only trusts a recent snapshot.
|
||||||
|
internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L
|
||||||
|
|
||||||
|
internal fun DesktopAccountProfile.isFresh(
|
||||||
|
nowEpochMillis: Long = System.currentTimeMillis(),
|
||||||
|
ttlMillis: Long = DesktopAccountProfileCacheTtlMillis
|
||||||
|
): Boolean {
|
||||||
|
if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false
|
||||||
|
val ageMillis = nowEpochMillis - fetchedAtEpochMillis
|
||||||
|
return ageMillis in 0L..ttlMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopAccountProfileRepository(
|
||||||
|
private val config: DesktopCloudConfig,
|
||||||
|
private val store: DesktopAccountProfileStore = DesktopAccountProfileStore()
|
||||||
|
) {
|
||||||
|
fun cachedProfile(
|
||||||
|
uid: String,
|
||||||
|
nowEpochMillis: Long = System.currentTimeMillis()
|
||||||
|
): DesktopAccountProfile? {
|
||||||
|
return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) {
|
||||||
|
store.save(uid, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCachedProfiles() {
|
||||||
|
store.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) {
|
||||||
|
if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile()
|
||||||
|
val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}"
|
||||||
|
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
setRequestProperty("Authorization", "Bearer $idToken")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
connectTimeout = 12_000
|
||||||
|
readTimeout = 20_000
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||||
|
return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull()
|
||||||
|
val profile = DesktopAccountProfile(
|
||||||
|
isProUser = fields?.booleanField("isPro") == true,
|
||||||
|
credits = fields?.numberField("credits")?.toInt() ?: 0,
|
||||||
|
fetchedAtEpochMillis = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
profile
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopAccountProfileStore(
|
||||||
|
private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties")
|
||||||
|
) {
|
||||||
|
fun load(uid: String): DesktopAccountProfile? {
|
||||||
|
if (uid.isBlank() || !settingsFile.isFile) return null
|
||||||
|
val properties = Properties()
|
||||||
|
return runCatching {
|
||||||
|
settingsFile.inputStream().use(properties::load)
|
||||||
|
if (properties.getProperty("uid", "") != uid) return null
|
||||||
|
DesktopAccountProfile(
|
||||||
|
isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false,
|
||||||
|
credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0,
|
||||||
|
fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(uid: String, profile: DesktopAccountProfile) {
|
||||||
|
if (uid.isBlank()) return
|
||||||
|
val properties = Properties().apply {
|
||||||
|
setProperty("uid", uid)
|
||||||
|
setProperty("isProUser", profile.isProUser.toString())
|
||||||
|
setProperty("credits", profile.credits.toString())
|
||||||
|
setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString())
|
||||||
|
}
|
||||||
|
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
settingsFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DesktopAccountJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun JsonObject?.booleanField(key: String): Boolean? {
|
||||||
|
return this?.get(key)
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("booleanValue")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.toBooleanStrictOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject?.numberField(key: String): Double? {
|
||||||
|
val field = this?.get(key)?.jsonObjectOrNull() ?: return null
|
||||||
|
return field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
|
||||||
|
?: field["doubleValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,338 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.dueattendant149.bookreader.shared.RecapResult
|
||||||
|
import org.dueattendant149.bookreader.shared.SummarizationResult
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.readerString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopAiHubSheet(
|
||||||
|
bookKey: String,
|
||||||
|
bookTitle: String,
|
||||||
|
itemIndex: Int,
|
||||||
|
itemTitle: String,
|
||||||
|
summaryCacheStore: DesktopSummaryCacheStore,
|
||||||
|
summaryResult: SummarizationResult?,
|
||||||
|
isSummaryLoading: Boolean,
|
||||||
|
recapResult: RecapResult?,
|
||||||
|
isRecapLoading: Boolean,
|
||||||
|
recapProgressMessage: String?,
|
||||||
|
onGenerateSummary: (force: Boolean) -> Unit,
|
||||||
|
onClearSummary: () -> Unit,
|
||||||
|
onGenerateRecap: (() -> Unit)?,
|
||||||
|
onClearRecap: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
credits: Int,
|
||||||
|
showCredits: Boolean
|
||||||
|
) {
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
var cacheRefresh by remember { mutableIntStateOf(0) }
|
||||||
|
val cachedSummary = remember(bookKey, itemIndex, cacheRefresh) {
|
||||||
|
summaryCacheStore.getSummary(bookKey, itemIndex)
|
||||||
|
}
|
||||||
|
val effectiveSummary = summaryResult ?: cachedSummary?.let {
|
||||||
|
SummarizationResult(summary = it, isCacheHit = true)
|
||||||
|
}
|
||||||
|
val summaryTab = readerString("label_summary", "Summary")
|
||||||
|
val recapTab = readerString("ai_tab_recap", "Recap")
|
||||||
|
val cacheTab = readerString("ai_tab_cache", "Cache")
|
||||||
|
val tabs = buildList {
|
||||||
|
add(summaryTab)
|
||||||
|
if (onGenerateRecap != null) add(recapTab)
|
||||||
|
add(cacheTab)
|
||||||
|
}
|
||||||
|
val selectedTabIndex = selectedTab.coerceIn(0, tabs.lastIndex)
|
||||||
|
val activeTab = tabs.getOrElse(selectedTabIndex) { summaryTab }
|
||||||
|
|
||||||
|
DesktopReaderBottomSheet(
|
||||||
|
title = readerString("desktop_ai_hub", "AI hub"),
|
||||||
|
onDismiss = onDismiss
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(bookTitle, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(itemTitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
if (showCredits) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
shape = RoundedCornerShape(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
readerString("credits_count", "%1\$d credits", credits),
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollableTabRow(selectedTabIndex = selectedTabIndex, edgePadding = 0.dp) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTabIndex == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (activeTab) {
|
||||||
|
summaryTab -> {
|
||||||
|
DesktopAiHubResultView(
|
||||||
|
title = itemTitle,
|
||||||
|
resultText = effectiveSummary?.summary,
|
||||||
|
errorText = effectiveSummary?.error,
|
||||||
|
cost = effectiveSummary?.cost,
|
||||||
|
freeRemaining = effectiveSummary?.freeRemaining,
|
||||||
|
isCacheHit = effectiveSummary?.isCacheHit == true,
|
||||||
|
isLoading = isSummaryLoading,
|
||||||
|
loadingLabel = readerString("generating_summary", "Generating summary..."),
|
||||||
|
emptyTitle = readerString("desktop_no_summary_cached_section", "No summary cached for this section."),
|
||||||
|
primaryActionLabel = readerString("desktop_generate_summary", "Generate summary"),
|
||||||
|
onPrimaryAction = { onGenerateSummary(false) },
|
||||||
|
onRegenerate = { onGenerateSummary(true) },
|
||||||
|
onClear = {
|
||||||
|
summaryCacheStore.deleteSummary(bookKey, itemIndex)
|
||||||
|
cacheRefresh++
|
||||||
|
onClearSummary()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recapTab -> {
|
||||||
|
DesktopAiHubResultView(
|
||||||
|
title = readerString("ai_story_recap", "Story recap"),
|
||||||
|
resultText = recapResult?.recap,
|
||||||
|
errorText = recapResult?.error,
|
||||||
|
cost = recapResult?.cost,
|
||||||
|
freeRemaining = recapResult?.freeRemaining,
|
||||||
|
isCacheHit = false,
|
||||||
|
isLoading = isRecapLoading,
|
||||||
|
loadingLabel = recapProgressMessage?.takeIf { it.isNotBlank() } ?: readerString("ai_generating_recap", "Generating recap..."),
|
||||||
|
emptyTitle = readerString("desktop_create_recap_current_position", "Create a recap up to your current position."),
|
||||||
|
primaryActionLabel = readerString("desktop_generate_recap", "Generate recap"),
|
||||||
|
onPrimaryAction = { onGenerateRecap?.invoke() },
|
||||||
|
onRegenerate = { onGenerateRecap?.invoke() },
|
||||||
|
onClear = onClearRecap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cacheTab -> {
|
||||||
|
DesktopSummaryCachePanel(
|
||||||
|
bookKey = bookKey,
|
||||||
|
summaryCacheStore = summaryCacheStore,
|
||||||
|
onCacheChanged = {
|
||||||
|
cacheRefresh++
|
||||||
|
onClearSummary()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopAiHubResultView(
|
||||||
|
title: String,
|
||||||
|
resultText: String?,
|
||||||
|
errorText: String?,
|
||||||
|
cost: Double?,
|
||||||
|
freeRemaining: Int?,
|
||||||
|
isCacheHit: Boolean,
|
||||||
|
isLoading: Boolean,
|
||||||
|
loadingLabel: String,
|
||||||
|
emptyTitle: String,
|
||||||
|
primaryActionLabel: String,
|
||||||
|
onPrimaryAction: () -> Unit,
|
||||||
|
onRegenerate: () -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val hasText = !resultText.isNullOrBlank()
|
||||||
|
val hasError = !errorText.isNullOrBlank()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 260.dp, max = 430.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
DesktopAiUsageBadge(
|
||||||
|
isCacheHit = isCacheHit,
|
||||||
|
cost = cost,
|
||||||
|
freeRemaining = freeRemaining,
|
||||||
|
isLoading = isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
hasText -> {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TextButton(enabled = !isLoading, onClick = onRegenerate) {
|
||||||
|
Text(readerString("ai_regenerate", "Regenerate"))
|
||||||
|
}
|
||||||
|
TextButton(enabled = !isLoading, onClick = onClear) {
|
||||||
|
Text(readerString("action_clear", "Clear"))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { clipboard.setText(AnnotatedString(resultText.orEmpty())) }) {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = readerString("action_copy", "Copy"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
Column(modifier = Modifier.weight(1f).heightIn(min = 120.dp).padding(end = 2.dp).verticalScroll(rememberScrollState())) {
|
||||||
|
SharedMarkdownText(resultText.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasError -> {
|
||||||
|
Text(errorText.orEmpty(), color = MaterialTheme.colorScheme.error)
|
||||||
|
Button(onClick = onPrimaryAction, enabled = !isLoading) {
|
||||||
|
Text(primaryActionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!isLoading -> {
|
||||||
|
Text(emptyTitle, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Button(onClick = onPrimaryAction) {
|
||||||
|
Text(primaryActionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopAiUsageBadge(
|
||||||
|
isCacheHit: Boolean,
|
||||||
|
cost: Double?,
|
||||||
|
freeRemaining: Int?,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
val text = when {
|
||||||
|
isCacheHit -> readerString("desktop_cached", "Cached")
|
||||||
|
cost == 0.0 && freeRemaining != null -> readerString("desktop_free_remaining_format", "Free, %1\$d left", freeRemaining)
|
||||||
|
cost != null -> readerString("desktop_credits_decimal_format", "%1\$s credits", cost)
|
||||||
|
isLoading -> readerString("desktop_cost_calculating", "Cost calculating")
|
||||||
|
else -> null
|
||||||
|
} ?: return
|
||||||
|
Surface(
|
||||||
|
color = if (isCacheHit || cost == 0.0) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isCacheHit || cost == 0.0) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DesktopSummaryCachePanel(
|
||||||
|
bookKey: String,
|
||||||
|
summaryCacheStore: DesktopSummaryCacheStore,
|
||||||
|
onCacheChanged: () -> Unit
|
||||||
|
) {
|
||||||
|
var cachedItems by remember(bookKey) { mutableStateOf(summaryCacheStore.getAllSummaries(bookKey)) }
|
||||||
|
if (cachedItems.isEmpty()) {
|
||||||
|
Text(readerString("desktop_no_cached_summaries_book", "No cached summaries for this book yet."), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
cachedItems.forEach { item ->
|
||||||
|
var expanded by remember(item.index, item.summary) { mutableStateOf(false) }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
tonalElevation = 1.dp
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(item.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(readerString("desktop_cached_summary", "Cached summary"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { expanded = !expanded }) {
|
||||||
|
Text(if (expanded) readerString("desktop_hide", "Hide") else readerString("desktop_view", "View"))
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
summaryCacheStore.deleteSummary(bookKey, item.index)
|
||||||
|
cachedItems = summaryCacheStore.getAllSummaries(bookKey)
|
||||||
|
onCacheChanged()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = readerString("desktop_delete_summary", "Delete summary"), tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
SharedMarkdownText(item.summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
summaryCacheStore.clearBookCache(bookKey)
|
||||||
|
cachedItems = emptyList()
|
||||||
|
onCacheChanged()
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text(readerString("clear_all", "Clear all"), color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,703 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import org.dueattendant149.bookreader.shared.AppContrastOption
|
||||||
|
import org.dueattendant149.bookreader.shared.AppThemeMode
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.SharedAppTheme
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.readerString
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.awt.Component
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.awt.GraphicsDevice
|
||||||
|
import java.awt.KeyboardFocusManager
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300)
|
||||||
|
|
||||||
|
private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L)
|
||||||
|
|
||||||
|
internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) {
|
||||||
|
configureComposeSwingInterop()
|
||||||
|
application {
|
||||||
|
val windowDefaults = remember { epistemeDesktopWindowDefaults() }
|
||||||
|
val windowStateStore = remember { DesktopWindowStateStore() }
|
||||||
|
val restoredWindowState = remember { windowStateStore.load() }
|
||||||
|
val windowState = rememberWindowState(
|
||||||
|
placement = restoredWindowState?.toWindowPlacement()
|
||||||
|
?: DesktopWindowStateSnapshot.default().toWindowPlacement(),
|
||||||
|
position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center),
|
||||||
|
size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize
|
||||||
|
)
|
||||||
|
var readerFullscreen by remember { mutableStateOf(false) }
|
||||||
|
DesktopWindowStatePersistenceEffect(
|
||||||
|
windowState = windowState,
|
||||||
|
store = windowStateStore,
|
||||||
|
enabled = !readerFullscreen
|
||||||
|
)
|
||||||
|
Window(
|
||||||
|
onCloseRequest = ::exitApplication,
|
||||||
|
title = windowDefaults.title,
|
||||||
|
state = windowState,
|
||||||
|
icon = painterResource(windowDefaults.iconResourcePath)
|
||||||
|
) {
|
||||||
|
DisposableEffect(window, windowDefaults.minimumSize) {
|
||||||
|
window.minimumSize = windowDefaults.minimumSize
|
||||||
|
onDispose {
|
||||||
|
startupSplash?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EpistemeDesktopStartupGate(
|
||||||
|
window = window,
|
||||||
|
startupSplash = startupSplash,
|
||||||
|
appWindowPlacement = windowState.placement,
|
||||||
|
readerFullscreen = readerFullscreen,
|
||||||
|
onReaderFullscreenChange = { readerFullscreen = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpistemeDesktopStartupGate(
|
||||||
|
window: Component?,
|
||||||
|
startupSplash: DesktopStartupSplash?,
|
||||||
|
appWindowPlacement: WindowPlacement,
|
||||||
|
readerFullscreen: Boolean,
|
||||||
|
onReaderFullscreenChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
var showApp by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
DisposableEffect(startupSplash) {
|
||||||
|
onDispose {
|
||||||
|
startupSplash?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showApp) {
|
||||||
|
EpistemeDesktopApp(
|
||||||
|
window = window,
|
||||||
|
appWindowPlacement = appWindowPlacement,
|
||||||
|
readerFullscreen = readerFullscreen,
|
||||||
|
onReaderFullscreenChange = onReaderFullscreenChange
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EpistemeDesktopStartupScreen(window = window)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
withFrameNanos { }
|
||||||
|
startupSplash?.close()
|
||||||
|
delay(80L)
|
||||||
|
showApp = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpistemeDesktopStartupScreen(window: Component?) {
|
||||||
|
val appTitle = remember { epistemeDesktopWindowDefaults().title }
|
||||||
|
SharedAppTheme(
|
||||||
|
appThemeMode = AppThemeMode.SYSTEM,
|
||||||
|
appContrastOption = AppContrastOption.STANDARD,
|
||||||
|
appTextDimFactorLight = 1.0f,
|
||||||
|
appTextDimFactorDark = 1.0f,
|
||||||
|
appSeedColor = DesktopDefaultAppSeedColor
|
||||||
|
) {
|
||||||
|
EpistemeDesktopWindowChromeEffect(
|
||||||
|
window = window,
|
||||||
|
captionColor = MaterialTheme.colorScheme.surface,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
borderColor = MaterialTheme.colorScheme.background
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.padding(32.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(EpistemeDesktopWindowIconResource),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = appTitle,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = readerString("desktop_opening_your_library", "Opening your library"),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
|
||||||
|
internal const val ComposeInteropBlendingEnabled = "true"
|
||||||
|
private const val DesktopWindowStatePersistDebounceMillis = 450L
|
||||||
|
|
||||||
|
internal fun composeInteropBlendingDefault(
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
): String? {
|
||||||
|
return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
ComposeInteropBlendingEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun configureComposeSwingInterop(
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
) {
|
||||||
|
// Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT
|
||||||
|
// browser surfaces; the blending path can prevent those native children from painting.
|
||||||
|
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
|
||||||
|
composeInteropBlendingDefault(platform)?.let { defaultValue ->
|
||||||
|
System.setProperty(ComposeInteropBlendingProperty, defaultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logDesktopWebView2(
|
||||||
|
"compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopWindowStatePersistenceEffect(
|
||||||
|
windowState: WindowState,
|
||||||
|
store: DesktopWindowStateStore,
|
||||||
|
enabled: Boolean,
|
||||||
|
transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it },
|
||||||
|
onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val persistenceEnabled by rememberUpdatedState(enabled)
|
||||||
|
val latestTransformSnapshot by rememberUpdatedState(transformSnapshot)
|
||||||
|
val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved)
|
||||||
|
LaunchedEffect(windowState, store) {
|
||||||
|
snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { snapshot ->
|
||||||
|
if (!persistenceEnabled || snapshot == null) return@collectLatest
|
||||||
|
val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest
|
||||||
|
delay(DesktopWindowStatePersistDebounceMillis)
|
||||||
|
if (persistenceEnabled) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
store.save(persistableSnapshot)
|
||||||
|
}
|
||||||
|
latestOnSnapshotSaved(persistableSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(windowState, store) {
|
||||||
|
onDispose {
|
||||||
|
if (persistenceEnabled) {
|
||||||
|
DesktopWindowStateSnapshot.fromWindowState(windowState)
|
||||||
|
?.let(latestTransformSnapshot)
|
||||||
|
?.let { snapshot ->
|
||||||
|
runCatching { store.save(snapshot) }
|
||||||
|
latestOnSnapshotSaved(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderFullscreenEffect(
|
||||||
|
window: Component?,
|
||||||
|
enabled: Boolean
|
||||||
|
) {
|
||||||
|
val awtWindow = window as? java.awt.Window ?: return
|
||||||
|
val fullscreenSnapshot = remember(awtWindow) {
|
||||||
|
AtomicReference<DesktopReaderFullscreenSnapshot?>()
|
||||||
|
}
|
||||||
|
val pendingExitSnapshot = remember(awtWindow) {
|
||||||
|
AtomicReference<DesktopReaderFullscreenSnapshot?>()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(awtWindow, enabled) {
|
||||||
|
if (!enabled && fullscreenSnapshot.get() == null) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (enabled) {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(if (enabled) 180L else 80L)
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (enabled) {
|
||||||
|
pendingExitSnapshot.set(null)
|
||||||
|
awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot)
|
||||||
|
} else {
|
||||||
|
awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(120L)
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (!enabled) {
|
||||||
|
awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null))
|
||||||
|
}
|
||||||
|
awtWindow.refreshDesktopReaderWindowFocus()
|
||||||
|
}
|
||||||
|
if (!enabled) {
|
||||||
|
for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) {
|
||||||
|
delay(delayMillis)
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
awtWindow.refreshDesktopReaderWindowFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(awtWindow) {
|
||||||
|
onDispose {
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DesktopReaderFullscreenSnapshot(
|
||||||
|
val device: GraphicsDevice?,
|
||||||
|
val frameState: Int?,
|
||||||
|
val frameBounds: Rectangle?,
|
||||||
|
val alwaysOnTop: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun java.awt.Window.enterDesktopReaderFullscreen(
|
||||||
|
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>
|
||||||
|
) {
|
||||||
|
if (!isDisplayable) return
|
||||||
|
focusableWindowState = true
|
||||||
|
captureDesktopReaderFullscreenSnapshot(snapshotRef)
|
||||||
|
applyDesktopReaderBorderlessFullscreen(snapshotRef.get())
|
||||||
|
refreshDesktopReaderWindowFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot(
|
||||||
|
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>
|
||||||
|
) {
|
||||||
|
snapshotRef.compareAndSet(
|
||||||
|
null,
|
||||||
|
DesktopReaderFullscreenSnapshot(
|
||||||
|
device = graphicsConfiguration?.device,
|
||||||
|
frameState = (this as? Frame)?.extendedState,
|
||||||
|
frameBounds = bounds.desktopReaderCopy(),
|
||||||
|
alwaysOnTop = isAlwaysOnTop
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) {
|
||||||
|
if (!isDisplayable) return
|
||||||
|
val device = snapshot?.device ?: graphicsConfiguration?.device
|
||||||
|
val frame = this as? Frame
|
||||||
|
focusableWindowState = true
|
||||||
|
if (frame != null) {
|
||||||
|
frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv()
|
||||||
|
frame.state = Frame.NORMAL
|
||||||
|
}
|
||||||
|
device?.let { fullscreenDevice ->
|
||||||
|
runCatching {
|
||||||
|
if (fullscreenDevice.fullScreenWindow == this) {
|
||||||
|
fullscreenDevice.fullScreenWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
isAlwaysOnTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.beginDesktopReaderFullscreenExit(
|
||||||
|
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>,
|
||||||
|
pendingExitSnapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>? = null
|
||||||
|
) {
|
||||||
|
val snapshot = snapshotRef.getAndSet(null)
|
||||||
|
if (snapshot == null) return
|
||||||
|
pendingExitSnapshotRef?.set(snapshot)
|
||||||
|
val device = snapshot.device ?: graphicsConfiguration?.device
|
||||||
|
device?.let { fullscreenDevice ->
|
||||||
|
runCatching {
|
||||||
|
if (fullscreenDevice.fullScreenWindow == this) {
|
||||||
|
fullscreenDevice.fullScreenWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
isAlwaysOnTop = snapshot.alwaysOnTop
|
||||||
|
}
|
||||||
|
if (!isVisible) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
(this as? Frame)?.let { frame ->
|
||||||
|
frame.state = Frame.NORMAL
|
||||||
|
frame.extendedState = Frame.NORMAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) {
|
||||||
|
if (snapshot == null) return
|
||||||
|
runCatching {
|
||||||
|
isAlwaysOnTop = snapshot.alwaysOnTop
|
||||||
|
}
|
||||||
|
if (!isVisible) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val frame = this as? Frame
|
||||||
|
if (frame == null) {
|
||||||
|
bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val restoreMaximized = snapshot.frameState?.let { state ->
|
||||||
|
state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH
|
||||||
|
} == true
|
||||||
|
frame.extendedState = Frame.NORMAL
|
||||||
|
frame.state = Frame.NORMAL
|
||||||
|
frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
|
||||||
|
if (restoreMaximized) {
|
||||||
|
frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds()
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (frame.isDisplayable && frame.isShowing) {
|
||||||
|
frame.extendedState = Frame.MAXIMIZED_BOTH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame.toFront()
|
||||||
|
frame.requestFocus()
|
||||||
|
frame.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle {
|
||||||
|
return defaultConfiguration.bounds.desktopReaderCopy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? {
|
||||||
|
val configuration = defaultConfiguration ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val bounds = configuration.bounds
|
||||||
|
val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration)
|
||||||
|
Rectangle(
|
||||||
|
bounds.x + insets.left,
|
||||||
|
bounds.y + insets.top,
|
||||||
|
(bounds.width - insets.left - insets.right).coerceAtLeast(1),
|
||||||
|
(bounds.height - insets.top - insets.bottom).coerceAtLeast(1)
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle {
|
||||||
|
val usableBounds = device?.desktopReaderUsableBounds()
|
||||||
|
?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820)
|
||||||
|
val source = this ?: usableBounds
|
||||||
|
val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640))
|
||||||
|
val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480))
|
||||||
|
val looksFullscreen = source.x <= usableBounds.x &&
|
||||||
|
source.y <= usableBounds.y &&
|
||||||
|
source.width >= usableBounds.width &&
|
||||||
|
source.height >= usableBounds.height
|
||||||
|
if (looksFullscreen) {
|
||||||
|
return usableBounds.desktopReaderCopy()
|
||||||
|
}
|
||||||
|
val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x)
|
||||||
|
val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y)
|
||||||
|
return Rectangle(
|
||||||
|
source.x.coerceIn(usableBounds.x, maxX),
|
||||||
|
source.y.coerceIn(usableBounds.y, maxY),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Rectangle.desktopReaderCopy(): Rectangle {
|
||||||
|
return Rectangle(x, y, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.refreshDesktopReaderWindowFocus() {
|
||||||
|
if (!isDisplayable) return
|
||||||
|
if (this is Frame && extendedState and Frame.ICONIFIED != 0) {
|
||||||
|
extendedState = extendedState and Frame.ICONIFIED.inv()
|
||||||
|
}
|
||||||
|
toFront()
|
||||||
|
requestFocus()
|
||||||
|
requestFocusInWindow()
|
||||||
|
focusOwner?.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderFullscreenKeyEffect(
|
||||||
|
enabled: Boolean,
|
||||||
|
onKeyPressed: (AwtKeyEvent) -> Boolean
|
||||||
|
) {
|
||||||
|
DesktopReaderKeyDispatcherEffect(
|
||||||
|
enabled = enabled,
|
||||||
|
allowChromeModalWindows = false,
|
||||||
|
onKeyPressed = onKeyPressed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderKeyDispatcherEffect(
|
||||||
|
enabled: Boolean,
|
||||||
|
allowChromeModalWindows: Boolean = false,
|
||||||
|
allowPanelModalWindows: Boolean = false,
|
||||||
|
dispatchWhenOwnerWindowActive: Boolean = true,
|
||||||
|
onKeyPressed: (AwtKeyEvent) -> Boolean
|
||||||
|
) {
|
||||||
|
val currentOnKeyPressed by rememberUpdatedState(onKeyPressed)
|
||||||
|
DisposableEffect(
|
||||||
|
enabled,
|
||||||
|
allowChromeModalWindows,
|
||||||
|
allowPanelModalWindows,
|
||||||
|
dispatchWhenOwnerWindowActive
|
||||||
|
) {
|
||||||
|
if (!enabled) {
|
||||||
|
onDispose {}
|
||||||
|
} else {
|
||||||
|
val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||||
|
val dispatcher = java.awt.KeyEventDispatcher { event ->
|
||||||
|
val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow
|
||||||
|
val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind()
|
||||||
|
val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||||
|
activeReaderModalKind = activeReaderModalKind,
|
||||||
|
allowChromeModalWindows = allowChromeModalWindows,
|
||||||
|
allowPanelModalWindows = allowPanelModalWindows,
|
||||||
|
dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive
|
||||||
|
)
|
||||||
|
activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
|
||||||
|
}
|
||||||
|
focusManager.addKeyEventDispatcher(dispatcher)
|
||||||
|
onDispose {
|
||||||
|
focusManager.removeKeyEventDispatcher(dispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class DesktopReaderModalWindowKind {
|
||||||
|
CHROME,
|
||||||
|
PANEL,
|
||||||
|
POPUP
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||||
|
activeReaderModalKind: DesktopReaderModalWindowKind?,
|
||||||
|
allowChromeModalWindows: Boolean,
|
||||||
|
allowPanelModalWindows: Boolean,
|
||||||
|
dispatchWhenOwnerWindowActive: Boolean
|
||||||
|
): Boolean {
|
||||||
|
return when (activeReaderModalKind) {
|
||||||
|
null -> dispatchWhenOwnerWindowActive
|
||||||
|
DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows
|
||||||
|
DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows
|
||||||
|
DesktopReaderModalWindowKind.POPUP -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? {
|
||||||
|
val windowTitle = when (this) {
|
||||||
|
is java.awt.Dialog -> title
|
||||||
|
is Frame -> title
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
return desktopReaderModalWindowKind(
|
||||||
|
windowName = name.orEmpty(),
|
||||||
|
windowTitle = windowTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopReaderModalWindowKind(
|
||||||
|
windowName: String,
|
||||||
|
windowTitle: String
|
||||||
|
): DesktopReaderModalWindowKind? {
|
||||||
|
return when {
|
||||||
|
windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" ||
|
||||||
|
windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" ||
|
||||||
|
windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME
|
||||||
|
|
||||||
|
windowName == "${DesktopReaderModalWindowNamePrefix}Panel" ||
|
||||||
|
windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" ||
|
||||||
|
windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" ||
|
||||||
|
windowTitle.startsWith("Reader Panel") ||
|
||||||
|
windowTitle.startsWith("Reader Navigation") ||
|
||||||
|
windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL
|
||||||
|
|
||||||
|
windowName.startsWith(DesktopReaderModalWindowNamePrefix) ||
|
||||||
|
windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
|
||||||
|
|
||||||
|
internal data class DesktopWebViewRuntimeState(
|
||||||
|
val initialized: Boolean = false,
|
||||||
|
val restartRequired: Boolean = false,
|
||||||
|
val downloadProgress: Float = -1f,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal enum class DesktopEpubWebViewBackend(
|
||||||
|
val logName: String,
|
||||||
|
val displayName: String
|
||||||
|
) {
|
||||||
|
WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"),
|
||||||
|
WEBKIT("webkit", "WebKit"),
|
||||||
|
UNSUPPORTED("unsupported", "native webview")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopEpubWebViewBackend(
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
): DesktopEpubWebViewBackend {
|
||||||
|
return when (platform.os) {
|
||||||
|
DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
|
||||||
|
DesktopOperatingSystem.LINUX,
|
||||||
|
DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT
|
||||||
|
DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopEpubWebViewUsesNativeSwtBrowser(
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
): Boolean {
|
||||||
|
return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopEpubWebViewUsesWebView2(
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
): Boolean {
|
||||||
|
return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopShouldUseNativeVerticalEpubReader(
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
): Boolean {
|
||||||
|
return platform.os == DesktopOperatingSystem.LINUX
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopEpubBookLoadSemanticMode(
|
||||||
|
settings: ReaderSettings,
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
): SharedJvmBookLoadSemanticMode {
|
||||||
|
return if (
|
||||||
|
settings.readingMode == ReaderReadingMode.VERTICAL &&
|
||||||
|
!desktopShouldUseNativeVerticalEpubReader(platform)
|
||||||
|
) {
|
||||||
|
SharedJvmBookLoadSemanticMode.SKIP
|
||||||
|
} else {
|
||||||
|
SharedJvmBookLoadSemanticMode.FULL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopEpubWebViewCanRender(
|
||||||
|
state: DesktopWebViewRuntimeState,
|
||||||
|
platform: DesktopPlatform = currentDesktopPlatform()
|
||||||
|
): Boolean {
|
||||||
|
return desktopEpubWebViewUsesNativeSwtBrowser(platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopWebViewRuntimeIndicator(
|
||||||
|
state: DesktopWebViewRuntimeState,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val platform = currentDesktopPlatform()
|
||||||
|
val message = when {
|
||||||
|
!desktopEpubWebViewUsesNativeSwtBrowser(platform) ->
|
||||||
|
readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.")
|
||||||
|
state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage)
|
||||||
|
state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.")
|
||||||
|
state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt())
|
||||||
|
else -> readerString("desktop_webview_preparing", "Preparing embedded webview...")
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
if (state.errorMessage == null && !state.restartRequired) {
|
||||||
|
if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
if (state.downloadProgress in 0f..100f) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { state.downloadProgress / 100f },
|
||||||
|
modifier = Modifier.width(260.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.BookShelfRef
|
||||||
|
import org.dueattendant149.bookreader.shared.CustomFontItem
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||||
|
import org.dueattendant149.bookreader.shared.ShelfRecord
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.SharedAppTab
|
||||||
|
|
||||||
|
internal val DesktopInitialAppTab = SharedAppTab.LIBRARY
|
||||||
|
|
||||||
|
internal fun desktopEmptyReaderBook(): SharedEpubBook {
|
||||||
|
val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open")
|
||||||
|
return SharedEpubBook(
|
||||||
|
id = "desktop_empty_reader",
|
||||||
|
fileName = "",
|
||||||
|
title = noBookOpen,
|
||||||
|
chapters = listOf(
|
||||||
|
SharedEpubChapter(
|
||||||
|
id = "empty",
|
||||||
|
title = noBookOpen,
|
||||||
|
plainText = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot {
|
||||||
|
val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion
|
||||||
|
val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) {
|
||||||
|
DesktopDefaultTextReaderSettings
|
||||||
|
} else {
|
||||||
|
readerDefaultSettings
|
||||||
|
}
|
||||||
|
val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) {
|
||||||
|
DesktopDefaultPdfReaderSettings
|
||||||
|
} else {
|
||||||
|
pdfReaderDefaultSettings
|
||||||
|
}
|
||||||
|
val migratedBooks = if (shouldMigrateReaderDefaults) {
|
||||||
|
books.map { book ->
|
||||||
|
when {
|
||||||
|
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) &&
|
||||||
|
book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults)
|
||||||
|
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) &&
|
||||||
|
book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults)
|
||||||
|
else -> book
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
books
|
||||||
|
}
|
||||||
|
return copy(
|
||||||
|
books = migratedBooks,
|
||||||
|
appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor,
|
||||||
|
readerDefaultSettings = migratedTextDefaults,
|
||||||
|
pdfReaderDefaultSettings = migratedPdfDefaults,
|
||||||
|
desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState {
|
||||||
|
val readableBooks = books.filter { it.type in DesktopReadableFileTypes }
|
||||||
|
return SharedReaderScreenState(
|
||||||
|
rawLibraryBooks = readableBooks,
|
||||||
|
recentFilesLimit = recentFilesLimit,
|
||||||
|
allTags = tags.ifEmpty { readableBooks.collectTags() },
|
||||||
|
syncedFolders = syncedFolders,
|
||||||
|
isTabsEnabled = isTabsEnabled,
|
||||||
|
openTabIds = openTabIds,
|
||||||
|
activeTabBookId = activeTabBookId,
|
||||||
|
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||||
|
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
||||||
|
useStrictFileFilter = useStrictFileFilter,
|
||||||
|
appThemeMode = appThemeMode,
|
||||||
|
appContrastOption = appContrastOption,
|
||||||
|
appTextDimFactorLight = appTextDimFactorLight,
|
||||||
|
appTextDimFactorDark = appTextDimFactorDark,
|
||||||
|
appSeedColor = appSeedColor,
|
||||||
|
appFontPreference = appFontPreference,
|
||||||
|
customAppThemes = customAppThemes,
|
||||||
|
customReaderThemes = customReaderThemes,
|
||||||
|
readerDefaultSettings = readerDefaultSettings,
|
||||||
|
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
|
||||||
|
readerToolbarPreferences = readerToolbarPreferences,
|
||||||
|
readerHighlightPalette = readerHighlightPalette,
|
||||||
|
pdfHighlighterPalette = pdfHighlighterPalette,
|
||||||
|
readerTtsReplacementPreferences = readerTtsReplacementPreferences
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedLibraryStateProjector.projectDesktopLibraryState(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
shelfRecords: List<ShelfRecord>,
|
||||||
|
shelfRefs: List<BookShelfRef>
|
||||||
|
): SharedReaderScreenState {
|
||||||
|
val allBooks = state.rawLibraryBooks
|
||||||
|
val visibleBooks = allBooks.filterNot { isDesktopPdfReflowBookId(it.id) }
|
||||||
|
val projected = project(
|
||||||
|
SharedLibraryProjectionInput(
|
||||||
|
state = state,
|
||||||
|
booksFromStore = visibleBooks,
|
||||||
|
shelfRecords = shelfRecords,
|
||||||
|
shelfRefs = shelfRefs,
|
||||||
|
tags = state.allTags.ifEmpty { visibleBooks.collectTags() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val booksById = allBooks.associateBy { it.id }
|
||||||
|
val openTabs = state.openTabIds.mapNotNull { booksById[it] }
|
||||||
|
val openTabIds = openTabs.map { it.id }
|
||||||
|
return projected.copy(
|
||||||
|
rawLibraryBooks = allBooks,
|
||||||
|
openTabs = openTabs,
|
||||||
|
openTabIds = openTabIds,
|
||||||
|
activeTabBookId = state.activeTabBookId?.takeIf { it in openTabIds }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedReaderScreenState.toDesktopLibrarySnapshot(
|
||||||
|
shelfRecords: List<ShelfRecord>,
|
||||||
|
shelfRefs: List<BookShelfRef>,
|
||||||
|
customFonts: List<CustomFontItem>
|
||||||
|
): SharedLibrarySnapshot {
|
||||||
|
return SharedLibrarySnapshot(
|
||||||
|
books = rawLibraryBooks,
|
||||||
|
shelfRecords = shelfRecords,
|
||||||
|
shelfRefs = shelfRefs,
|
||||||
|
tags = allTags,
|
||||||
|
customFonts = customFonts,
|
||||||
|
syncedFolders = syncedFolders,
|
||||||
|
recentFilesLimit = recentFilesLimit,
|
||||||
|
isTabsEnabled = isTabsEnabled,
|
||||||
|
openTabIds = openTabIds,
|
||||||
|
activeTabBookId = activeTabBookId,
|
||||||
|
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||||
|
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
||||||
|
useStrictFileFilter = useStrictFileFilter,
|
||||||
|
appThemeMode = appThemeMode,
|
||||||
|
appContrastOption = appContrastOption,
|
||||||
|
appTextDimFactorLight = appTextDimFactorLight,
|
||||||
|
appTextDimFactorDark = appTextDimFactorDark,
|
||||||
|
appSeedColor = appSeedColor,
|
||||||
|
appFontPreference = appFontPreference,
|
||||||
|
customAppThemes = customAppThemes,
|
||||||
|
customReaderThemes = customReaderThemes,
|
||||||
|
readerDefaultSettings = readerDefaultSettings,
|
||||||
|
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
|
||||||
|
desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion,
|
||||||
|
readerToolbarPreferences = readerToolbarPreferences,
|
||||||
|
readerHighlightPalette = readerHighlightPalette,
|
||||||
|
pdfHighlighterPalette = pdfHighlighterPalette,
|
||||||
|
readerTtsReplacementPreferences = readerTtsReplacementPreferences
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
internal fun File.writeTextAtomically(text: String) {
|
||||||
|
parentFile?.mkdirs()
|
||||||
|
val temp = createSiblingTempFile()
|
||||||
|
try {
|
||||||
|
temp.writeText(text)
|
||||||
|
moveReplacing(temp, this)
|
||||||
|
} finally {
|
||||||
|
runCatching { if (temp.exists()) temp.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun File.storePropertiesAtomically(properties: Properties, comments: String) {
|
||||||
|
parentFile?.mkdirs()
|
||||||
|
val temp = createSiblingTempFile()
|
||||||
|
try {
|
||||||
|
temp.outputStream().use { output ->
|
||||||
|
properties.store(output, comments)
|
||||||
|
}
|
||||||
|
moveReplacing(temp, this)
|
||||||
|
} finally {
|
||||||
|
runCatching { if (temp.exists()) temp.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.createSiblingTempFile(): File {
|
||||||
|
val directory = parentFile ?: File(".")
|
||||||
|
directory.mkdirs()
|
||||||
|
val prefix = ".$name."
|
||||||
|
return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveReplacing(source: File, target: File) {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
try {
|
||||||
|
Files.move(
|
||||||
|
source.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE
|
||||||
|
)
|
||||||
|
} catch (_: AtomicMoveNotSupportedException) {
|
||||||
|
Files.move(
|
||||||
|
source.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.ImportedBookFile
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
internal data class DesktopPreparedImport(
|
||||||
|
val files: List<ImportedBookFile>,
|
||||||
|
val failedCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class DesktopBookImporter(
|
||||||
|
private val booksDirectory: File = File(desktopUserDataRoot(), "books")
|
||||||
|
) {
|
||||||
|
fun createBookFile(fileName: String): File {
|
||||||
|
booksDirectory.mkdirs()
|
||||||
|
return File(booksDirectory, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prepareImports(files: List<ImportedBookFile>): DesktopPreparedImport {
|
||||||
|
val preparedFiles = mutableListOf<ImportedBookFile>()
|
||||||
|
var failedCount = 0
|
||||||
|
booksDirectory.mkdirs()
|
||||||
|
|
||||||
|
files.forEach { file ->
|
||||||
|
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||||
|
if (!SharedFileCapabilities.canOpen(type, ReaderPlatform.DESKTOP)) {
|
||||||
|
preparedFiles += file.copy(sourceFolder = null)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val source = file.localPath
|
||||||
|
?.let(::File)
|
||||||
|
?.takeIf { it.isFile }
|
||||||
|
|
||||||
|
if (source == null) {
|
||||||
|
failedCount += 1
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val hashResult = runCatching { source.sha256() }
|
||||||
|
if (hashResult.isFailure) {
|
||||||
|
failedCount += 1
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val hash = hashResult.getOrThrow()
|
||||||
|
val destination = File(booksDirectory, "$hash${file.storageSuffix(source)}")
|
||||||
|
|
||||||
|
val copyResult = runCatching {
|
||||||
|
copyIfNeeded(source, destination)
|
||||||
|
destination
|
||||||
|
}
|
||||||
|
if (copyResult.isFailure) {
|
||||||
|
failedCount += 1
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val copied = copyResult.getOrThrow()
|
||||||
|
|
||||||
|
preparedFiles += ImportedBookFile(
|
||||||
|
name = file.name,
|
||||||
|
uriString = null,
|
||||||
|
localPath = copied.absolutePath,
|
||||||
|
size = copied.length(),
|
||||||
|
sourceFolder = null,
|
||||||
|
id = hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DesktopPreparedImport(
|
||||||
|
files = preparedFiles,
|
||||||
|
failedCount = failedCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyIfNeeded(source: File, destination: File) {
|
||||||
|
val sourceFile = source.canonicalFile
|
||||||
|
val destinationFile = destination.canonicalFile
|
||||||
|
if (sourceFile == destinationFile) return
|
||||||
|
destination.parentFile?.mkdirs()
|
||||||
|
Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ImportedBookFile.storageSuffix(source: File): String {
|
||||||
|
return SharedFileCapabilities.fileExtensionSuffixForName(name)
|
||||||
|
?: source.extension.takeIf { it.isNotBlank() }?.let { ".$it" }
|
||||||
|
?: ".book"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.sha256(): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
inputStream().use { input ->
|
||||||
|
val buffer = ByteArray(8 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) break
|
||||||
|
digest.update(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().toHexString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toHexString(): String {
|
||||||
|
return joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedLegalLinks
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedLegalProfile
|
||||||
|
import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile
|
||||||
|
|
||||||
|
internal const val DesktopFlavorProperty = "episteme.desktop.flavor"
|
||||||
|
internal const val DesktopVersionProperty = "episteme.desktop.version"
|
||||||
|
internal const val DesktopFlavorStandard = "standard"
|
||||||
|
internal const val DesktopFlavorOssOffline = "oss-offline"
|
||||||
|
internal const val EpistemeDesktopStandardAppName = "Episteme"
|
||||||
|
internal const val EpistemeDesktopOssAppName = "Episteme oss"
|
||||||
|
internal const val ComposeApplicationResourcesDirProperty = "compose.application.resources.dir"
|
||||||
|
|
||||||
|
internal data class DesktopBuildProfile(
|
||||||
|
val flavor: String,
|
||||||
|
val appName: String,
|
||||||
|
val buildLabel: String,
|
||||||
|
val featurePolicy: SharedFeaturePolicy,
|
||||||
|
val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) {
|
||||||
|
SharedLegalProfile.OSS
|
||||||
|
} else {
|
||||||
|
SharedLegalProfile.STANDARD
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline
|
||||||
|
val aiKeySettingsAvailable: Boolean
|
||||||
|
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS
|
||||||
|
val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess
|
||||||
|
val creditBackedCloudTtsControlsAvailable: Boolean
|
||||||
|
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable
|
||||||
|
val legalLinks: SharedLegalLinks
|
||||||
|
get() = sharedLegalLinksForProfile(legalProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun currentDesktopBuildProfile(): DesktopBuildProfile {
|
||||||
|
return desktopBuildProfileForFlavor(
|
||||||
|
System.getProperty(DesktopFlavorProperty, DesktopFlavorStandard)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfile {
|
||||||
|
val flavor = normalizedDesktopFlavor(rawFlavor)
|
||||||
|
return when (flavor) {
|
||||||
|
DesktopFlavorOssOffline -> DesktopBuildProfile(
|
||||||
|
flavor = DesktopFlavorOssOffline,
|
||||||
|
appName = EpistemeDesktopOssAppName,
|
||||||
|
buildLabel = "Offline OSS edition",
|
||||||
|
featurePolicy = SharedFeaturePolicy.OssOffline,
|
||||||
|
legalProfile = SharedLegalProfile.OSS
|
||||||
|
)
|
||||||
|
else -> DesktopBuildProfile(
|
||||||
|
flavor = DesktopFlavorStandard,
|
||||||
|
appName = EpistemeDesktopStandardAppName,
|
||||||
|
buildLabel = "Standard edition",
|
||||||
|
featurePolicy = SharedFeaturePolicy.Standard,
|
||||||
|
legalProfile = SharedLegalProfile.STANDARD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizedDesktopFlavor(rawFlavor: String?): String {
|
||||||
|
return when (rawFlavor?.trim()?.lowercase()) {
|
||||||
|
DesktopFlavorOssOffline,
|
||||||
|
"oss",
|
||||||
|
"episteme-oss" -> DesktopFlavorOssOffline
|
||||||
|
else -> DesktopFlavorStandard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ReaderAiByokSettings.withDesktopFeaturePolicy(
|
||||||
|
featurePolicy: SharedFeaturePolicy
|
||||||
|
): ReaderAiByokSettings {
|
||||||
|
return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) {
|
||||||
|
toDesktopPersistableAiSettings()
|
||||||
|
} else {
|
||||||
|
ReaderAiByokSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings {
|
||||||
|
return sanitized().copy(hideReaderAiFeatures = false)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.AiAdapter
|
||||||
|
import org.dueattendant149.bookreader.shared.AiDefinitionResult
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderAiFeature
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderByokTextRequest
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderByokTextRequestResult
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderByokTextRequests
|
||||||
|
import org.dueattendant149.bookreader.shared.RecapResult
|
||||||
|
import org.dueattendant149.bookreader.shared.SummarizationResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class DesktopByokAiAdapter(
|
||||||
|
private val settingsProvider: () -> ReaderAiByokSettings,
|
||||||
|
private val networkAccess: () -> Boolean = { true }
|
||||||
|
) : AiAdapter {
|
||||||
|
override val isAvailable: Boolean
|
||||||
|
get() = networkAccess() && settingsProvider().sanitized().areReaderAiFeaturesAvailable
|
||||||
|
|
||||||
|
override suspend fun define(text: String, context: String?): AiDefinitionResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
|
||||||
|
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun defineStreaming(
|
||||||
|
text: String,
|
||||||
|
context: String?,
|
||||||
|
onUpdate: (String) -> Unit
|
||||||
|
): AiDefinitionResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.DEFINE, text, context, onUpdate)
|
||||||
|
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun summarize(text: String): SummarizationResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text)
|
||||||
|
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun summarizeStreaming(
|
||||||
|
text: String,
|
||||||
|
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit,
|
||||||
|
onUpdate: (String) -> Unit
|
||||||
|
): SummarizationResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text, onUpdate = onUpdate)
|
||||||
|
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun recap(textBeforeCurrentLocation: String): RecapResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation)
|
||||||
|
return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun callTextAi(
|
||||||
|
feature: ReaderAiFeature,
|
||||||
|
text: String,
|
||||||
|
context: String? = null,
|
||||||
|
onUpdate: (String) -> Unit = {}
|
||||||
|
): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
if (!networkAccess()) return@withContext Result.failure(IllegalStateException("AI features are unavailable in this desktop build."))
|
||||||
|
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
|
||||||
|
when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) {
|
||||||
|
ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden."))
|
||||||
|
is ReaderByokTextRequestResult.MissingKey -> {
|
||||||
|
Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models."))
|
||||||
|
}
|
||||||
|
is ReaderByokTextRequestResult.MissingModel -> {
|
||||||
|
Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models."))
|
||||||
|
}
|
||||||
|
is ReaderByokTextRequestResult.Ready -> runCatching {
|
||||||
|
requestResult.request.execute(onUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderByokTextRequest.execute(onUpdate: (String) -> Unit): String {
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
val url = if (model.provider == "groq") {
|
||||||
|
URL("https://api.groq.com/openai/v1/chat/completions")
|
||||||
|
} else {
|
||||||
|
URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey")
|
||||||
|
}
|
||||||
|
connection = (url.openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
if (model.provider == "groq") {
|
||||||
|
setRequestProperty("Authorization", "Bearer $apiKey")
|
||||||
|
}
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 120_000
|
||||||
|
doOutput = true
|
||||||
|
doInput = true
|
||||||
|
}
|
||||||
|
val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this)
|
||||||
|
connection.outputStream.use { output ->
|
||||||
|
output.write(payload.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
val errorBody = runCatching {
|
||||||
|
connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||||
|
}.getOrNull()
|
||||||
|
throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
||||||
|
}
|
||||||
|
val text = if (model.provider == "groq") {
|
||||||
|
streamGroqResponse(connection, onUpdate)
|
||||||
|
} else {
|
||||||
|
streamGeminiResponse(connection, onUpdate)
|
||||||
|
}.trim()
|
||||||
|
if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.")
|
||||||
|
return text
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGroqPayload(request: ReaderByokTextRequest): String {
|
||||||
|
return buildJsonObject {
|
||||||
|
put("model", JsonPrimitive(request.model.name))
|
||||||
|
put(
|
||||||
|
"messages",
|
||||||
|
buildJsonArray {
|
||||||
|
add(buildJsonObject {
|
||||||
|
put("role", JsonPrimitive("system"))
|
||||||
|
put("content", JsonPrimitive(request.systemInstruction))
|
||||||
|
})
|
||||||
|
add(buildJsonObject {
|
||||||
|
put("role", JsonPrimitive("user"))
|
||||||
|
put("content", JsonPrimitive(request.userPrompt))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put("temperature", JsonPrimitive(request.temperature))
|
||||||
|
put("top_p", JsonPrimitive(0.95))
|
||||||
|
put("max_tokens", JsonPrimitive(request.maxTokens))
|
||||||
|
put("stream", JsonPrimitive(true))
|
||||||
|
if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none"))
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGeminiPayload(request: ReaderByokTextRequest): String {
|
||||||
|
return buildJsonObject {
|
||||||
|
put(
|
||||||
|
"contents",
|
||||||
|
buildJsonArray {
|
||||||
|
add(buildJsonObject {
|
||||||
|
put("parts", buildJsonArray {
|
||||||
|
add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"systemInstruction",
|
||||||
|
buildJsonObject {
|
||||||
|
put("parts", buildJsonArray {
|
||||||
|
add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"generationConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put("temperature", JsonPrimitive(request.temperature))
|
||||||
|
put("topP", JsonPrimitive(0.95))
|
||||||
|
put("topK", JsonPrimitive(40))
|
||||||
|
put("maxOutputTokens", JsonPrimitive(request.maxTokens))
|
||||||
|
put("response_mime_type", JsonPrimitive("text/plain"))
|
||||||
|
if (request.model.name.startsWith("gemini")) {
|
||||||
|
put(
|
||||||
|
"thinkingConfig",
|
||||||
|
buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamGeminiResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String {
|
||||||
|
val output = StringBuilder()
|
||||||
|
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
var buffer = ""
|
||||||
|
var line: String?
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
buffer += line
|
||||||
|
while (true) {
|
||||||
|
val start = buffer.indexOf('{')
|
||||||
|
if (start == -1) {
|
||||||
|
buffer = ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var depth = 0
|
||||||
|
var end = -1
|
||||||
|
scan@ for (index in start until buffer.length) {
|
||||||
|
when (buffer[index]) {
|
||||||
|
'{' -> depth++
|
||||||
|
'}' -> {
|
||||||
|
depth--
|
||||||
|
if (depth == 0) {
|
||||||
|
end = index
|
||||||
|
break@scan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end == -1) break
|
||||||
|
val jsonObject = buffer.substring(start, end + 1)
|
||||||
|
buffer = buffer.substring(end + 1)
|
||||||
|
val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull()
|
||||||
|
val chunk = parsed.geminiTextChunk()
|
||||||
|
if (chunk.isNotEmpty()) {
|
||||||
|
output.append(chunk)
|
||||||
|
onUpdate(chunk)
|
||||||
|
}
|
||||||
|
if (parsed.geminiFinishReason() == "SAFETY") {
|
||||||
|
throw IllegalStateException("Blocked for safety reasons.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamGroqResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String {
|
||||||
|
val output = StringBuilder()
|
||||||
|
var inThink = false
|
||||||
|
var thinkBuffer = ""
|
||||||
|
|
||||||
|
fun cleanChunk(text: String): String {
|
||||||
|
thinkBuffer += text
|
||||||
|
val cleaned = StringBuilder()
|
||||||
|
while (true) {
|
||||||
|
if (inThink) {
|
||||||
|
val end = thinkBuffer.indexOf("</think>")
|
||||||
|
if (end == -1) {
|
||||||
|
if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
inThink = false
|
||||||
|
thinkBuffer = thinkBuffer.substring(end + 8)
|
||||||
|
} else {
|
||||||
|
val start = thinkBuffer.indexOf("<think>")
|
||||||
|
if (start == -1) {
|
||||||
|
if (thinkBuffer.length > 6) {
|
||||||
|
cleaned.append(thinkBuffer.dropLast(6))
|
||||||
|
thinkBuffer = thinkBuffer.takeLast(6)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cleaned.append(thinkBuffer.substring(0, start))
|
||||||
|
inThink = true
|
||||||
|
thinkBuffer = thinkBuffer.substring(start + 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
var line: String?
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
val trimmed = line!!.trim()
|
||||||
|
if (!trimmed.startsWith("data: ")) continue
|
||||||
|
val data = trimmed.removePrefix("data: ").trim()
|
||||||
|
if (data == "[DONE]") continue
|
||||||
|
val chunk = runCatching {
|
||||||
|
DesktopAiJson.parseToJsonElement(data).jsonObject["choices"]
|
||||||
|
?.jsonArray
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObject
|
||||||
|
?.get("delta")
|
||||||
|
?.jsonObject
|
||||||
|
?.get("content")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
val cleaned = cleanChunk(chunk)
|
||||||
|
if (cleaned.isNotEmpty()) {
|
||||||
|
output.append(cleaned)
|
||||||
|
onUpdate(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inThink && thinkBuffer.isNotBlank()) {
|
||||||
|
output.append(thinkBuffer)
|
||||||
|
onUpdate(thinkBuffer)
|
||||||
|
}
|
||||||
|
return output.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DesktopAiJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun JsonObject?.geminiTextChunk(): String {
|
||||||
|
if (this == null) return ""
|
||||||
|
return this["candidates"]
|
||||||
|
?.jsonArrayOrNull()
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("content")
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("parts")
|
||||||
|
?.jsonArrayOrNull()
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("text")
|
||||||
|
?.jsonPrimitiveOrNull()
|
||||||
|
?.contentOrNull
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject?.geminiFinishReason(): String? {
|
||||||
|
if (this == null) return null
|
||||||
|
return this["candidates"]
|
||||||
|
?.jsonArrayOrNull()
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("finishReason")
|
||||||
|
?.jsonPrimitiveOrNull()
|
||||||
|
?.contentOrNull
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
|
||||||
|
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
internal data class DesktopCloudConfig(
|
||||||
|
val aiWorkerUrl: String,
|
||||||
|
val ttsWorkerUrl: String,
|
||||||
|
val firebaseWebApiKey: String,
|
||||||
|
val firebaseProjectId: String,
|
||||||
|
val googleOAuthClientId: String,
|
||||||
|
val googleOAuthClientSecret: String
|
||||||
|
) {
|
||||||
|
val isAuthConfigured: Boolean
|
||||||
|
get() = firebaseWebApiKey.isNotBlank() &&
|
||||||
|
firebaseProjectId.isNotBlank() &&
|
||||||
|
googleOAuthClientId.isNotBlank()
|
||||||
|
|
||||||
|
val isAiWorkerConfigured: Boolean get() = aiWorkerUrl.isNotBlank()
|
||||||
|
val isTtsWorkerConfigured: Boolean get() = ttsWorkerUrl.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
|
||||||
|
return desktopCloudConfigFromProperties(
|
||||||
|
resourceProperties = loadDesktopCloudResourceProperties(),
|
||||||
|
localProperties = loadDesktopLocalProperties()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDesktopCloudResourceProperties(): Properties {
|
||||||
|
return Properties().apply {
|
||||||
|
val classLoader = DesktopCloudConfig::class.java.classLoader
|
||||||
|
val stream = classLoader.getResourceAsStream("desktop-cloud.properties")
|
||||||
|
?: classLoader.getResourceAsStream("common/desktop-cloud.properties")
|
||||||
|
?: System.getProperty(ComposeApplicationResourcesDirProperty)
|
||||||
|
?.let(::File)
|
||||||
|
?.let { resourcesDir ->
|
||||||
|
listOf(
|
||||||
|
resourcesDir.resolve("desktop-cloud.properties"),
|
||||||
|
resourcesDir.resolve("common/desktop-cloud.properties")
|
||||||
|
).firstOrNull { it.isFile }?.inputStream()
|
||||||
|
}
|
||||||
|
stream?.use { input -> load(input) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties {
|
||||||
|
return Properties().apply {
|
||||||
|
file.takeIf { it.isFile }
|
||||||
|
?.inputStream()
|
||||||
|
?.use { input -> load(input) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopCloudConfigFromProperties(
|
||||||
|
resourceProperties: Properties,
|
||||||
|
localProperties: Properties = Properties(),
|
||||||
|
systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") },
|
||||||
|
environment: (String) -> String? = { key -> System.getenv(key) }
|
||||||
|
): DesktopCloudConfig {
|
||||||
|
fun value(vararg keys: String): String {
|
||||||
|
return keys.firstNotNullOfOrNull { key ->
|
||||||
|
systemProperty(key)
|
||||||
|
?: environment("EPISTEME_DESKTOP_${key.uppercase()}")
|
||||||
|
?: environment(key)
|
||||||
|
?: localProperties.getProperty("DESKTOP_$key")
|
||||||
|
?: localProperties.getProperty(key)
|
||||||
|
?: resourceProperties.getProperty(key)
|
||||||
|
}?.trim().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
val aiWorkerUrl = value("AI_WORKER_URL").ifBlank {
|
||||||
|
"https://reader-ai.aryanrajttps.workers.dev"
|
||||||
|
}
|
||||||
|
val ttsWorkerUrl = value("TTS_WORKER_URL")
|
||||||
|
|
||||||
|
return DesktopCloudConfig(
|
||||||
|
aiWorkerUrl = aiWorkerUrl,
|
||||||
|
ttsWorkerUrl = ttsWorkerUrl,
|
||||||
|
firebaseWebApiKey = value("FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"),
|
||||||
|
firebaseProjectId = value("FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" },
|
||||||
|
googleOAuthClientId = value("GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"),
|
||||||
|
googleOAuthClientSecret = value("GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,683 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.SequenceInputStream
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.HttpRequest
|
||||||
|
import java.net.http.HttpResponse
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
internal data class DesktopCloudBookMetadata(
|
||||||
|
val bookId: String = "",
|
||||||
|
val title: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val displayName: String = "",
|
||||||
|
val type: String = "",
|
||||||
|
val lastPositionCfi: String? = null,
|
||||||
|
val lastChapterIndex: Int? = null,
|
||||||
|
val locatorBlockIndex: Int? = null,
|
||||||
|
val locatorCharOffset: Int? = null,
|
||||||
|
val lastPage: Int? = null,
|
||||||
|
val progressPercentage: Float? = null,
|
||||||
|
val isRecent: Boolean = true,
|
||||||
|
val isDeleted: Boolean = false,
|
||||||
|
val lastModifiedTimestamp: Long = 0L,
|
||||||
|
val readingPositionModifiedTimestamp: Long = 0L,
|
||||||
|
val annotationModifiedTimestamp: Long = 0L,
|
||||||
|
val bookmarksJson: String? = null,
|
||||||
|
val hasAnnotations: Boolean = false,
|
||||||
|
val fileContentModifiedTimestamp: Long = 0L,
|
||||||
|
val customName: String? = null,
|
||||||
|
val highlightsJson: String? = null,
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesIndex: Double? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val originalTitle: String? = null,
|
||||||
|
val originalAuthor: String? = null,
|
||||||
|
val originalSeriesName: String? = null,
|
||||||
|
val originalSeriesIndex: Double? = null,
|
||||||
|
val originalDescription: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopCloudShelfMetadata(
|
||||||
|
val name: String = "",
|
||||||
|
val bookIds: List<String> = emptyList(),
|
||||||
|
val lastModifiedTimestamp: Long = 0L,
|
||||||
|
val isDeleted: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopCloudFontMetadata(
|
||||||
|
val id: String = "",
|
||||||
|
val displayName: String = "",
|
||||||
|
val fileName: String = "",
|
||||||
|
val fileExtension: String = "",
|
||||||
|
val timestamp: Long = 0L,
|
||||||
|
val isDeleted: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopDriveFile(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val modifiedTimeMillis: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class DesktopFirestoreRepository(
|
||||||
|
private val config: DesktopCloudConfig,
|
||||||
|
private val client: HttpClient = defaultDesktopCloudHttpClient()
|
||||||
|
) {
|
||||||
|
suspend fun getAllBooks(userId: String, idToken: String): List<DesktopCloudBookMetadata> =
|
||||||
|
firestoreCollection(userId, "books", idToken).mapNotNull { document ->
|
||||||
|
document.fields?.toBookMetadata(document.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getBookMetadata(userId: String, bookId: String, idToken: String): DesktopCloudBookMetadata? =
|
||||||
|
firestoreDocument(userId, "books", bookId, idToken)?.let { document ->
|
||||||
|
document.fields?.toBookMetadata(document.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncBookMetadata(
|
||||||
|
userId: String,
|
||||||
|
book: DesktopCloudBookMetadata,
|
||||||
|
originDeviceId: String,
|
||||||
|
idToken: String
|
||||||
|
) {
|
||||||
|
val fields = book.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId))
|
||||||
|
writeFirestoreDocument(userId, "books", book.bookId, fields, idToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllShelves(userId: String, idToken: String): List<DesktopCloudShelfMetadata> =
|
||||||
|
firestoreCollection(userId, "shelves", idToken).mapNotNull { document ->
|
||||||
|
document.fields?.toShelfMetadata(document.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncShelf(
|
||||||
|
userId: String,
|
||||||
|
shelf: DesktopCloudShelfMetadata,
|
||||||
|
originDeviceId: String,
|
||||||
|
idToken: String
|
||||||
|
) {
|
||||||
|
val fields = shelf.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId))
|
||||||
|
writeFirestoreDocument(userId, "shelves", shelf.name, fields, idToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllFonts(userId: String, idToken: String): List<DesktopCloudFontMetadata> =
|
||||||
|
firestoreCollection(userId, "fonts", idToken).mapNotNull { document ->
|
||||||
|
document.fields?.toFontMetadata(document.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncFontMetadata(userId: String, font: DesktopCloudFontMetadata, idToken: String) {
|
||||||
|
writeFirestoreDocument(userId, "fonts", font.id, font.toFirestoreFields(), idToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteFontMetadata(userId: String, fontId: String, idToken: String) {
|
||||||
|
deleteFirestoreDocument(userId, "fonts", fontId, idToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteAllUserFirestoreData(userId: String, idToken: String) {
|
||||||
|
listOf("books", "shelves", "fonts").forEach { collection ->
|
||||||
|
firestoreCollection(userId, collection, idToken).forEach { document ->
|
||||||
|
deleteFirestoreDocument(userId, collection, document.id, idToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun firestoreCollection(
|
||||||
|
userId: String,
|
||||||
|
collection: String,
|
||||||
|
idToken: String
|
||||||
|
): List<DesktopFirestoreDocument> = withContext(Dispatchers.IO) {
|
||||||
|
val response = sendFirestore(
|
||||||
|
request = HttpRequest.newBuilder(firestoreCollectionUri(userId, collection))
|
||||||
|
.GET()
|
||||||
|
.build(),
|
||||||
|
idToken = idToken,
|
||||||
|
allowNotFound = true
|
||||||
|
) ?: return@withContext emptyList()
|
||||||
|
val root = DesktopCloudJson.parseToJsonElement(response).jsonObject
|
||||||
|
root["documents"]?.jsonArrayOrNull().orEmpty().mapNotNull { element ->
|
||||||
|
val document = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||||
|
DesktopFirestoreDocument(
|
||||||
|
id = document.string("name")?.substringAfterLast('/').orEmpty(),
|
||||||
|
fields = document["fields"]?.jsonObjectOrNull()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun firestoreDocument(
|
||||||
|
userId: String,
|
||||||
|
collection: String,
|
||||||
|
documentId: String,
|
||||||
|
idToken: String
|
||||||
|
): DesktopFirestoreDocument? = withContext(Dispatchers.IO) {
|
||||||
|
val response = sendFirestore(
|
||||||
|
request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
|
||||||
|
.GET()
|
||||||
|
.build(),
|
||||||
|
idToken = idToken,
|
||||||
|
allowNotFound = true
|
||||||
|
) ?: return@withContext null
|
||||||
|
val document = DesktopCloudJson.parseToJsonElement(response).jsonObject
|
||||||
|
DesktopFirestoreDocument(
|
||||||
|
id = document.string("name")?.substringAfterLast('/').orEmpty(),
|
||||||
|
fields = document["fields"]?.jsonObjectOrNull()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun writeFirestoreDocument(
|
||||||
|
userId: String,
|
||||||
|
collection: String,
|
||||||
|
documentId: String,
|
||||||
|
fields: Map<String, JsonElement>,
|
||||||
|
idToken: String
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
val body = JsonObject(mapOf("fields" to JsonObject(fields)))
|
||||||
|
sendFirestore(
|
||||||
|
request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
|
||||||
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
.method("PATCH", HttpRequest.BodyPublishers.ofString(DesktopCloudJson.encodeToString(JsonElement.serializer(), body)))
|
||||||
|
.build(),
|
||||||
|
idToken = idToken
|
||||||
|
)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteFirestoreDocument(
|
||||||
|
userId: String,
|
||||||
|
collection: String,
|
||||||
|
documentId: String,
|
||||||
|
idToken: String
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
sendFirestore(
|
||||||
|
request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
|
||||||
|
.DELETE()
|
||||||
|
.build(),
|
||||||
|
idToken = idToken,
|
||||||
|
allowNotFound = true
|
||||||
|
)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreCollectionUri(userId: String, collection: String): URI {
|
||||||
|
return URI.create("${firestoreBaseUrl()}/users/${pathEncode(userId)}/$collection")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreDocumentUri(userId: String, collection: String, documentId: String): URI {
|
||||||
|
return URI.create("${firestoreCollectionUri(userId, collection)}/${pathEncode(documentId)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreBaseUrl(): String {
|
||||||
|
return "https://firestore.googleapis.com/v1/projects/${pathEncode(config.firebaseProjectId)}/databases/(default)/documents"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendFirestore(
|
||||||
|
request: HttpRequest,
|
||||||
|
idToken: String,
|
||||||
|
allowNotFound: Boolean = false
|
||||||
|
): String? {
|
||||||
|
val authed = HttpRequest.newBuilder(request.uri())
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.copyMethodAndBodyFrom(request)
|
||||||
|
.header("Authorization", "Bearer $idToken")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.apply {
|
||||||
|
request.headers().map().forEach { (name, values) ->
|
||||||
|
values.forEach { value -> header(name, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
val response = client.send(authed, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
|
||||||
|
if (allowNotFound && response.statusCode() == 404) return null
|
||||||
|
if (response.statusCode() !in 200..299) {
|
||||||
|
throw IllegalStateException("Firestore HTTP ${response.statusCode()}: ${response.body().take(240)}")
|
||||||
|
}
|
||||||
|
return response.body()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopGoogleDriveRepository(
|
||||||
|
private val client: HttpClient = defaultDesktopCloudHttpClient()
|
||||||
|
) {
|
||||||
|
suspend fun getFiles(accessToken: String): List<DesktopDriveFile> = withContext(Dispatchers.IO) {
|
||||||
|
listFiles(accessToken = accessToken, query = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) {
|
||||||
|
listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? =
|
||||||
|
uploadNamedFile(
|
||||||
|
accessToken = accessToken,
|
||||||
|
fileName = fileName,
|
||||||
|
file = file,
|
||||||
|
contentType = when (extension.lowercase()) {
|
||||||
|
"ttf" -> "font/ttf"
|
||||||
|
"otf" -> "font/otf"
|
||||||
|
"woff2" -> "font/woff2"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun uploadFile(accessToken: String, bookId: String, file: File, type: FileType): DesktopDriveFile? {
|
||||||
|
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
|
||||||
|
val mimeType = SharedFileCapabilities.mimeTypeFor(type) ?: return null
|
||||||
|
return uploadNamedFile(
|
||||||
|
accessToken = accessToken,
|
||||||
|
fileName = "$bookId.$extension",
|
||||||
|
file = file,
|
||||||
|
contentType = mimeType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? {
|
||||||
|
return uploadNamedFile(
|
||||||
|
accessToken = accessToken,
|
||||||
|
fileName = desktopCloudAnnotationDriveFileName(bookId),
|
||||||
|
file = file,
|
||||||
|
contentType = "application/json"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean {
|
||||||
|
val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId))
|
||||||
|
?: return false
|
||||||
|
return downloadFile(accessToken, driveFile.id, destination).also { downloaded ->
|
||||||
|
if (downloaded && driveFile.modifiedTimeMillis > 0L) {
|
||||||
|
destination.setLastModified(driveFile.modifiedTimeMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
destination.parentFile?.mkdirs()
|
||||||
|
val temp = File(destination.parentFile ?: File("."), "${destination.name}.${System.nanoTime()}.tmp")
|
||||||
|
val request = HttpRequest.newBuilder(
|
||||||
|
URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}?alt=media")
|
||||||
|
)
|
||||||
|
.timeout(Duration.ofMinutes(3))
|
||||||
|
.header("Authorization", "Bearer $accessToken")
|
||||||
|
.GET()
|
||||||
|
.build()
|
||||||
|
val response = client.send(request, HttpResponse.BodyHandlers.ofFile(temp.toPath()))
|
||||||
|
if (response.statusCode() !in 200..299) {
|
||||||
|
temp.delete()
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
Files.move(temp.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteAllFiles(accessToken: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
getFiles(accessToken).forEach { file ->
|
||||||
|
deleteDriveFile(accessToken, file.id)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteDriveFile(accessToken: String, fileId: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val request = HttpRequest.newBuilder(
|
||||||
|
URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}")
|
||||||
|
)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.header("Authorization", "Bearer $accessToken")
|
||||||
|
.DELETE()
|
||||||
|
.build()
|
||||||
|
val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
|
||||||
|
response.statusCode() in 200..299 || response.statusCode() == 404
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun uploadNamedFile(
|
||||||
|
accessToken: String,
|
||||||
|
fileName: String,
|
||||||
|
file: File,
|
||||||
|
contentType: String
|
||||||
|
): DesktopDriveFile? = withContext(Dispatchers.IO) {
|
||||||
|
if (!file.isFile) return@withContext null
|
||||||
|
val existingFiles = listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false")
|
||||||
|
existingFiles.drop(1).forEach { duplicate -> deleteDriveFile(accessToken, duplicate.id) }
|
||||||
|
val existingFileId = existingFiles.firstOrNull()?.id
|
||||||
|
val boundary = "episteme_${UUID.randomUUID().toString().replace("-", "")}"
|
||||||
|
val metadata = buildJsonObject {
|
||||||
|
put("name", JsonPrimitive(fileName))
|
||||||
|
if (existingFileId == null) {
|
||||||
|
put("parents", JsonArray(listOf(JsonPrimitive("appDataFolder"))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val prefix = buildString {
|
||||||
|
append("--")
|
||||||
|
append(boundary)
|
||||||
|
append("\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n")
|
||||||
|
append(DesktopCloudJson.encodeToString(JsonElement.serializer(), metadata))
|
||||||
|
append("\r\n--")
|
||||||
|
append(boundary)
|
||||||
|
append("\r\nContent-Type: ")
|
||||||
|
append(contentType)
|
||||||
|
append("\r\n\r\n")
|
||||||
|
}.toByteArray(Charsets.UTF_8)
|
||||||
|
val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8)
|
||||||
|
val uploadUri = if (existingFileId == null) {
|
||||||
|
URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
|
||||||
|
} else {
|
||||||
|
URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
|
||||||
|
}
|
||||||
|
val request = HttpRequest.newBuilder(uploadUri)
|
||||||
|
.timeout(Duration.ofMinutes(5))
|
||||||
|
.header("Authorization", "Bearer $accessToken")
|
||||||
|
.header("Content-Type", "multipart/related; boundary=$boundary")
|
||||||
|
.method(
|
||||||
|
if (existingFileId == null) "POST" else "PATCH",
|
||||||
|
HttpRequest.BodyPublishers.ofInputStream {
|
||||||
|
sequenceInputStream(
|
||||||
|
ByteArrayInputStream(prefix),
|
||||||
|
file.inputStream(),
|
||||||
|
ByteArrayInputStream(suffix)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
|
||||||
|
if (response.statusCode() !in 200..299) return@withContext null
|
||||||
|
val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject
|
||||||
|
DesktopDriveFile(
|
||||||
|
id = root.string("id").orEmpty(),
|
||||||
|
name = root.string("name").orEmpty(),
|
||||||
|
modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listFiles(accessToken: String, query: String?): List<DesktopDriveFile> {
|
||||||
|
val params = buildList {
|
||||||
|
add("spaces" to "appDataFolder")
|
||||||
|
add("fields" to "files(id,name,modifiedTime)")
|
||||||
|
if (!query.isNullOrBlank()) add("q" to query)
|
||||||
|
}
|
||||||
|
val request = HttpRequest.newBuilder(
|
||||||
|
URI.create("https://www.googleapis.com/drive/v3/files?${query(params)}")
|
||||||
|
)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.header("Authorization", "Bearer $accessToken")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.GET()
|
||||||
|
.build()
|
||||||
|
val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
|
||||||
|
if (response.statusCode() !in 200..299) {
|
||||||
|
throw IllegalStateException("Google Drive HTTP ${response.statusCode()}: ${response.body().take(240)}")
|
||||||
|
}
|
||||||
|
val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject
|
||||||
|
return root["files"]?.jsonArrayOrNull().orEmpty().mapNotNull { element ->
|
||||||
|
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||||
|
val id = obj.string("id") ?: return@mapNotNull null
|
||||||
|
val name = obj.string("name") ?: return@mapNotNull null
|
||||||
|
DesktopDriveFile(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDriveModifiedTimeMillis(value: String?): Long {
|
||||||
|
if (value.isNullOrBlank()) return 0L
|
||||||
|
return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DesktopFirestoreDocument(
|
||||||
|
val id: String,
|
||||||
|
val fields: JsonObject?
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DesktopCloudJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
prettyPrint = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDesktopCloudHttpClient(): HttpClient {
|
||||||
|
return HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DesktopCloudBookMetadata.toFirestoreFields(): Map<String, JsonElement> = mapOf(
|
||||||
|
"bookId" to firestoreString(bookId),
|
||||||
|
"title" to firestoreNullableString(title),
|
||||||
|
"author" to firestoreNullableString(author),
|
||||||
|
"displayName" to firestoreString(displayName),
|
||||||
|
"type" to firestoreString(type),
|
||||||
|
"lastPositionCfi" to firestoreNullableString(lastPositionCfi),
|
||||||
|
"lastChapterIndex" to firestoreNullableInt(lastChapterIndex),
|
||||||
|
"locatorBlockIndex" to firestoreNullableInt(locatorBlockIndex),
|
||||||
|
"locatorCharOffset" to firestoreNullableInt(locatorCharOffset),
|
||||||
|
"lastPage" to firestoreNullableInt(lastPage),
|
||||||
|
"progressPercentage" to firestoreNullableFloat(progressPercentage),
|
||||||
|
"isRecent" to firestoreBoolean(isRecent),
|
||||||
|
"isDeleted" to firestoreBoolean(isDeleted),
|
||||||
|
"lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
|
||||||
|
"readingPositionModifiedTimestamp" to firestoreLong(readingPositionModifiedTimestamp),
|
||||||
|
"annotationModifiedTimestamp" to firestoreLong(annotationModifiedTimestamp),
|
||||||
|
"bookmarksJson" to firestoreNullableString(bookmarksJson),
|
||||||
|
"hasAnnotations" to firestoreBoolean(hasAnnotations),
|
||||||
|
"fileContentModifiedTimestamp" to firestoreLong(fileContentModifiedTimestamp),
|
||||||
|
"customName" to firestoreNullableString(customName),
|
||||||
|
"highlightsJson" to firestoreNullableString(highlightsJson),
|
||||||
|
"seriesName" to firestoreNullableString(seriesName),
|
||||||
|
"seriesIndex" to firestoreNullableDouble(seriesIndex),
|
||||||
|
"description" to firestoreNullableString(description),
|
||||||
|
"originalTitle" to firestoreNullableString(originalTitle),
|
||||||
|
"originalAuthor" to firestoreNullableString(originalAuthor),
|
||||||
|
"originalSeriesName" to firestoreNullableString(originalSeriesName),
|
||||||
|
"originalSeriesIndex" to firestoreNullableDouble(originalSeriesIndex),
|
||||||
|
"originalDescription" to firestoreNullableString(originalDescription)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun DesktopCloudShelfMetadata.toFirestoreFields(): Map<String, JsonElement> = mapOf(
|
||||||
|
"name" to firestoreString(name),
|
||||||
|
"bookIds" to firestoreStringArray(bookIds),
|
||||||
|
"lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
|
||||||
|
"isDeleted" to firestoreBoolean(isDeleted)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun DesktopCloudFontMetadata.toFirestoreFields(): Map<String, JsonElement> = mapOf(
|
||||||
|
"id" to firestoreString(id),
|
||||||
|
"displayName" to firestoreString(displayName),
|
||||||
|
"fileName" to firestoreString(fileName),
|
||||||
|
"fileExtension" to firestoreString(fileExtension),
|
||||||
|
"timestamp" to firestoreLong(timestamp),
|
||||||
|
"isDeleted" to firestoreBoolean(isDeleted)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JsonObject.toBookMetadata(documentId: String): DesktopCloudBookMetadata? {
|
||||||
|
val bookId = stringField("bookId") ?: documentId.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return DesktopCloudBookMetadata(
|
||||||
|
bookId = bookId,
|
||||||
|
title = stringField("title"),
|
||||||
|
author = stringField("author"),
|
||||||
|
displayName = stringField("displayName").orEmpty(),
|
||||||
|
type = stringField("type").orEmpty(),
|
||||||
|
lastPositionCfi = stringField("lastPositionCfi"),
|
||||||
|
lastChapterIndex = intField("lastChapterIndex"),
|
||||||
|
locatorBlockIndex = intField("locatorBlockIndex"),
|
||||||
|
locatorCharOffset = intField("locatorCharOffset"),
|
||||||
|
lastPage = intField("lastPage"),
|
||||||
|
progressPercentage = doubleField("progressPercentage")?.toFloat(),
|
||||||
|
isRecent = booleanField("isRecent") ?: true,
|
||||||
|
isDeleted = booleanField("isDeleted") ?: false,
|
||||||
|
lastModifiedTimestamp = longField("lastModifiedTimestamp"),
|
||||||
|
readingPositionModifiedTimestamp = longField("readingPositionModifiedTimestamp"),
|
||||||
|
annotationModifiedTimestamp = longField("annotationModifiedTimestamp"),
|
||||||
|
bookmarksJson = stringField("bookmarksJson"),
|
||||||
|
hasAnnotations = booleanField("hasAnnotations") ?: false,
|
||||||
|
fileContentModifiedTimestamp = longField("fileContentModifiedTimestamp"),
|
||||||
|
customName = stringField("customName"),
|
||||||
|
highlightsJson = stringField("highlightsJson"),
|
||||||
|
seriesName = stringField("seriesName"),
|
||||||
|
seriesIndex = doubleField("seriesIndex"),
|
||||||
|
description = stringField("description"),
|
||||||
|
originalTitle = stringField("originalTitle"),
|
||||||
|
originalAuthor = stringField("originalAuthor"),
|
||||||
|
originalSeriesName = stringField("originalSeriesName"),
|
||||||
|
originalSeriesIndex = doubleField("originalSeriesIndex"),
|
||||||
|
originalDescription = stringField("originalDescription")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.toShelfMetadata(documentId: String): DesktopCloudShelfMetadata? {
|
||||||
|
val name = stringField("name") ?: documentId.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return DesktopCloudShelfMetadata(
|
||||||
|
name = name,
|
||||||
|
bookIds = stringArrayField("bookIds"),
|
||||||
|
lastModifiedTimestamp = longField("lastModifiedTimestamp"),
|
||||||
|
isDeleted = booleanField("isDeleted") ?: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.toFontMetadata(documentId: String): DesktopCloudFontMetadata? {
|
||||||
|
val id = stringField("id") ?: documentId.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return DesktopCloudFontMetadata(
|
||||||
|
id = id,
|
||||||
|
displayName = stringField("displayName").orEmpty(),
|
||||||
|
fileName = stringField("fileName").orEmpty(),
|
||||||
|
fileExtension = stringField("fileExtension").orEmpty(),
|
||||||
|
timestamp = longField("timestamp"),
|
||||||
|
isDeleted = booleanField("isDeleted") ?: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreString(value: String): JsonElement = JsonObject(mapOf("stringValue" to JsonPrimitive(value)))
|
||||||
|
|
||||||
|
private fun firestoreNullableString(value: String?): JsonElement {
|
||||||
|
return value?.let(::firestoreString) ?: firestoreNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreNullableInt(value: Int?): JsonElement {
|
||||||
|
return value?.let { JsonObject(mapOf("integerValue" to JsonPrimitive(it.toString()))) } ?: firestoreNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreLong(value: Long): JsonElement = JsonObject(mapOf("integerValue" to JsonPrimitive(value.toString())))
|
||||||
|
|
||||||
|
private fun firestoreNullableFloat(value: Float?): JsonElement {
|
||||||
|
return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it.toDouble()))) } ?: firestoreNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreNullableDouble(value: Double?): JsonElement {
|
||||||
|
return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it))) } ?: firestoreNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreBoolean(value: Boolean): JsonElement = JsonObject(mapOf("booleanValue" to JsonPrimitive(value)))
|
||||||
|
|
||||||
|
private fun firestoreStringArray(values: List<String>): JsonElement {
|
||||||
|
return JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"arrayValue" to JsonObject(
|
||||||
|
mapOf("values" to JsonArray(values.map(::firestoreString)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firestoreNull(): JsonElement = JsonObject(mapOf("nullValue" to JsonPrimitive("NULL_VALUE")))
|
||||||
|
|
||||||
|
private fun JsonObject.stringField(key: String): String? {
|
||||||
|
return field(key)?.get("stringValue")?.jsonPrimitive?.contentOrNull
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.booleanField(key: String): Boolean? {
|
||||||
|
return field(key)?.get("booleanValue")?.jsonPrimitive?.booleanOrNull
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.longField(key: String): Long {
|
||||||
|
val field = field(key) ?: return 0L
|
||||||
|
return field["integerValue"]?.jsonPrimitive?.longOrNull
|
||||||
|
?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toLong()
|
||||||
|
?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.intField(key: String): Int? {
|
||||||
|
val field = field(key) ?: return null
|
||||||
|
return field["integerValue"]?.jsonPrimitive?.intOrNull
|
||||||
|
?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.doubleField(key: String): Double? {
|
||||||
|
val field = field(key) ?: return null
|
||||||
|
return field["doubleValue"]?.jsonPrimitive?.doubleOrNull
|
||||||
|
?: field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.stringArrayField(key: String): List<String> {
|
||||||
|
val values = field(key)
|
||||||
|
?.get("arrayValue")
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("values")
|
||||||
|
?.jsonArrayOrNull()
|
||||||
|
.orEmpty()
|
||||||
|
return values.mapNotNull { it.jsonObjectOrNull()?.get("stringValue")?.jsonPrimitive?.contentOrNull }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.field(key: String): JsonObject? = this[key]?.jsonObjectOrNull()
|
||||||
|
|
||||||
|
private fun JsonElement?.jsonObjectOrNull(): JsonObject? {
|
||||||
|
if (this == null || this is JsonNull) return null
|
||||||
|
return runCatching { jsonObject }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.jsonArrayOrNull(): JsonArray? {
|
||||||
|
if (this == null || this is JsonNull) return null
|
||||||
|
return runCatching { jsonArray }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
|
||||||
|
|
||||||
|
private fun query(vararg pairs: Pair<String, String>): String = query(pairs.asIterable())
|
||||||
|
|
||||||
|
private fun query(pairs: Iterable<Pair<String, String>>): String {
|
||||||
|
return pairs.joinToString("&") { (key, value) -> "${formEncode(key)}=${formEncode(value)}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
|
||||||
|
|
||||||
|
private fun pathEncode(value: String): String = formEncode(value).replace("+", "%20")
|
||||||
|
|
||||||
|
private fun driveQueryStringValue(value: String): String {
|
||||||
|
return value.replace("\\", "\\\\").replace("'", "\\'")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sequenceInputStream(vararg streams: InputStream): SequenceInputStream {
|
||||||
|
return SequenceInputStream(Collections.enumeration(streams.toList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HttpRequest.Builder.copyMethodAndBodyFrom(source: HttpRequest): HttpRequest.Builder {
|
||||||
|
val publisher = source.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody())
|
||||||
|
return method(source.method(), publisher)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.BookItem
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer
|
||||||
|
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||||
|
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal object DesktopCloudSidecarSync {
|
||||||
|
fun localAnnotationDebugSummary(book: BookItem): String {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return "path=null type=${book.type}"
|
||||||
|
if (book.type != FileType.PDF) return "path=${path.logPreview(140)} type=${book.type}"
|
||||||
|
val annotationFile = desktopPdfAnnotationFile(path)
|
||||||
|
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
|
||||||
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||||
|
val richTextFile = desktopPdfRichTextFile(path)
|
||||||
|
return "path=${path.logPreview(140)} " +
|
||||||
|
"annotations{exists=${annotationFile.isFile} syncable=${annotationFile.hasSyncablePdfAnnotations()} " +
|
||||||
|
"bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFile()}} " +
|
||||||
|
"deletedAnnotations{exists=${deletedAnnotationFile.isFile} count=${deletedAnnotationFile.annotationDeletionCount()} " +
|
||||||
|
"bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModifiedIfFile()}} " +
|
||||||
|
"bookmarks{exists=${bookmarkFile.isFile} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModifiedIfFile()}} " +
|
||||||
|
"text{exists=${richTextFile.isFile} syncable=${richTextFile.hasSyncablePdfRichText()} " +
|
||||||
|
"bytes=${richTextFile.length()} ts=${richTextFile.lastModifiedIfFile()}} " +
|
||||||
|
"payloadTs=${localAnnotationPayloadTimestamp(book)} totalTs=${localAnnotationTimestamp(book)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasLocalAnnotationData(book: BookItem): Boolean {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
|
||||||
|
if (book.type != FileType.PDF) return false
|
||||||
|
return desktopPdfAnnotationFile(path).hasSyncablePdfAnnotations() ||
|
||||||
|
desktopPdfAnnotationDeletionFile(path).hasSyncablePdfAnnotationDeletions() ||
|
||||||
|
desktopPdfBookmarkFile(path).isFile ||
|
||||||
|
desktopPdfRichTextFile(path).hasSyncablePdfRichText()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun localAnnotationTimestamp(book: BookItem): Long {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
|
||||||
|
if (book.type != FileType.PDF) return 0L
|
||||||
|
return maxOf(
|
||||||
|
localAnnotationPayloadTimestamp(path),
|
||||||
|
desktopPdfBookmarkFile(path).lastModifiedIfFile()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun localAnnotationPayloadTimestamp(book: BookItem): Long {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
|
||||||
|
if (book.type != FileType.PDF) return 0L
|
||||||
|
return localAnnotationPayloadTimestamp(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAnnotationPayloadSynced(book: BookItem, timestamp: Long) {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
if (book.type != FileType.PDF || timestamp <= 0L) return
|
||||||
|
listOf(desktopPdfAnnotationFile(path), desktopPdfAnnotationDeletionFile(path), desktopPdfRichTextFile(path))
|
||||||
|
.filter { it.isFile }
|
||||||
|
.forEach { it.setLastModified(timestamp) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordAnnotationDeletions(book: BookItem, annotationIds: Collection<String>) {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
if (book.type != FileType.PDF) return
|
||||||
|
recordAnnotationDeletions(path, book.id, annotationIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection<String>) {
|
||||||
|
val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet()
|
||||||
|
if (ids.isEmpty()) return
|
||||||
|
val file = desktopPdfAnnotationDeletionFile(documentPath)
|
||||||
|
val existing = if (file.isFile) {
|
||||||
|
SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val next = existing.toMutableMap()
|
||||||
|
ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) }
|
||||||
|
val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next)
|
||||||
|
if (file.isFile && file.readText() == nextJson) return
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeText(nextJson)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " +
|
||||||
|
"bytes=${file.length()} ts=${file.lastModified()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportAnnotationBundle(book: BookItem): File? {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
if (book.type != FileType.PDF) return null
|
||||||
|
val annotationFile = desktopPdfAnnotationFile(path)
|
||||||
|
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
|
||||||
|
val richTextFile = desktopPdfRichTextFile(path)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}"
|
||||||
|
}
|
||||||
|
val data = buildMap {
|
||||||
|
if (annotationFile.isFile) {
|
||||||
|
desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations ->
|
||||||
|
put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) {
|
||||||
|
val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText())
|
||||||
|
put(
|
||||||
|
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
|
||||||
|
SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (richTextFile.isFile) {
|
||||||
|
desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data)
|
||||||
|
val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(
|
||||||
|
cloudSidecarJson.encodeToString(JsonElement.serializer(), payload)
|
||||||
|
)
|
||||||
|
val tempFile = File(
|
||||||
|
desktopUserCacheRoot(),
|
||||||
|
"sync_bundle_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json"
|
||||||
|
)
|
||||||
|
tempFile.parentFile?.mkdirs()
|
||||||
|
tempFile.writeText(canonical)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " +
|
||||||
|
"canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}"
|
||||||
|
}
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: run {
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (book.type != FileType.PDF) {
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run {
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val data = root["data"]?.jsonObjectOrNull() ?: root
|
||||||
|
val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data)
|
||||||
|
val annotationFile = desktopPdfAnnotationFile(path)
|
||||||
|
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
|
||||||
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||||
|
val richTextFile = desktopPdfRichTextFile(path)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " +
|
||||||
|
"rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " +
|
||||||
|
localAnnotationDebugSummary(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canonicalData.hasPdfAnnotationPayload()) {
|
||||||
|
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData)
|
||||||
|
if (annotations.isEmpty()) {
|
||||||
|
if (annotationFile.isFile) {
|
||||||
|
val deleted = annotationFile.delete()
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" }
|
||||||
|
} else {
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
annotationFile.parentFile?.mkdirs()
|
||||||
|
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||||
|
annotationFile.setLastModified(timestamp)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.import.write_annotations book=${book.id} count=${annotations.size} " +
|
||||||
|
"bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (annotationFile.isFile) {
|
||||||
|
val deleted = annotationFile.delete()
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" }
|
||||||
|
} else {
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData)
|
||||||
|
if (deletions.isEmpty()) {
|
||||||
|
if (deletedAnnotationFile.isFile) {
|
||||||
|
val deleted = deletedAnnotationFile.delete()
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deletedAnnotationFile.parentFile?.mkdirs()
|
||||||
|
deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions))
|
||||||
|
deletedAnnotationFile.setLastModified(timestamp)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " +
|
||||||
|
"bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canonicalData["bookmarks"]?.let { bookmarks ->
|
||||||
|
bookmarkFile.parentFile?.mkdirs()
|
||||||
|
bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks))
|
||||||
|
bookmarkFile.setLastModified(timestamp)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canonicalData["text"]?.let { richText ->
|
||||||
|
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
|
||||||
|
if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
|
||||||
|
if (richTextFile.isFile) {
|
||||||
|
val deleted = richTextFile.delete()
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" }
|
||||||
|
} else {
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
richTextFile.parentFile?.mkdirs()
|
||||||
|
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||||
|
richTextFile.setLastModified(timestamp)
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " +
|
||||||
|
"spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: run {
|
||||||
|
if (richTextFile.isFile) {
|
||||||
|
val deleted = richTextFile.delete()
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" }
|
||||||
|
} else {
|
||||||
|
logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logDesktopCloudAnnotations {
|
||||||
|
"desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}"
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localAnnotationPayloadTimestamp(path: String): Long {
|
||||||
|
return maxOf(
|
||||||
|
desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(),
|
||||||
|
desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(),
|
||||||
|
desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cloudSidecarJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Json.parseElementOrNull(raw: String): JsonElement? {
|
||||||
|
return runCatching { parseToJsonElement(raw) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||||
|
if (this is JsonNull) return null
|
||||||
|
return runCatching { jsonObject }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
|
||||||
|
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfFile(): Long {
|
||||||
|
return if (isFile) lastModified() else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.hasSyncablePdfAnnotations(): Boolean {
|
||||||
|
return isFile && desktopPdfAnnotationElementForSync(readText()) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfSyncableAnnotations(): Long {
|
||||||
|
return if (hasSyncablePdfAnnotations()) lastModified() else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.annotationDeletionCount(): Int {
|
||||||
|
return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.hasSyncablePdfAnnotationDeletions(): Boolean {
|
||||||
|
return annotationDeletionCount() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long {
|
||||||
|
return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.hasSyncablePdfRichText(): Boolean {
|
||||||
|
return isFile && desktopPdfRichTextElementForSync(readText()) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfSyncableRichText(): Long {
|
||||||
|
return if (hasSyncablePdfRichText()) lastModified() else 0L
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,82 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.BookItem
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
|
||||||
|
internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync"
|
||||||
|
internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations"
|
||||||
|
|
||||||
|
internal fun logDesktopCloudSync(message: () -> String) {
|
||||||
|
logDesktopDiagnostic(DesktopCloudSyncLogTag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logDesktopCloudAnnotations(message: () -> String) {
|
||||||
|
logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String {
|
||||||
|
val position = readerPosition
|
||||||
|
val page = if (type.usesCloudLocatorForDiagnostics()) {
|
||||||
|
position?.pageIndex ?: lastPageIndex
|
||||||
|
} else {
|
||||||
|
lastPageIndex
|
||||||
|
}
|
||||||
|
return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
|
||||||
|
"contentTs=$fileContentModifiedTimestamp " +
|
||||||
|
"page=$page chapter=${position?.chapterIndex} " +
|
||||||
|
"block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " +
|
||||||
|
"cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " +
|
||||||
|
"bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String {
|
||||||
|
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
|
||||||
|
"annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
|
||||||
|
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
|
||||||
|
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " +
|
||||||
|
"recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
|
||||||
|
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean {
|
||||||
|
val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex
|
||||||
|
val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) {
|
||||||
|
other.readerPosition?.pageIndex ?: other.lastPageIndex
|
||||||
|
} else {
|
||||||
|
other.lastPageIndex
|
||||||
|
}
|
||||||
|
val thisProgress = progressPercentage
|
||||||
|
val otherProgress = other.progressPercentage
|
||||||
|
val progressMatches = when {
|
||||||
|
thisProgress == null && otherProgress == null -> true
|
||||||
|
thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) {
|
||||||
|
readerPosition == other.readerPosition
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
return thisPage == otherPage &&
|
||||||
|
locatorMatches &&
|
||||||
|
progressMatches
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun org.dueattendant149.bookreader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean {
|
||||||
|
return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.cloudSyncPreview(maxLength: Int = 80): String {
|
||||||
|
val value = this ?: return "null"
|
||||||
|
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.cloudSyncAnnotationSummary(): String {
|
||||||
|
val value = this?.trim() ?: return "null"
|
||||||
|
return when {
|
||||||
|
value.isEmpty() -> "blank"
|
||||||
|
value == "[]" -> "empty"
|
||||||
|
else -> "present(${value.length})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
internal data class DesktopCloudSyncSettings(
|
||||||
|
val isSyncEnabled: Boolean = false,
|
||||||
|
val isFolderSyncEnabled: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class DesktopCloudSyncSettingsStore(
|
||||||
|
private val settingsFile: File = File(desktopUserConfigRoot(), "cloud-sync.properties")
|
||||||
|
) {
|
||||||
|
fun load(): DesktopCloudSyncSettings {
|
||||||
|
if (!settingsFile.isFile) return DesktopCloudSyncSettings()
|
||||||
|
val properties = Properties()
|
||||||
|
return runCatching {
|
||||||
|
settingsFile.inputStream().use(properties::load)
|
||||||
|
DesktopCloudSyncSettings(
|
||||||
|
isSyncEnabled = properties.getProperty("syncEnabled", "false").toBooleanStrictOrNull() == true,
|
||||||
|
isFolderSyncEnabled = properties.getProperty("folderSyncEnabled", "false").toBooleanStrictOrNull() == true
|
||||||
|
)
|
||||||
|
}.getOrDefault(DesktopCloudSyncSettings())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(settings: DesktopCloudSyncSettings) {
|
||||||
|
settingsFile.parentFile?.mkdirs()
|
||||||
|
val properties = Properties().apply {
|
||||||
|
setProperty("syncEnabled", settings.isSyncEnabled.toString())
|
||||||
|
setProperty("folderSyncEnabled", settings.isFolderSyncEnabled.toString())
|
||||||
|
}
|
||||||
|
settingsFile.outputStream().use { output ->
|
||||||
|
properties.store(output, "Episteme desktop cloud sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopInstallationIdStore(
|
||||||
|
private val settingsFile: File = File(desktopUserConfigRoot(), "installation.properties")
|
||||||
|
) {
|
||||||
|
fun getOrCreateId(): String {
|
||||||
|
val properties = Properties()
|
||||||
|
val existing = runCatching {
|
||||||
|
if (!settingsFile.isFile) return@runCatching null
|
||||||
|
settingsFile.inputStream().use(properties::load)
|
||||||
|
properties.getProperty("installationId")?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
if (existing != null) return existing
|
||||||
|
|
||||||
|
val generated = UUID.randomUUID().toString()
|
||||||
|
settingsFile.parentFile?.mkdirs()
|
||||||
|
settingsFile.outputStream().use { output ->
|
||||||
|
Properties().apply {
|
||||||
|
setProperty("installationId", generated)
|
||||||
|
}.store(output, "Episteme desktop installation")
|
||||||
|
}
|
||||||
|
return generated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,614 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import org.dueattendant149.bookreader.shared.opds.OpdsCatalog
|
||||||
|
import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference
|
||||||
|
import com.sun.jna.Library
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.ptr.PointerByReference
|
||||||
|
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||||
|
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||||
|
import java.awt.Color
|
||||||
|
import java.awt.Font
|
||||||
|
import java.awt.RenderingHints
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal object DesktopComicArchive {
|
||||||
|
private val comicTypes = SharedFileCapabilities.comicArchiveTypes
|
||||||
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||||
|
|
||||||
|
fun canLoad(type: FileType): Boolean = type in comicTypes
|
||||||
|
|
||||||
|
fun load(file: File, type: FileType): DesktopComicDocument {
|
||||||
|
require(file.isFile) { "Missing comic archive: ${file.absolutePath}" }
|
||||||
|
require(canLoad(type)) { "${type.name} is not a comic archive type." }
|
||||||
|
return when (type) {
|
||||||
|
FileType.CBZ -> loadZip(file)
|
||||||
|
FileType.CBR -> loadRar(file)
|
||||||
|
FileType.CB7 -> loadSevenZ(file)
|
||||||
|
FileType.CBT -> loadTar(file)
|
||||||
|
else -> error("${type.name} is not a comic archive type.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadOpdsStream(
|
||||||
|
path: String,
|
||||||
|
title: String,
|
||||||
|
reference: OpdsStreamReference,
|
||||||
|
catalog: OpdsCatalog?
|
||||||
|
): DesktopComicDocument {
|
||||||
|
val cacheDir = File(
|
||||||
|
DesktopLibraryDatabase.defaultDatabaseFile().parentFile,
|
||||||
|
"opds_stream_cache/${reference.id.hashCode()}"
|
||||||
|
).apply { mkdirs() }
|
||||||
|
val pages = (0 until reference.count).map { pageIndex ->
|
||||||
|
DesktopComicPage(
|
||||||
|
name = "opds_${pageIndex + 1}.jpg",
|
||||||
|
width = 800,
|
||||||
|
height = 1200,
|
||||||
|
source = OpdsStreamComicPageSource(
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
urlTemplate = reference.urlTemplate,
|
||||||
|
catalog = catalog,
|
||||||
|
cacheDir = cacheDir
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DesktopComicDocument(
|
||||||
|
path = path,
|
||||||
|
title = title,
|
||||||
|
pages = pages,
|
||||||
|
closeAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadZip(file: File): DesktopComicDocument {
|
||||||
|
val zip = ZipFile(file)
|
||||||
|
return try {
|
||||||
|
val pages = zip.entries()
|
||||||
|
.asSequence()
|
||||||
|
.filter { entry -> !entry.isDirectory && entry.name.isComicImageName() }
|
||||||
|
.sortedBy { entry -> entry.name.comicSortKey() }
|
||||||
|
.mapNotNull { entry ->
|
||||||
|
val bytes = zip.getInputStream(entry).use { it.readBytes() }
|
||||||
|
val size = decodeImageSize(bytes) ?: return@mapNotNull null
|
||||||
|
DesktopComicPage(
|
||||||
|
name = entry.name,
|
||||||
|
width = size.first,
|
||||||
|
height = size.second,
|
||||||
|
source = ZipComicPageSource(zip, entry.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||||
|
DesktopComicDocument(
|
||||||
|
path = file.absolutePath,
|
||||||
|
title = file.nameWithoutExtension,
|
||||||
|
pages = pages,
|
||||||
|
closeAction = { zip.close() }
|
||||||
|
)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
runCatching { zip.close() }
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRar(file: File): DesktopComicDocument {
|
||||||
|
val nativeResult = runCatching { loadRarWithNativeLibarchive(file) }
|
||||||
|
if (nativeResult.isSuccess) return nativeResult.getOrThrow()
|
||||||
|
|
||||||
|
return runCatching { loadWithArchiveCommand(file) }
|
||||||
|
.getOrElse { commandError ->
|
||||||
|
error(
|
||||||
|
"Could not open CBR with libarchive. " +
|
||||||
|
"Bundle libarchive for this desktop platform, or keep tar/bsdtar available on PATH. " +
|
||||||
|
"Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
|
||||||
|
"Command: ${commandError.shortMessage()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRarWithNativeLibarchive(file: File): DesktopComicDocument {
|
||||||
|
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||||
|
return try {
|
||||||
|
val extracted = DesktopLibarchive.extractImagePages(file, tempDir, imageExtensions)
|
||||||
|
documentFromExtracted(file, extracted, tempDir)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
runCatching { tempDir.deleteRecursively() }
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSevenZ(file: File): DesktopComicDocument {
|
||||||
|
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||||
|
val commonsResult = runCatching {
|
||||||
|
val extracted = mutableListOf<ExtractedComicPage>()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
SevenZFile(file).use { archive ->
|
||||||
|
var entry = archive.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val name = entry.name.orEmpty()
|
||||||
|
if (!entry.isDirectory && name.isComicImageName()) {
|
||||||
|
val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}")
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
archive.copyCurrentEntryTo(output)
|
||||||
|
}
|
||||||
|
extracted += ExtractedComicPage(name = name, file = target)
|
||||||
|
}
|
||||||
|
entry = archive.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentFromExtracted(file, extracted, tempDir)
|
||||||
|
}
|
||||||
|
if (commonsResult.isSuccess) return commonsResult.getOrThrow()
|
||||||
|
|
||||||
|
runCatching { tempDir.deleteRecursively() }
|
||||||
|
return runCatching { loadWithArchiveCommand(file) }
|
||||||
|
.getOrElse { commandError ->
|
||||||
|
error(
|
||||||
|
"Could not open CB7 with Commons Compress or system tar/bsdtar. " +
|
||||||
|
"Commons: ${commonsResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
|
||||||
|
"Command: ${commandError.shortMessage()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadTar(file: File): DesktopComicDocument {
|
||||||
|
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||||
|
return try {
|
||||||
|
val extracted = mutableListOf<ExtractedComicPage>()
|
||||||
|
TarArchiveInputStream(file.inputStream().buffered()).use { archive ->
|
||||||
|
var entry = archive.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val name = entry.name.orEmpty()
|
||||||
|
if (!entry.isDirectory && name.isComicImageName()) {
|
||||||
|
val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}")
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
archive.copyTo(output)
|
||||||
|
}
|
||||||
|
extracted += ExtractedComicPage(name = name, file = target)
|
||||||
|
}
|
||||||
|
entry = archive.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentFromExtracted(file, extracted, tempDir)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
runCatching { tempDir.deleteRecursively() }
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
|
||||||
|
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||||
|
return try {
|
||||||
|
val extracted = DesktopArchiveCommand.extractImagePages(file, tempDir, imageExtensions)
|
||||||
|
documentFromExtracted(file, extracted, tempDir)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
runCatching { tempDir.deleteRecursively() }
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun documentFromExtracted(
|
||||||
|
file: File,
|
||||||
|
extracted: List<ExtractedComicPage>,
|
||||||
|
tempDir: File
|
||||||
|
): DesktopComicDocument {
|
||||||
|
val pages = extracted
|
||||||
|
.sortedBy { page -> page.name.comicSortKey() }
|
||||||
|
.mapNotNull { page ->
|
||||||
|
val bytes = page.file.readBytes()
|
||||||
|
val size = decodeImageSize(bytes) ?: return@mapNotNull null
|
||||||
|
DesktopComicPage(
|
||||||
|
name = page.name,
|
||||||
|
width = size.first,
|
||||||
|
height = size.second,
|
||||||
|
source = FileComicPageSource(page.file)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||||
|
return DesktopComicDocument(
|
||||||
|
path = file.absolutePath,
|
||||||
|
title = file.nameWithoutExtension,
|
||||||
|
pages = pages,
|
||||||
|
closeAction = { tempDir.deleteRecursively() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SevenZFile.copyCurrentEntryTo(output: OutputStream) {
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val read = read(buffer, 0, buffer.size)
|
||||||
|
if (read < 0) break
|
||||||
|
if (read > 0) output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeImageSize(bytes: ByteArray): Pair<Int, Int>? {
|
||||||
|
val image = ByteArrayInputStream(bytes).use { input ->
|
||||||
|
ImageIO.read(input)
|
||||||
|
} ?: return null
|
||||||
|
return image.width.coerceAtLeast(1) to image.height.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.isComicImageName(): Boolean {
|
||||||
|
return imageExtension() in imageExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.imageExtension(): String {
|
||||||
|
return substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "img")
|
||||||
|
.lowercase()
|
||||||
|
.takeIf { it in imageExtensions }
|
||||||
|
?: "img"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.comicSortKey(): String {
|
||||||
|
return replace('\\', '/').lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Throwable.shortMessage(): String {
|
||||||
|
return message
|
||||||
|
?.replace(Regex("\\s+"), " ")
|
||||||
|
?.take(240)
|
||||||
|
?.ifBlank { null }
|
||||||
|
?: javaClass.simpleName
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ExtractedComicPage(
|
||||||
|
val name: String,
|
||||||
|
val file: File
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopComicDocument(
|
||||||
|
val path: String,
|
||||||
|
val title: String,
|
||||||
|
pages: List<DesktopComicPage>,
|
||||||
|
private val closeAction: () -> Unit
|
||||||
|
) {
|
||||||
|
private val pages = pages.toList()
|
||||||
|
|
||||||
|
val pageCount: Int = pages.size
|
||||||
|
val pageSizes: List<DesktopPdfPageSize> = pages.map { page ->
|
||||||
|
DesktopPdfPageSize(page.width.toFloat(), page.height.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage {
|
||||||
|
val page = pages.getOrNull(pageIndex) ?: error("Invalid comic page index $pageIndex.")
|
||||||
|
val sourceImage = ByteArrayInputStream(page.source.readBytes()).use { input ->
|
||||||
|
ImageIO.read(input)
|
||||||
|
} ?: error("Could not decode comic page ${pageIndex + 1}.")
|
||||||
|
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
val sourceWidth = sourceImage.width.coerceAtLeast(1)
|
||||||
|
val sourceHeight = sourceImage.height.coerceAtLeast(1)
|
||||||
|
val width = (sourceWidth * safeScale).roundToInt().coerceAtLeast(1)
|
||||||
|
val height = (sourceHeight * safeScale).roundToInt().coerceAtLeast(1)
|
||||||
|
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val graphics = image.createGraphics()
|
||||||
|
try {
|
||||||
|
graphics.color = Color.WHITE
|
||||||
|
graphics.fillRect(0, 0, width, height)
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||||
|
graphics.drawImage(sourceImage, 0, 0, width, height, null)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
sourceImage.flush()
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
closeAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopComicPage(
|
||||||
|
val name: String,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val source: ComicPageSource
|
||||||
|
)
|
||||||
|
|
||||||
|
internal interface ComicPageSource {
|
||||||
|
fun readBytes(): ByteArray
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ZipComicPageSource(
|
||||||
|
private val zip: ZipFile,
|
||||||
|
private val entryName: String
|
||||||
|
) : ComicPageSource {
|
||||||
|
override fun readBytes(): ByteArray {
|
||||||
|
val entry = zip.getEntry(entryName) ?: error("Missing comic page entry: $entryName")
|
||||||
|
return zip.getInputStream(entry).use { it.readBytes() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FileComicPageSource(
|
||||||
|
private val file: File
|
||||||
|
) : ComicPageSource {
|
||||||
|
override fun readBytes(): ByteArray = file.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OpdsStreamComicPageSource(
|
||||||
|
private val pageIndex: Int,
|
||||||
|
private val urlTemplate: String,
|
||||||
|
private val catalog: OpdsCatalog?,
|
||||||
|
private val cacheDir: File
|
||||||
|
) : ComicPageSource {
|
||||||
|
override fun readBytes(): ByteArray {
|
||||||
|
val cachedFile = File(cacheDir, "page_$pageIndex.jpg")
|
||||||
|
if (cachedFile.isFile && cachedFile.length() > 0L) {
|
||||||
|
return cachedFile.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val bytes = DesktopOpdsHttp.fetchBytes(streamPageUrl(), catalog)
|
||||||
|
if (bytes.isNotEmpty()) {
|
||||||
|
cachedFile.writeBytes(bytes)
|
||||||
|
bytes
|
||||||
|
} else {
|
||||||
|
error("Empty OPDS stream page response.")
|
||||||
|
}
|
||||||
|
}.getOrElse {
|
||||||
|
errorPageBytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamPageUrl(): String {
|
||||||
|
return effectiveTemplate()
|
||||||
|
.replace("{pageNumber}", pageIndex.toString())
|
||||||
|
.replace("{page}", pageIndex.toString())
|
||||||
|
.replace("{maxWidth}", "1600")
|
||||||
|
.replace("{maxHeight}", "2400")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun effectiveTemplate(): String {
|
||||||
|
val catalogUrl = catalog?.url ?: return urlTemplate
|
||||||
|
if (!urlTemplate.startsWith("http", ignoreCase = true)) return urlTemplate
|
||||||
|
return runCatching {
|
||||||
|
val oldUrl = URL(urlTemplate)
|
||||||
|
val newUrl = URL(catalogUrl)
|
||||||
|
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
|
||||||
|
val newBase = "${newUrl.protocol}://${newUrl.authority}"
|
||||||
|
urlTemplate.replace(oldBase, newBase)
|
||||||
|
}.getOrDefault(urlTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorPageBytes(): ByteArray {
|
||||||
|
val image = BufferedImage(800, 1200, BufferedImage.TYPE_INT_RGB)
|
||||||
|
val graphics = image.createGraphics()
|
||||||
|
try {
|
||||||
|
graphics.color = Color.DARK_GRAY
|
||||||
|
graphics.fillRect(0, 0, image.width, image.height)
|
||||||
|
graphics.color = Color.WHITE
|
||||||
|
graphics.font = Font(Font.SANS_SERIF, Font.BOLD, 36)
|
||||||
|
val text = "Page unavailable"
|
||||||
|
val metrics = graphics.fontMetrics
|
||||||
|
graphics.drawString(text, (image.width - metrics.stringWidth(text)) / 2, image.height / 2)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
}
|
||||||
|
return ByteArrayOutputStream().use { output ->
|
||||||
|
ImageIO.write(image, "jpg", output)
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DesktopArchiveCommand {
|
||||||
|
private const val EXTRACT_TIMEOUT_SECONDS = 60L
|
||||||
|
|
||||||
|
fun extractImagePages(
|
||||||
|
file: File,
|
||||||
|
tempDir: File,
|
||||||
|
imageExtensions: Set<String>
|
||||||
|
): List<DesktopComicArchive.ExtractedComicPage> {
|
||||||
|
val command = resolveCommand()
|
||||||
|
?: error("No tar/bsdtar command was found on PATH.")
|
||||||
|
val names = listArchiveEntries(command, file)
|
||||||
|
.filter { name ->
|
||||||
|
val extension = name.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
extension in imageExtensions
|
||||||
|
}
|
||||||
|
.sortedBy { name -> with(DesktopComicArchive) { name.comicSortKey() } }
|
||||||
|
require(names.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||||
|
return names.mapIndexed { index, name ->
|
||||||
|
val extension = name.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "img")
|
||||||
|
.lowercase()
|
||||||
|
.ifBlank { "img" }
|
||||||
|
val target = File(tempDir, "page_$index.$extension")
|
||||||
|
extractEntry(command, file, name, target)
|
||||||
|
DesktopComicArchive.ExtractedComicPage(name = name, file = target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCommand(): String? {
|
||||||
|
val override = System.getProperty("reader.archive.command")
|
||||||
|
?: System.getenv("READER_ARCHIVE_COMMAND")
|
||||||
|
return listOfNotNull(override?.takeIf { it.isNotBlank() }, "bsdtar", "tar")
|
||||||
|
.firstOrNull(::isCommandAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCommandAvailable(command: String): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
val process = ProcessBuilder(command, "--version")
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
process.inputStream.use { it.readBytes() }
|
||||||
|
process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listArchiveEntries(command: String, file: File): List<String> {
|
||||||
|
val process = ProcessBuilder(command, "-tf", file.absolutePath)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
error("$command timed out while listing ${file.name}.")
|
||||||
|
}
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
error(output.ifBlank { "$command could not list ${file.name}." })
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
.lineSequence()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractEntry(command: String, file: File, entryName: String, target: File) {
|
||||||
|
val process = ProcessBuilder(command, "-xOf", file.absolutePath, entryName)
|
||||||
|
.start()
|
||||||
|
val errorText = StringBuilder()
|
||||||
|
val errorThread = Thread {
|
||||||
|
process.errorStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
errorText.append(reader.readText())
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
process.inputStream.use { input ->
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
error("$command timed out while extracting $entryName.")
|
||||||
|
}
|
||||||
|
errorThread.join(1000)
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
error(errorText.toString().ifBlank { "$command could not extract $entryName." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DesktopLibarchive {
|
||||||
|
private const val ARCHIVE_OK = 0
|
||||||
|
private const val ARCHIVE_EOF = 1
|
||||||
|
private const val ARCHIVE_ENTRY_DIRECTORY = 0x4000
|
||||||
|
private const val BUFFER_SIZE = 128 * 1024
|
||||||
|
|
||||||
|
fun extractImagePages(
|
||||||
|
file: File,
|
||||||
|
tempDir: File,
|
||||||
|
imageExtensions: Set<String>
|
||||||
|
): List<DesktopComicArchive.ExtractedComicPage> {
|
||||||
|
val archive = api.archive_read_new()
|
||||||
|
?: error("libarchive could not allocate a reader.")
|
||||||
|
try {
|
||||||
|
checkArchive(api.archive_read_support_filter_all(archive), archive, "enable archive filters")
|
||||||
|
checkArchive(api.archive_read_support_format_all(archive), archive, "enable archive formats")
|
||||||
|
checkArchive(
|
||||||
|
api.archive_read_open_filename(archive, file.absolutePath, BUFFER_SIZE.toLong()),
|
||||||
|
archive,
|
||||||
|
"open ${file.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val pages = mutableListOf<DesktopComicArchive.ExtractedComicPage>()
|
||||||
|
val entryRef = PointerByReference()
|
||||||
|
while (true) {
|
||||||
|
when (val status = api.archive_read_next_header(archive, entryRef)) {
|
||||||
|
ARCHIVE_OK -> Unit
|
||||||
|
ARCHIVE_EOF -> break
|
||||||
|
else -> checkArchive(status, archive, "read archive header")
|
||||||
|
}
|
||||||
|
|
||||||
|
val entry = entryRef.value ?: continue
|
||||||
|
val name = api.archive_entry_pathname_utf8(entry)
|
||||||
|
?: api.archive_entry_pathname(entry)
|
||||||
|
?: continue
|
||||||
|
val extension = name.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
if (api.archive_entry_filetype(entry) == ARCHIVE_ENTRY_DIRECTORY || extension !in imageExtensions) {
|
||||||
|
api.archive_read_data_skip(archive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = File(tempDir, "page_${pages.size}.${extension.ifBlank { "img" }}")
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val read = api.archive_read_data(archive, buffer, buffer.size.toLong())
|
||||||
|
when {
|
||||||
|
read > 0 -> output.write(buffer, 0, read.toInt())
|
||||||
|
read == 0L -> break
|
||||||
|
else -> checkArchive(read.toInt(), archive, "extract $name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages += DesktopComicArchive.ExtractedComicPage(name = name, file = target)
|
||||||
|
}
|
||||||
|
return pages.sortedBy { page -> with(DesktopComicArchive) { page.name.comicSortKey() } }
|
||||||
|
} finally {
|
||||||
|
api.archive_read_free(archive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val api: LibarchiveLibrary by lazy {
|
||||||
|
val overridePath = System.getProperty("reader.libarchive.path")
|
||||||
|
?: System.getenv("READER_LIBARCHIVE_PATH")
|
||||||
|
val candidates = if (overridePath.isNullOrBlank()) {
|
||||||
|
listOf("archive", "libarchive", "libarchive-13", "libarchive-14")
|
||||||
|
} else {
|
||||||
|
listOf(File(overridePath).absolutePath, overridePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.firstNotNullOfOrNull { candidate ->
|
||||||
|
runCatching { Native.load(candidate, LibarchiveLibrary::class.java) }
|
||||||
|
.getOrNull()
|
||||||
|
} ?: error(
|
||||||
|
"Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " +
|
||||||
|
"or bundle libarchive for this platform."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkArchive(status: Int, archive: Pointer, action: String) {
|
||||||
|
if (status >= ARCHIVE_OK) return
|
||||||
|
val message = api.archive_error_string(archive).orEmpty()
|
||||||
|
error("libarchive could not $action: ${message.ifBlank { "error code $status" }}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
private interface LibarchiveLibrary : Library {
|
||||||
|
fun archive_read_new(): Pointer?
|
||||||
|
fun archive_read_support_filter_all(archive: Pointer): Int
|
||||||
|
fun archive_read_support_format_all(archive: Pointer): Int
|
||||||
|
fun archive_read_open_filename(archive: Pointer, fileName: String, blockSize: Long): Int
|
||||||
|
fun archive_read_next_header(archive: Pointer, entry: PointerByReference): Int
|
||||||
|
fun archive_read_data(archive: Pointer, buffer: ByteArray, size: Long): Long
|
||||||
|
fun archive_read_data_skip(archive: Pointer): Int
|
||||||
|
fun archive_read_free(archive: Pointer): Int
|
||||||
|
fun archive_error_string(archive: Pointer): String?
|
||||||
|
fun archive_entry_pathname_utf8(entry: Pointer): String?
|
||||||
|
fun archive_entry_pathname(entry: Pointer): String?
|
||||||
|
fun archive_entry_filetype(entry: Pointer): Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.CustomFontItem
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class DesktopCustomFontStore(
|
||||||
|
private val fontsDir: File = defaultFontsDir(),
|
||||||
|
private val googleFontsDownloadAvailable: () -> Boolean = { true }
|
||||||
|
) {
|
||||||
|
private var googleFontsCache: List<String>? = null
|
||||||
|
|
||||||
|
fun importFont(source: File, displayNameOverride: String? = null): Result<CustomFontItem> {
|
||||||
|
if (!source.isFile) {
|
||||||
|
return Result.failure(IllegalArgumentException("Choose a font file."))
|
||||||
|
}
|
||||||
|
val extension = source.extension.lowercase()
|
||||||
|
if (extension !in SupportedFontExtensions) {
|
||||||
|
return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
fontsDir.mkdirs()
|
||||||
|
val fontId = UUID.randomUUID().toString()
|
||||||
|
val fileName = "font_$fontId.$extension"
|
||||||
|
val destination = File(fontsDir, fileName)
|
||||||
|
source.inputStream().use { input ->
|
||||||
|
destination.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
CustomFontItem(
|
||||||
|
id = fontId,
|
||||||
|
displayName = displayNameOverride?.takeIf { it.isNotBlank() }
|
||||||
|
?: source.nameWithoutExtension.ifBlank { "Imported font" },
|
||||||
|
fileName = fileName,
|
||||||
|
fileExtension = extension,
|
||||||
|
path = destination.absolutePath,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteFont(font: CustomFontItem): Boolean {
|
||||||
|
val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false
|
||||||
|
val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false
|
||||||
|
val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root }
|
||||||
|
if (!insideFontStore) return false
|
||||||
|
return !target.exists() || target.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFontFile(fileName: String): File {
|
||||||
|
fontsDir.mkdirs()
|
||||||
|
return File(fontsDir, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun syncedFontItem(metadata: DesktopCloudFontMetadata): CustomFontItem {
|
||||||
|
return CustomFontItem(
|
||||||
|
id = metadata.id,
|
||||||
|
displayName = metadata.displayName,
|
||||||
|
fileName = metadata.fileName,
|
||||||
|
fileExtension = metadata.fileExtension,
|
||||||
|
path = getFontFile(metadata.fileName).absolutePath,
|
||||||
|
timestamp = metadata.timestamp,
|
||||||
|
isDeleted = metadata.isDeleted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadGoogleFontsList(): List<String> {
|
||||||
|
googleFontsCache?.let { return it }
|
||||||
|
val loaded = runCatching {
|
||||||
|
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource)
|
||||||
|
?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource)
|
||||||
|
?: return@runCatching emptyList()
|
||||||
|
stream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
googleFontsFromJson(reader.readText())
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
googleFontsCache = loaded
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadGoogleFont(fontName: String): Result<CustomFontItem> {
|
||||||
|
if (!googleFontsDownloadAvailable()) {
|
||||||
|
return Result.failure(IllegalStateException("Google Fonts download is unavailable in this desktop build."))
|
||||||
|
}
|
||||||
|
val normalizedFontName = fontName.trim()
|
||||||
|
if (normalizedFontName.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("Choose a Google Font."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val encodedName = URLEncoder.encode(normalizedFontName, Charsets.UTF_8.name())
|
||||||
|
val cssConnection = URL("https://fonts.googleapis.com/css?family=$encodedName")
|
||||||
|
.openConnection() as HttpURLConnection
|
||||||
|
cssConnection.setRequestProperty("User-Agent", GoogleFontsSafariUserAgent)
|
||||||
|
cssConnection.connectTimeout = 15_000
|
||||||
|
cssConnection.readTimeout = 15_000
|
||||||
|
|
||||||
|
if (cssConnection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
throw IllegalStateException("Font '$normalizedFontName' was not found on Google Fonts.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val css = cssConnection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
val fontUrl = googleFontDownloadUrlFromCss(css)
|
||||||
|
?: throw IllegalStateException("Could not parse a download link for $normalizedFontName.")
|
||||||
|
val extension = googleFontFileExtension(fontUrl)
|
||||||
|
if (extension !in SupportedFontExtensions) {
|
||||||
|
throw IllegalStateException("Unsupported format ($extension) returned for $normalizedFontName.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempFile = File.createTempFile("episteme_google_font_", ".$extension")
|
||||||
|
try {
|
||||||
|
val fontConnection = URL(fontUrl).openConnection() as HttpURLConnection
|
||||||
|
fontConnection.connectTimeout = 15_000
|
||||||
|
fontConnection.readTimeout = 30_000
|
||||||
|
fontConnection.inputStream.use { input ->
|
||||||
|
tempFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
importFont(tempFile, displayNameOverride = normalizedFontName).getOrThrow()
|
||||||
|
} finally {
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val SupportedFontExtensions = setOf("ttf", "otf", "woff2")
|
||||||
|
private const val GoogleFontsResource = "google_fonts.json"
|
||||||
|
private const val GoogleFontsSafariUserAgent =
|
||||||
|
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1"
|
||||||
|
|
||||||
|
fun defaultFontsDir(): File {
|
||||||
|
return File(desktopUserDataRoot(), "custom_fonts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun googleFontDownloadUrlFromCss(css: String): String? {
|
||||||
|
return Regex("""url\((https://[^)]+)\)""")
|
||||||
|
.find(css)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun googleFontFileExtension(fontUrl: String): String {
|
||||||
|
return fontUrl.substringBefore('?')
|
||||||
|
.substringAfterLast('.', "ttf")
|
||||||
|
.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun googleFontsFromJson(rawJson: String): List<String> {
|
||||||
|
return Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonArray
|
||||||
|
.mapNotNull { element ->
|
||||||
|
runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||||
|
private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
||||||
|
private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS"
|
||||||
|
private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS"
|
||||||
|
|
||||||
|
private val DesktopDiagnosticTags: Set<String> =
|
||||||
|
listOfNotNull(
|
||||||
|
System.getProperty(DesktopDiagnosticsTagsProperty),
|
||||||
|
System.getenv(DesktopDiagnosticsTagsEnv)
|
||||||
|
)
|
||||||
|
.joinToString(" ")
|
||||||
|
.split(',', ';', ' ', '\t', '\n')
|
||||||
|
.mapNotNull { rawTag ->
|
||||||
|
rawTag.trim()
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.lowercase()
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
internal val DesktopDiagnosticsEnabled: Boolean =
|
||||||
|
desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) ||
|
||||||
|
desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) ||
|
||||||
|
DesktopDiagnosticTags.isNotEmpty()
|
||||||
|
|
||||||
|
internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean {
|
||||||
|
return rawValue?.trim()?.equals("true", ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean {
|
||||||
|
if (DesktopDiagnosticTags.isEmpty()) return true
|
||||||
|
return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun logDesktopDiagnostic(tag: String, message: () -> String) {
|
||||||
|
if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) {
|
||||||
|
println("$tag ${message()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||||
|
import org.dueattendant149.bookreader.shared.toStableReaderPositionCfi
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderLinkClick
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
internal data class DesktopReaderPosition(
|
||||||
|
val pageIndex: Int,
|
||||||
|
val locator: ReaderLocator?
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopReaderHighlightClick(
|
||||||
|
val highlightId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopEpubLinkClick(
|
||||||
|
val href: String,
|
||||||
|
val chapterIndex: Int?,
|
||||||
|
val text: String? = null,
|
||||||
|
val chapterId: String? = null,
|
||||||
|
val chapterHref: String? = null,
|
||||||
|
val source: String = "bridge"
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick {
|
||||||
|
return DesktopEpubLinkClick(
|
||||||
|
href = href,
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
text = text,
|
||||||
|
source = "native"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopEpubHandledLink(
|
||||||
|
val href: String,
|
||||||
|
val handledAtMs: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
internal enum class DesktopReaderSelectionAction {
|
||||||
|
DEFINE,
|
||||||
|
SPEAK,
|
||||||
|
SEARCH,
|
||||||
|
PALETTE
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class DesktopReaderKeyNavigation {
|
||||||
|
NEXT,
|
||||||
|
PREVIOUS,
|
||||||
|
FIRST,
|
||||||
|
LAST,
|
||||||
|
SEARCH,
|
||||||
|
NEXT_SEARCH,
|
||||||
|
EXIT_FULLSCREEN
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(
|
||||||
|
fullscreen: Boolean,
|
||||||
|
rightToLeftPagination: Boolean = false
|
||||||
|
): DesktopReaderKeyNavigation? {
|
||||||
|
if (id != AwtKeyEvent.KEY_PRESSED) return null
|
||||||
|
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
|
||||||
|
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||||
|
}
|
||||||
|
if (isControlDown && keyCode == AwtKeyEvent.VK_F) {
|
||||||
|
return DesktopReaderKeyNavigation.SEARCH
|
||||||
|
}
|
||||||
|
if (isControlDown && keyCode == AwtKeyEvent.VK_G) {
|
||||||
|
return DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||||
|
}
|
||||||
|
return when (keyCode) {
|
||||||
|
AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) {
|
||||||
|
DesktopReaderKeyNavigation.PREVIOUS
|
||||||
|
} else {
|
||||||
|
DesktopReaderKeyNavigation.NEXT
|
||||||
|
}
|
||||||
|
AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) {
|
||||||
|
DesktopReaderKeyNavigation.NEXT
|
||||||
|
} else {
|
||||||
|
DesktopReaderKeyNavigation.PREVIOUS
|
||||||
|
}
|
||||||
|
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
|
||||||
|
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
|
||||||
|
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
|
||||||
|
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopReaderSelectionActionPayload(
|
||||||
|
val action: DesktopReaderSelectionAction,
|
||||||
|
val text: String,
|
||||||
|
val locator: ReaderLocator? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val highlightId = obj["id"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: obj["highlightId"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@runCatching null
|
||||||
|
DesktopReaderHighlightClick(highlightId)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val text = obj["text"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@runCatching null
|
||||||
|
val action = when (
|
||||||
|
obj["action"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.lowercase()
|
||||||
|
) {
|
||||||
|
"define" -> DesktopReaderSelectionAction.DEFINE
|
||||||
|
"speak" -> DesktopReaderSelectionAction.SPEAK
|
||||||
|
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
|
||||||
|
"palette" -> DesktopReaderSelectionAction.PALETTE
|
||||||
|
else -> return@runCatching null
|
||||||
|
}
|
||||||
|
val locator = obj["locator"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonObject
|
||||||
|
?.let { locatorObj ->
|
||||||
|
ReaderLocator(
|
||||||
|
chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DesktopReaderSelectionActionPayload(action, text, locator)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerSelectionDebugMessageOrNull(): String? {
|
||||||
|
fun parse(rawJson: String): String? = runCatching {
|
||||||
|
Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonObject["message"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerPaginationLogMessageOrNull(): String? {
|
||||||
|
fun parse(rawJson: String): String? = runCatching {
|
||||||
|
Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonObject["message"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val pageIndex = obj["pageIndex"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.intOrNull
|
||||||
|
?: return@runCatching null
|
||||||
|
val locator = ReaderLocator(
|
||||||
|
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
|
||||||
|
)
|
||||||
|
DesktopReaderPosition(pageIndex, locator)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? {
|
||||||
|
fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching {
|
||||||
|
val action = Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonObject["action"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?: return@runCatching null
|
||||||
|
when (action) {
|
||||||
|
"next" -> DesktopReaderKeyNavigation.NEXT
|
||||||
|
"previous" -> DesktopReaderKeyNavigation.PREVIOUS
|
||||||
|
"first" -> DesktopReaderKeyNavigation.FIRST
|
||||||
|
"last" -> DesktopReaderKeyNavigation.LAST
|
||||||
|
"search" -> DesktopReaderKeyNavigation.SEARCH
|
||||||
|
"nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||||
|
"exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
|
||||||
|
fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
|
||||||
|
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val href = obj["href"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@runCatching null
|
||||||
|
DesktopEpubLinkClick(
|
||||||
|
href = href,
|
||||||
|
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||||
|
text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||||
|
chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
parse(this)?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||||
|
}.getOrNull()?.let { parse(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
|
||||||
|
val trimmed = trim()
|
||||||
|
if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
|
||||||
|
logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
|
||||||
|
val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
|
||||||
|
.split('&')
|
||||||
|
.firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
|
||||||
|
?.substringAfter("=", missingDelimiterValue = "")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
if (payload == null) {
|
||||||
|
logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val decoded = runCatching {
|
||||||
|
URLDecoder.decode(payload, Charsets.UTF_8.name())
|
||||||
|
}.getOrElse {
|
||||||
|
logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
|
||||||
|
if (link == null) {
|
||||||
|
logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
return readerHrefFromIntercept()?.let { href ->
|
||||||
|
DesktopEpubLinkClick(
|
||||||
|
href = href,
|
||||||
|
chapterIndex = null,
|
||||||
|
source = "request"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.readerHrefFromIntercept(): String? {
|
||||||
|
val trimmed = trim()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
if (trimmed.equals("about:blank", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("file:/", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
|
||||||
|
if (trimmed.startsWith("data:", ignoreCase = true)) return null
|
||||||
|
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ReaderLocator.toReaderLocatorJson(): String {
|
||||||
|
return buildString {
|
||||||
|
append("{")
|
||||||
|
val values = buildList {
|
||||||
|
chapterIndex?.let { add("\"chapterIndex\":$it") }
|
||||||
|
chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") }
|
||||||
|
href?.let { add("\"href\":${it.toJsonStringLiteral()}") }
|
||||||
|
pageIndex?.let { add("\"pageIndex\":$it") }
|
||||||
|
startOffset?.let { add("\"startOffset\":$it") }
|
||||||
|
endOffset?.let { add("\"endOffset\":$it") }
|
||||||
|
blockIndex?.let { add("\"blockIndex\":$it") }
|
||||||
|
charOffset?.let { add("\"charOffset\":$it") }
|
||||||
|
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
|
||||||
|
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
|
||||||
|
}
|
||||||
|
append(values.joinToString(","))
|
||||||
|
append("}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toJsonStringLiteral(): String {
|
||||||
|
val builder = StringBuilder("\"")
|
||||||
|
forEach { char ->
|
||||||
|
when (char) {
|
||||||
|
'\\' -> builder.append("\\\\")
|
||||||
|
'"' -> builder.append("\\\"")
|
||||||
|
'\n' -> builder.append("\\n")
|
||||||
|
'\r' -> builder.append("\\r")
|
||||||
|
'\t' -> builder.append("\\t")
|
||||||
|
'\b' -> builder.append("\\b")
|
||||||
|
'\u000C' -> builder.append("\\f")
|
||||||
|
else -> {
|
||||||
|
if (char.code < 0x20) {
|
||||||
|
builder.append("\\u")
|
||||||
|
builder.append(char.code.toString(16).padStart(4, '0'))
|
||||||
|
} else {
|
||||||
|
builder.append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.append('"')
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object DesktopEpubLoader {
|
||||||
|
fun load(file: File): SharedEpubBook {
|
||||||
|
return SharedJvmBookLoader.load(file, FileType.EPUB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderLayoutSignature
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderPage
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||||
|
|
||||||
|
internal data class DesktopEpubPaginationRequest(
|
||||||
|
val bookId: String,
|
||||||
|
val chapterSignature: Int,
|
||||||
|
val layoutSignature: ReaderLayoutSignature,
|
||||||
|
val viewport: ReaderViewportSpec,
|
||||||
|
val density: DesktopEpubPaginationDensity,
|
||||||
|
val cacheGeneration: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopEpubPaginationDensity(
|
||||||
|
val density: Float,
|
||||||
|
val fontScale: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun desktopMeasuredPaginationReady(
|
||||||
|
request: DesktopEpubPaginationRequest?,
|
||||||
|
completedRequest: DesktopEpubPaginationRequest?,
|
||||||
|
currentPages: List<ReaderPage>,
|
||||||
|
measuredPages: List<ReaderPage>
|
||||||
|
): Boolean {
|
||||||
|
return request != null &&
|
||||||
|
completedRequest == request &&
|
||||||
|
measuredPages.isNotEmpty() &&
|
||||||
|
currentPages.samePageLayoutAs(measuredPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPaginatedLayoutReadyForDisplay(
|
||||||
|
readingMode: ReaderReadingMode,
|
||||||
|
measuredPagesApplied: Boolean
|
||||||
|
): Boolean {
|
||||||
|
return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopPagesWithMeasuredChapter(
|
||||||
|
currentPages: List<ReaderPage>,
|
||||||
|
chapterIndex: Int,
|
||||||
|
measuredChapterPages: List<ReaderPage>
|
||||||
|
): List<ReaderPage> {
|
||||||
|
if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages
|
||||||
|
val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex }
|
||||||
|
if (firstChapterPage < 0) return currentPages
|
||||||
|
val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex }
|
||||||
|
val combined = currentPages.take(firstChapterPage) +
|
||||||
|
measuredChapterPages +
|
||||||
|
currentPages.drop(lastChapterPage + 1)
|
||||||
|
return combined.mapIndexed { index, page -> page.copy(pageIndex = index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<ReaderPage>.firstPageIndexForChapter(chapterIndex: Int): Int? {
|
||||||
|
return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedEpubBook.desktopPaginationContentSignature(): Int {
|
||||||
|
return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter ->
|
||||||
|
31 * acc +
|
||||||
|
chapter.id.hashCode() +
|
||||||
|
chapter.plainText.length +
|
||||||
|
chapter.plainText.hashCode() +
|
||||||
|
chapter.semanticBlocks.hashCode() +
|
||||||
|
chapter.htmlContent.length +
|
||||||
|
chapter.htmlContent.hashCode() +
|
||||||
|
chapter.baseHref.orEmpty().hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopEpubPaginationPreparing(
|
||||||
|
active: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
if (active) "Preparing pages" else "Measuring reader layout",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||||
|
import org.dueattendant149.bookreader.shared.UserHighlight
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopEpubWebView(
|
||||||
|
html: String,
|
||||||
|
appearanceScript: String,
|
||||||
|
highlightPaletteScript: String,
|
||||||
|
navigationTarget: ReaderContentNavigationTarget,
|
||||||
|
highlights: List<UserHighlight>,
|
||||||
|
onHighlightCreated: (UserHighlight) -> Unit,
|
||||||
|
onHighlightSelected: (String) -> Unit,
|
||||||
|
isFullscreen: Boolean,
|
||||||
|
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
|
||||||
|
onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
|
||||||
|
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
|
||||||
|
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
|
||||||
|
onPointerActivity: () -> Unit = {},
|
||||||
|
networkAccessEnabled: Boolean,
|
||||||
|
backgroundColor: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val backend = desktopEpubWebViewBackend()
|
||||||
|
LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) {
|
||||||
|
logDesktopWebView2(
|
||||||
|
"backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " +
|
||||||
|
"network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}"
|
||||||
|
)
|
||||||
|
logDesktopReaderOpenTrace {
|
||||||
|
"event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " +
|
||||||
|
"htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " +
|
||||||
|
"navMode=${navigationTarget.readingMode}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesktopNativeSwtEpubWebView(
|
||||||
|
html = html,
|
||||||
|
appearanceScript = appearanceScript,
|
||||||
|
highlightPaletteScript = highlightPaletteScript,
|
||||||
|
navigationTarget = navigationTarget,
|
||||||
|
highlights = highlights,
|
||||||
|
onHighlightCreated = onHighlightCreated,
|
||||||
|
onHighlightSelected = onHighlightSelected,
|
||||||
|
isFullscreen = isFullscreen,
|
||||||
|
onKeyboardNavigation = onKeyboardNavigation,
|
||||||
|
onSelectionAction = onSelectionAction,
|
||||||
|
onLinkClicked = onLinkClicked,
|
||||||
|
onVisiblePageChanged = onVisiblePageChanged,
|
||||||
|
onPointerActivity = onPointerActivity,
|
||||||
|
networkAccessEnabled = networkAccessEnabled,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopEpubBridgeHandler(
|
||||||
|
val methodName: String,
|
||||||
|
val onMessage: (String) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun rememberDesktopEpubBridgeHandlers(
|
||||||
|
onHighlightCreated: (UserHighlight) -> Unit,
|
||||||
|
onHighlightSelected: (String) -> Unit,
|
||||||
|
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
|
||||||
|
onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
|
||||||
|
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
|
||||||
|
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
|
||||||
|
onPointerActivity: () -> Unit
|
||||||
|
): List<DesktopEpubBridgeHandler> {
|
||||||
|
val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated)
|
||||||
|
val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected)
|
||||||
|
val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation)
|
||||||
|
val latestOnSelectionAction by rememberUpdatedState(onSelectionAction)
|
||||||
|
val latestOnLinkClicked by rememberUpdatedState(onLinkClicked)
|
||||||
|
val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged)
|
||||||
|
val latestOnPointerActivity by rememberUpdatedState(onPointerActivity)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
return remember(scope) {
|
||||||
|
listOf(
|
||||||
|
DesktopEpubBridgeHandler("readerHighlightCreated") { params ->
|
||||||
|
logEpubHighlightFlow("bridge_received method=readerHighlightCreated params=\"${params.logPreview(900)}\"")
|
||||||
|
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(params)
|
||||||
|
if (highlight == null) {
|
||||||
|
logEpubHighlightFlow("bridge_parse_failed method=readerHighlightCreated")
|
||||||
|
logEpubSelectionDebug("highlight_parse_failed params=${params.logPreview(900)}")
|
||||||
|
} else {
|
||||||
|
logEpubHighlightFlow(
|
||||||
|
"bridge_parse_success id=${highlight.id} color=${highlight.color.id} " +
|
||||||
|
"chapter=${highlight.chapterIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
|
||||||
|
"page=${highlight.locator.pageIndex} textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
|
||||||
|
)
|
||||||
|
logDesktopHighlightMap(
|
||||||
|
"bridge_highlight_created id=${highlight.id} color=${highlight.color.id} " +
|
||||||
|
"chapter=${highlight.chapterIndex} locatorChapter=${highlight.locator.chapterIndex} " +
|
||||||
|
"page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
|
||||||
|
"block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " +
|
||||||
|
"chapterId=${highlight.locator.chapterId.orEmpty().logPreview()} href=${highlight.locator.href.orEmpty().logPreview()} " +
|
||||||
|
"textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
|
||||||
|
)
|
||||||
|
scope.launch { latestOnHighlightCreated(highlight) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerHighlightClicked") { params ->
|
||||||
|
params.readerHighlightClickOrNull()?.let { highlightClick ->
|
||||||
|
scope.launch { latestOnHighlightSelected(highlightClick.highlightId) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerPositionChanged") { params ->
|
||||||
|
params.readerPositionOrNull()?.let { position ->
|
||||||
|
logDesktopPositionTrace(
|
||||||
|
"event=bridge_position_changed page=${position.pageIndex} " +
|
||||||
|
"locator=${position.locator.desktopPositionTraceSummary()}"
|
||||||
|
)
|
||||||
|
logDesktopHighlightMap(
|
||||||
|
"bridge_position_changed page=${position.pageIndex} chapter=${position.locator?.chapterIndex} " +
|
||||||
|
"offsets=${position.locator?.startOffset}..${position.locator?.endOffset} " +
|
||||||
|
"block=${position.locator?.blockIndex} char=${position.locator?.charOffset} " +
|
||||||
|
"chapterId=${position.locator?.chapterId.orEmpty().logPreview()} href=${position.locator?.href.orEmpty().logPreview()} " +
|
||||||
|
"text=\"${position.locator?.textQuote.orEmpty().logPreview(120)}\" " +
|
||||||
|
"cfi=\"${position.locator?.cfi.orEmpty().logPreview(160)}\""
|
||||||
|
)
|
||||||
|
logDesktopTtsStartTrace {
|
||||||
|
"event=bridge_position_changed page=${position.pageIndex} " +
|
||||||
|
"locator=${position.locator.desktopPositionTraceSummary(160)}"
|
||||||
|
}
|
||||||
|
scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerDesktopPositionTraceLog") { params ->
|
||||||
|
logDesktopPositionTrace(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerTtsStartTraceLog") { params ->
|
||||||
|
logDesktopTtsStartTrace { params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900) }
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerSelectionAction") { params ->
|
||||||
|
val selectionAction = params.readerSelectionActionOrNull()
|
||||||
|
if (selectionAction != null) {
|
||||||
|
scope.launch { latestOnSelectionAction(selectionAction) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerKeyNavigation") { params ->
|
||||||
|
params.readerKeyNavigationOrNull()?.let { action ->
|
||||||
|
scope.launch { latestOnKeyboardNavigation(action) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerPointerActivity") {
|
||||||
|
scope.launch { latestOnPointerActivity() }
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerTtsHighlightLog") { params ->
|
||||||
|
logDesktopTts("epub_highlight_js ${params.logPreview(500)}")
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerSelectionDebugLog") { params ->
|
||||||
|
logEpubSelectionDebug(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerHighlightFlowLog") { params ->
|
||||||
|
logEpubHighlightFlow(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerDesktopHighlightMapLog") { params ->
|
||||||
|
logDesktopHighlightMap(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerPaginationLayoutLog") { params ->
|
||||||
|
logEpubPagination(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerGapLayoutLog") { params ->
|
||||||
|
logReaderGap(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
|
||||||
|
},
|
||||||
|
DesktopEpubBridgeHandler("readerLinkClicked") { params ->
|
||||||
|
logEpubLink("bridge_message params=\"${params.logPreview()}\"")
|
||||||
|
val link = params.readerLinkClickOrNull()
|
||||||
|
if (link == null) {
|
||||||
|
logEpubLink("bridge_message_ignored reason=parse_failed")
|
||||||
|
} else {
|
||||||
|
logEpubLink(
|
||||||
|
"bridge_message_parsed href=\"${link.href.logPreview()}\" " +
|
||||||
|
"chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\""
|
||||||
|
)
|
||||||
|
scope.launch { latestOnLinkClicked(link) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val DesktopEpubKeyNavigationScript = """
|
||||||
|
(function () {
|
||||||
|
if (!window.readerDesktopChromeTapInstalled) {
|
||||||
|
window.readerDesktopChromeTapInstalled = true;
|
||||||
|
var chromeTapStart = null;
|
||||||
|
var lastChromeTapNotifiedAt = 0;
|
||||||
|
function notifyChromeTap() {
|
||||||
|
if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
|
||||||
|
window.kmpJsBridge.callNative('readerPointerActivity', '{}');
|
||||||
|
lastChromeTapNotifiedAt = Date.now();
|
||||||
|
}
|
||||||
|
function chromeTapIgnored(target) {
|
||||||
|
if (!target || !target.closest) return false;
|
||||||
|
return !!target.closest(
|
||||||
|
'a[href], button, input, textarea, select, [contenteditable="true"], #reader-selection-menu, .reader-selection-handle'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function hasActiveReaderSelection() {
|
||||||
|
var selection = window.getSelection && window.getSelection();
|
||||||
|
return !!selection && selection.toString().trim().length > 0;
|
||||||
|
}
|
||||||
|
function beginChromeTap(event) {
|
||||||
|
if (event.button !== undefined && event.button !== 0) return;
|
||||||
|
if (chromeTapIgnored(event.target)) {
|
||||||
|
chromeTapStart = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chromeTapStart = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
x: event.clientX || 0,
|
||||||
|
y: event.clientY || 0,
|
||||||
|
at: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function finishChromeTap(event) {
|
||||||
|
if (!chromeTapStart) return;
|
||||||
|
if (event.pointerId !== undefined && chromeTapStart.pointerId !== undefined && event.pointerId !== chromeTapStart.pointerId) return;
|
||||||
|
var dx = (event.clientX || 0) - chromeTapStart.x;
|
||||||
|
var dy = (event.clientY || 0) - chromeTapStart.y;
|
||||||
|
var elapsed = Date.now() - chromeTapStart.at;
|
||||||
|
chromeTapStart = null;
|
||||||
|
if ((dx * dx + dy * dy) > 64 || elapsed > 650) return;
|
||||||
|
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
|
||||||
|
notifyChromeTap();
|
||||||
|
}
|
||||||
|
function maybeNotifyChromeTapFromClick(event) {
|
||||||
|
if (Date.now() - lastChromeTapNotifiedAt < 250) return;
|
||||||
|
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
|
||||||
|
notifyChromeTap();
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', beginChromeTap, true);
|
||||||
|
document.addEventListener('pointerup', finishChromeTap, true);
|
||||||
|
document.addEventListener('pointercancel', function () { chromeTapStart = null; }, true);
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (window.PointerEvent) {
|
||||||
|
maybeNotifyChromeTapFromClick(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
beginChromeTap(event);
|
||||||
|
finishChromeTap(event);
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
if (window.readerDesktopKeyNavigationInstalled) return;
|
||||||
|
window.readerDesktopKeyNavigationInstalled = true;
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
var tag = target && target.tagName ? target.tagName.toLowerCase() : '';
|
||||||
|
if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return;
|
||||||
|
var action = null;
|
||||||
|
if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search';
|
||||||
|
else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch';
|
||||||
|
else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next';
|
||||||
|
else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous';
|
||||||
|
else if (event.key === 'Home') action = 'first';
|
||||||
|
else if (event.key === 'End') action = 'last';
|
||||||
|
else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen';
|
||||||
|
if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action }));
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.readerString
|
||||||
|
import java.awt.Desktop
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
internal const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme"
|
||||||
|
internal const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues"
|
||||||
|
internal const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112"
|
||||||
|
internal const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader"
|
||||||
|
internal const val EpistemeSupportEmail = "epistemereader@gmail.com"
|
||||||
|
|
||||||
|
private const val ExternalLinkLogTag = "EpistemeExternalLink"
|
||||||
|
|
||||||
|
internal fun desktopFeedbackSubject(profile: DesktopBuildProfile): String {
|
||||||
|
return "Feedback: ${profile.appName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopAppVersionName(): String {
|
||||||
|
val version = System.getProperty(DesktopVersionProperty)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
return version?.let { "Version $it" } ?: "Version unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
private object EpistemeDesktopAppVersion
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopExternalLinkDialog(
|
||||||
|
url: String?,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
if (url == null) return
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
logExternalLink("dialog_show url=\"${url.logPreview()}\"")
|
||||||
|
}
|
||||||
|
fun dismiss() {
|
||||||
|
logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"")
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
DesktopReaderBottomSheet(
|
||||||
|
title = readerString("dialog_external_link_title", "External link"),
|
||||||
|
onDismiss = ::dismiss
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
readerString("desktop_external_link_desc", "You clicked an external link."),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
url,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = ::dismiss) {
|
||||||
|
Text(readerString("action_cancel", "Cancel"))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
logExternalLink("dialog_copy url=\"${url.logPreview()}\"")
|
||||||
|
clipboardManager.setText(AnnotatedString(url))
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(readerString("action_copy", "Copy"))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
logExternalLink("dialog_open url=\"${url.logPreview()}\"")
|
||||||
|
openExternalUrl(url)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(readerString("action_open", "Open"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openExternalUrl(url: String) {
|
||||||
|
if (!currentDesktopBuildProfile().featurePolicy.projectLinks) {
|
||||||
|
logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val normalizedUrl = url.normalizedExternalUrl()
|
||||||
|
runCatching {
|
||||||
|
if (Desktop.isDesktopSupported()) {
|
||||||
|
val desktop = Desktop.getDesktop()
|
||||||
|
if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) {
|
||||||
|
desktop.mail(URI(normalizedUrl))
|
||||||
|
} else {
|
||||||
|
desktop.browse(URI(normalizedUrl))
|
||||||
|
}
|
||||||
|
logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"")
|
||||||
|
} else {
|
||||||
|
logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"")
|
||||||
|
}
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.normalizedExternalUrl(): String {
|
||||||
|
val trimmed = trim()
|
||||||
|
return if (trimmed.startsWith("www.", ignoreCase = true)) {
|
||||||
|
"https://$trimmed"
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.isRemoteNetworkUrl(): Boolean {
|
||||||
|
val trimmed = trim()
|
||||||
|
return trimmed.startsWith("http://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("https://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("ws://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("wss://", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.urlEncode(): String {
|
||||||
|
return URLEncoder.encode(this, Charsets.UTF_8.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logExternalLink(message: String) {
|
||||||
|
logDesktopDiagnostic(ExternalLinkLogTag) { message }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
internal data class DesktopFeatureNoticePlacement(
|
||||||
|
val readerWindowId: String? = null
|
||||||
|
) {
|
||||||
|
fun rendersInMainWindow(): Boolean = readerWindowId == null
|
||||||
|
|
||||||
|
fun rendersInReaderWindow(windowId: String): Boolean = readerWindowId == windowId
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun desktopFeatureNoticePlacement(readerWindowId: String?): DesktopFeatureNoticePlacement {
|
||||||
|
return DesktopFeatureNoticePlacement(readerWindowId = readerWindowId?.takeIf { it.isNotBlank() })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.ImportedBookFile
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import java.awt.FileDialog
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.io.File
|
||||||
|
import javax.swing.JFileChooser
|
||||||
|
|
||||||
|
internal val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
internal val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
internal val DesktopBookFileTypes = DesktopReadableFileTypes
|
||||||
|
private val DesktopBookFileDialogPattern = SharedFileCapabilities.all
|
||||||
|
.filter { it.type in DesktopBookFileTypes }
|
||||||
|
.flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } }
|
||||||
|
.joinToString(";")
|
||||||
|
|
||||||
|
internal fun desktopBookFileTypesForDialog(): Set<FileType> = DesktopBookFileTypes
|
||||||
|
|
||||||
|
internal fun chooseFiles(): List<ImportedBookFile> {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_import_books", "Import books"), FileDialog.LOAD).apply {
|
||||||
|
isMultipleMode = true
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
return dialog.files.orEmpty().map { it.toDesktopImportedBookFile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseBookFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_book", "Open Book"), FileDialog.LOAD).apply {
|
||||||
|
file = DesktopBookFileDialogPattern
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun choosePdfFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_pdf", "Open PDF"), FileDialog.LOAD).apply {
|
||||||
|
file = "*.pdf"
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseFontFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_choose_font", "Choose font"), FileDialog.LOAD).apply {
|
||||||
|
file = "*.ttf;*.otf;*.woff2"
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseReaderTextureFile(): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_choose_reader_texture", "Choose reader texture"), FileDialog.LOAD).apply {
|
||||||
|
file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp"
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseSaveImageFile(defaultFileName: String): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_save_image", "Save image"), FileDialog.SAVE).apply {
|
||||||
|
file = defaultFileName
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseSaveBookFile(defaultFileName: String): File? {
|
||||||
|
val dialog = FileDialog(null as Frame?, desktopDialogString("action_save_copy_to_device", "Save copy to device"), FileDialog.SAVE).apply {
|
||||||
|
file = defaultFileName
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
val directory = dialog.directory ?: return null
|
||||||
|
val file = dialog.file ?: return null
|
||||||
|
return File(directory, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun chooseFolder(): File? {
|
||||||
|
val chooser = JFileChooser().apply {
|
||||||
|
dialogTitle = desktopDialogString("desktop_import_folder", "Import folder")
|
||||||
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
|
isAcceptAllFileFilterUsed = false
|
||||||
|
}
|
||||||
|
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||||
|
chooser.selectedFile
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun desktopDialogString(name: String, fallback: String): String {
|
||||||
|
return loadDesktopStringResolver().string(name, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ImportedBookFile.desktopFileType(): FileType {
|
||||||
|
return SharedFileCapabilities.fileTypeForName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun File.toDesktopImportedBookFile(sourceFolder: String? = null): ImportedBookFile {
|
||||||
|
return ImportedBookFile(
|
||||||
|
name = name,
|
||||||
|
uriString = null,
|
||||||
|
localPath = absolutePath,
|
||||||
|
size = length(),
|
||||||
|
sourceFolder = sourceFolder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import org.dueattendant149.bookreader.shared.ImportedBookFile
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.readerQuantityString
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.readerString
|
||||||
|
import java.awt.Component
|
||||||
|
import java.awt.Container
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.awt.datatransfer.DataFlavor
|
||||||
|
import java.awt.dnd.DnDConstants
|
||||||
|
import java.awt.dnd.DropTarget
|
||||||
|
import java.awt.dnd.DropTargetAdapter
|
||||||
|
import java.awt.dnd.DropTargetDragEvent
|
||||||
|
import java.awt.dnd.DropTargetDropEvent
|
||||||
|
import java.awt.dnd.DropTargetEvent
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal data class DesktopDropImportState(
|
||||||
|
val active: Boolean = false,
|
||||||
|
val supportedCount: Int = 0,
|
||||||
|
val totalFileCount: Int = 0,
|
||||||
|
val hasFilePayload: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopFileDropTarget(
|
||||||
|
window: Component?,
|
||||||
|
onFilesDropped: (List<ImportedBookFile>) -> Unit,
|
||||||
|
onDragStateChange: (DesktopDropImportState) -> Unit
|
||||||
|
) {
|
||||||
|
val onFilesDroppedState = rememberUpdatedState(onFilesDropped)
|
||||||
|
val onDragStateChangeState = rememberUpdatedState(onDragStateChange)
|
||||||
|
|
||||||
|
DisposableEffect(window) {
|
||||||
|
if (window == null) {
|
||||||
|
onDispose { }
|
||||||
|
} else {
|
||||||
|
val installedTargets = mutableListOf<InstalledDropTarget>()
|
||||||
|
var disposed = false
|
||||||
|
var lastDragState = DesktopDropImportState()
|
||||||
|
|
||||||
|
fun publishDragState(state: DesktopDropImportState) {
|
||||||
|
if (state == lastDragState) return
|
||||||
|
lastDragState = state
|
||||||
|
onDragStateChangeState.value(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
val listener = object : DropTargetAdapter() {
|
||||||
|
override fun dragEnter(event: DropTargetDragEvent) {
|
||||||
|
handleDrag(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dragOver(event: DropTargetDragEvent) {
|
||||||
|
handleDrag(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dragExit(event: DropTargetEvent) {
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun drop(event: DropTargetDropEvent) {
|
||||||
|
if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||||
|
event.rejectDrop()
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.acceptDrop(DnDConstants.ACTION_COPY)
|
||||||
|
val files = event.transferable.localDraggedFiles().filter { it.isFile }
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
event.dropComplete(false)
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilesDroppedState.value(files.map { it.toDesktopImportedBookFile() })
|
||||||
|
event.dropComplete(true)
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDrag(event: DropTargetDragEvent) {
|
||||||
|
val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)
|
||||||
|
publishDragState(
|
||||||
|
DesktopDropImportState(
|
||||||
|
active = true,
|
||||||
|
hasFilePayload = hasFilePayload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (hasFilePayload) {
|
||||||
|
event.acceptDrag(DnDConstants.ACTION_COPY)
|
||||||
|
} else {
|
||||||
|
event.rejectDrag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.installDropTargets(listener, installedTargets)
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
if (!disposed) {
|
||||||
|
window.installDropTargets(listener, installedTargets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
disposed = true
|
||||||
|
installedTargets.forEach { installed ->
|
||||||
|
runCatching { installed.dropTarget.removeDropTargetListener(listener) }
|
||||||
|
installed.component.dropTarget = installed.previous
|
||||||
|
}
|
||||||
|
publishDragState(DesktopDropImportState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class InstalledDropTarget(
|
||||||
|
val component: Component,
|
||||||
|
val previous: DropTarget?,
|
||||||
|
val dropTarget: DropTarget
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Component.installDropTargets(
|
||||||
|
listener: DropTargetAdapter,
|
||||||
|
installedTargets: MutableList<InstalledDropTarget>
|
||||||
|
) {
|
||||||
|
collectDropTargetComponents()
|
||||||
|
.distinct()
|
||||||
|
.filterNot { component -> installedTargets.any { it.component == component } }
|
||||||
|
.forEach { component ->
|
||||||
|
val previous = component.dropTarget
|
||||||
|
val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true)
|
||||||
|
installedTargets += InstalledDropTarget(component, previous, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Component.collectDropTargetComponents(): List<Component> {
|
||||||
|
val collected = mutableListOf<Component>()
|
||||||
|
|
||||||
|
fun visit(component: Component) {
|
||||||
|
collected += component
|
||||||
|
if (component is Container) {
|
||||||
|
component.components.forEach(::visit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(this)
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopDropImportOverlay(state: DesktopDropImportState) {
|
||||||
|
if (!state.active) return
|
||||||
|
|
||||||
|
val hasSupportedFiles = state.supportedCount > 0
|
||||||
|
val title = when {
|
||||||
|
hasSupportedFiles -> readerQuantityString(
|
||||||
|
"desktop_drop_import_file_count",
|
||||||
|
state.supportedCount,
|
||||||
|
"Drop to import %1\$d file",
|
||||||
|
"Drop to import %1\$d files",
|
||||||
|
state.supportedCount
|
||||||
|
)
|
||||||
|
state.hasFilePayload -> readerString("desktop_drop_supported_files_to_import", "Drop supported files to import")
|
||||||
|
else -> readerString("desktop_drop_files_to_import", "Drop files to import")
|
||||||
|
}
|
||||||
|
val body = if (hasSupportedFiles) {
|
||||||
|
val skipped = state.totalFileCount - state.supportedCount
|
||||||
|
if (skipped > 0) {
|
||||||
|
readerQuantityString(
|
||||||
|
"desktop_unsupported_import_file_count",
|
||||||
|
skipped,
|
||||||
|
"%1\$d unsupported file will be skipped.",
|
||||||
|
"%1\$d unsupported files will be skipped.",
|
||||||
|
skipped
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
readerString("desktop_release_add_library", "Release to add to your library.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zIndex(20f)
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
tonalElevation = 8.dp,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List<File> {
|
||||||
|
if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(getTransferData(DataFlavor.javaFileListFlavor) as? List<*>)
|
||||||
|
.orEmpty()
|
||||||
|
.filterIsInstance<File>()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.UserData
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Properties
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
internal data class DesktopAuthSession(
|
||||||
|
val user: UserData,
|
||||||
|
val idToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val expiresAtEpochMillis: Long,
|
||||||
|
val googleAccessToken: String = "",
|
||||||
|
val googleRefreshToken: String = "",
|
||||||
|
val googleAccessTokenExpiresAtEpochMillis: Long = 0L
|
||||||
|
) {
|
||||||
|
val isFresh: Boolean get() = idToken.isNotBlank() && expiresAtEpochMillis - System.currentTimeMillis() > 60_000L
|
||||||
|
val isGoogleAccessTokenFresh: Boolean
|
||||||
|
get() = googleAccessToken.isNotBlank() &&
|
||||||
|
googleAccessTokenExpiresAtEpochMillis - System.currentTimeMillis() > 60_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopFirebaseAuthRepository(
|
||||||
|
private val config: DesktopCloudConfig,
|
||||||
|
private val store: DesktopAuthStore = DesktopAuthStore()
|
||||||
|
) {
|
||||||
|
private var session: DesktopAuthSession? = store.load()
|
||||||
|
|
||||||
|
fun currentSession(): DesktopAuthSession? = session
|
||||||
|
|
||||||
|
suspend fun restoreSavedSession(): DesktopAuthSession? {
|
||||||
|
val restored = session ?: store.load()?.also { session = it }
|
||||||
|
return restored?.let { refreshSessionIfNeeded(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun signIn(openUrl: (String) -> Unit): DesktopAuthSession {
|
||||||
|
if (!config.isAuthConfigured) {
|
||||||
|
throw IllegalStateException("Desktop Google sign-in is not configured.")
|
||||||
|
}
|
||||||
|
val oauthCode = requestGoogleOAuthCode(openUrl)
|
||||||
|
val googleTokens = exchangeCodeForGoogleTokens(oauthCode.code, oauthCode.redirectUri, oauthCode.codeVerifier)
|
||||||
|
val existingGoogleRefreshToken = session?.googleRefreshToken.orEmpty()
|
||||||
|
val nextSession = signInWithFirebase(googleTokens.idToken).copy(
|
||||||
|
googleAccessToken = googleTokens.accessToken,
|
||||||
|
googleRefreshToken = googleTokens.refreshToken.ifBlank { existingGoogleRefreshToken },
|
||||||
|
googleAccessTokenExpiresAtEpochMillis = googleTokens.expiresAtEpochMillis
|
||||||
|
)
|
||||||
|
session = nextSession
|
||||||
|
persistSession(nextSession)
|
||||||
|
return nextSession
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
session = null
|
||||||
|
store.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun freshIdToken(): String? {
|
||||||
|
val current = session ?: store.load()?.also { session = it } ?: return null
|
||||||
|
return refreshSessionIfNeeded(current)?.idToken
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun freshGoogleAccessToken(): String? {
|
||||||
|
val current = session ?: store.load()?.also { session = it } ?: return null
|
||||||
|
if (current.isGoogleAccessTokenFresh) return current.googleAccessToken
|
||||||
|
if (current.googleRefreshToken.isBlank()) return null
|
||||||
|
val refreshed = runCatching {
|
||||||
|
refreshGoogleAccessToken(current)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
session = refreshed
|
||||||
|
persistSession(refreshed)
|
||||||
|
return refreshed.googleAccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshSessionIfNeeded(current: DesktopAuthSession): DesktopAuthSession? {
|
||||||
|
if (current.isFresh) return current
|
||||||
|
val refreshed = runCatching {
|
||||||
|
refreshFirebaseSession(current)
|
||||||
|
}.onFailure {
|
||||||
|
signOut()
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
session = refreshed
|
||||||
|
persistSession(refreshed)
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistSession(session: DesktopAuthSession) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
store.save(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun requestGoogleOAuthCode(openUrl: (String) -> Unit): DesktopOAuthCode = withContext(Dispatchers.IO) {
|
||||||
|
val codeVerifier = randomUrlToken(64)
|
||||||
|
val state = UUID.randomUUID().toString()
|
||||||
|
val server = HttpServer.create(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0)
|
||||||
|
val callback = CompletableFuture<Result<String>>()
|
||||||
|
val redirectUri = "http://127.0.0.1:${server.address.port}/callback"
|
||||||
|
server.executor = Executors.newSingleThreadExecutor()
|
||||||
|
server.createContext("/callback") { exchange ->
|
||||||
|
val params = exchange.requestURI.rawQuery.orEmpty().split("&")
|
||||||
|
.mapNotNull { part ->
|
||||||
|
val key = part.substringBefore("=", "")
|
||||||
|
val value = part.substringAfter("=", "")
|
||||||
|
key.takeIf { it.isNotBlank() }?.let { it to java.net.URLDecoder.decode(value, Charsets.UTF_8.name()) }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
val (title, message, result) = if (params["state"] != state) {
|
||||||
|
Triple(
|
||||||
|
"Google sign-in failed",
|
||||||
|
"Google sign-in could not be completed. Return to Episteme and try again.",
|
||||||
|
Result.failure(IllegalStateException("Google sign-in returned an invalid state."))
|
||||||
|
)
|
||||||
|
} else if (params["error"].isNullOrBlank().not()) {
|
||||||
|
Triple(
|
||||||
|
"Google sign-in failed",
|
||||||
|
"Google sign-in was cancelled or failed. Return to Episteme and try again.",
|
||||||
|
Result.failure(IllegalStateException(params["error"] ?: "Google sign-in failed."))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val code = params["code"].orEmpty()
|
||||||
|
if (code.isBlank()) {
|
||||||
|
Triple(
|
||||||
|
"Google sign-in failed",
|
||||||
|
"Google sign-in could not be completed. Return to Episteme and try again.",
|
||||||
|
Result.failure(IllegalStateException("Google sign-in did not return an authorization code."))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Triple(
|
||||||
|
"Google sign-in complete",
|
||||||
|
"Return to Episteme to continue.",
|
||||||
|
Result.success(code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = googleOAuthCallbackPage(title, message).toByteArray(Charsets.UTF_8)
|
||||||
|
try {
|
||||||
|
exchange.responseHeaders.add("Content-Type", "text/html; charset=UTF-8")
|
||||||
|
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(body) }
|
||||||
|
} finally {
|
||||||
|
callback.complete(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
try {
|
||||||
|
val authUrl = buildGoogleAuthUrl(
|
||||||
|
redirectUri = redirectUri,
|
||||||
|
codeVerifier = codeVerifier,
|
||||||
|
state = state
|
||||||
|
)
|
||||||
|
openUrl(authUrl)
|
||||||
|
val code = runCatching { callback.get(120, TimeUnit.SECONDS) }
|
||||||
|
.getOrElse { throw IllegalStateException("Google sign-in timed out.") }
|
||||||
|
.getOrThrow()
|
||||||
|
DesktopOAuthCode(code = code, redirectUri = redirectUri, codeVerifier = codeVerifier)
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
(server.executor as? java.util.concurrent.ExecutorService)?.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGoogleAuthUrl(
|
||||||
|
redirectUri: String,
|
||||||
|
codeVerifier: String,
|
||||||
|
state: String
|
||||||
|
): String {
|
||||||
|
val codeChallenge = Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString(MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray(Charsets.US_ASCII)))
|
||||||
|
return "https://accounts.google.com/o/oauth2/v2/auth?" + formEncode(
|
||||||
|
"client_id" to config.googleOAuthClientId,
|
||||||
|
"redirect_uri" to redirectUri,
|
||||||
|
"response_type" to "code",
|
||||||
|
"scope" to DesktopGoogleOAuthScopes,
|
||||||
|
"code_challenge" to codeChallenge,
|
||||||
|
"code_challenge_method" to "S256",
|
||||||
|
"state" to state,
|
||||||
|
"access_type" to "offline",
|
||||||
|
"prompt" to "consent select_account"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun exchangeCodeForGoogleTokens(
|
||||||
|
code: String,
|
||||||
|
redirectUri: String,
|
||||||
|
codeVerifier: String
|
||||||
|
): DesktopGoogleTokens = withContext(Dispatchers.IO) {
|
||||||
|
val tokenRequest = listOfNotNull(
|
||||||
|
"client_id" to config.googleOAuthClientId,
|
||||||
|
config.googleOAuthClientSecret.takeIf { it.isNotBlank() }?.let { "client_secret" to it },
|
||||||
|
"code" to code,
|
||||||
|
"code_verifier" to codeVerifier,
|
||||||
|
"grant_type" to "authorization_code",
|
||||||
|
"redirect_uri" to redirectUri
|
||||||
|
)
|
||||||
|
val response = postForm(
|
||||||
|
url = "https://oauth2.googleapis.com/token",
|
||||||
|
body = formEncode(tokenRequest)
|
||||||
|
)
|
||||||
|
val parsed = DesktopAuthJson.parseToJsonElement(response).jsonObject
|
||||||
|
val idToken = parsed.string("id_token")
|
||||||
|
?: throw IllegalStateException(parsed.string("error_description") ?: "Google sign-in did not return an ID token.")
|
||||||
|
val accessToken = parsed.string("access_token")
|
||||||
|
?: throw IllegalStateException(parsed.string("error_description") ?: "Google sign-in did not return a Drive access token.")
|
||||||
|
DesktopGoogleTokens(
|
||||||
|
idToken = idToken,
|
||||||
|
accessToken = accessToken,
|
||||||
|
refreshToken = parsed.string("refresh_token").orEmpty(),
|
||||||
|
expiresAtEpochMillis = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun signInWithFirebase(googleIdToken: String): DesktopAuthSession = withContext(Dispatchers.IO) {
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("postBody", JsonPrimitive("id_token=${urlEncode(googleIdToken)}&providerId=google.com"))
|
||||||
|
put("requestUri", JsonPrimitive("http://localhost"))
|
||||||
|
put("returnIdpCredential", JsonPrimitive(true))
|
||||||
|
put("returnSecureToken", JsonPrimitive(true))
|
||||||
|
}.toString()
|
||||||
|
val parsed = postJson(
|
||||||
|
url = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${urlEncode(config.firebaseWebApiKey)}",
|
||||||
|
body = payload
|
||||||
|
).let { DesktopAuthJson.parseToJsonElement(it).jsonObject }
|
||||||
|
|
||||||
|
val idToken = parsed.string("idToken")
|
||||||
|
?: throw IllegalStateException(parsed.errorMessage() ?: "Firebase sign-in failed.")
|
||||||
|
val refreshToken = parsed.string("refreshToken")
|
||||||
|
?: throw IllegalStateException("Firebase sign-in did not return a refresh token.")
|
||||||
|
val expiresAt = System.currentTimeMillis() + ((parsed.string("expiresIn")?.toLongOrNull() ?: 3600L) * 1000L)
|
||||||
|
val user = UserData(
|
||||||
|
uid = parsed.string("localId").orEmpty(),
|
||||||
|
displayName = parsed.string("displayName"),
|
||||||
|
photoUrl = parsed.string("photoUrl"),
|
||||||
|
email = parsed.string("email")
|
||||||
|
)
|
||||||
|
DesktopAuthSession(user = user, idToken = idToken, refreshToken = refreshToken, expiresAtEpochMillis = expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshFirebaseSession(current: DesktopAuthSession): DesktopAuthSession = withContext(Dispatchers.IO) {
|
||||||
|
val parsed = postForm(
|
||||||
|
url = "https://securetoken.googleapis.com/v1/token?key=${urlEncode(config.firebaseWebApiKey)}",
|
||||||
|
body = formEncode(
|
||||||
|
"grant_type" to "refresh_token",
|
||||||
|
"refresh_token" to current.refreshToken
|
||||||
|
)
|
||||||
|
).let { DesktopAuthJson.parseToJsonElement(it).jsonObject }
|
||||||
|
val idToken = parsed.string("id_token")
|
||||||
|
?: throw IllegalStateException(parsed.errorMessage() ?: "Could not refresh Google account session.")
|
||||||
|
val refreshToken = parsed.string("refresh_token") ?: current.refreshToken
|
||||||
|
val expiresAt = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L)
|
||||||
|
current.copy(
|
||||||
|
idToken = idToken,
|
||||||
|
refreshToken = refreshToken,
|
||||||
|
expiresAtEpochMillis = expiresAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshGoogleAccessToken(current: DesktopAuthSession): DesktopAuthSession = withContext(Dispatchers.IO) {
|
||||||
|
val tokenRequest = listOfNotNull(
|
||||||
|
"client_id" to config.googleOAuthClientId,
|
||||||
|
config.googleOAuthClientSecret.takeIf { it.isNotBlank() }?.let { "client_secret" to it },
|
||||||
|
"refresh_token" to current.googleRefreshToken,
|
||||||
|
"grant_type" to "refresh_token"
|
||||||
|
)
|
||||||
|
val parsed = postForm(
|
||||||
|
url = "https://oauth2.googleapis.com/token",
|
||||||
|
body = formEncode(tokenRequest)
|
||||||
|
).let { DesktopAuthJson.parseToJsonElement(it).jsonObject }
|
||||||
|
val accessToken = parsed.string("access_token")
|
||||||
|
?: throw IllegalStateException(parsed.string("error_description") ?: "Could not refresh Google Drive access.")
|
||||||
|
val expiresAt = System.currentTimeMillis() + ((parsed.string("expires_in")?.toLongOrNull() ?: 3600L) * 1000L)
|
||||||
|
current.copy(
|
||||||
|
googleAccessToken = accessToken,
|
||||||
|
googleAccessTokenExpiresAtEpochMillis = expiresAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DesktopOAuthCode(
|
||||||
|
val code: String,
|
||||||
|
val redirectUri: String,
|
||||||
|
val codeVerifier: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class DesktopGoogleTokens(
|
||||||
|
val idToken: String,
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val expiresAtEpochMillis: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun googleOAuthCallbackPage(title: String, message: String): String {
|
||||||
|
return """
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${title.escapeHtml()}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f7f4ef; color: #1f1b16; }
|
||||||
|
main { max-width: 34rem; padding: 2rem; text-align: center; }
|
||||||
|
h1 { margin: 0 0 0.75rem; font-size: 1.75rem; }
|
||||||
|
p { margin: 0; font-size: 1rem; line-height: 1.5; color: #5b5349; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>${title.escapeHtml()}</h1>
|
||||||
|
<p>${message.escapeHtml()}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopAuthStore(
|
||||||
|
private val settingsFile: File = File(desktopUserConfigRoot(), "auth.properties"),
|
||||||
|
private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform()
|
||||||
|
) {
|
||||||
|
fun load(): DesktopAuthSession? {
|
||||||
|
if (!settingsFile.isFile) return null
|
||||||
|
val properties = Properties()
|
||||||
|
return runCatching {
|
||||||
|
settingsFile.inputStream().use(properties::load)
|
||||||
|
val refreshTokenRef = properties.getProperty(RefreshTokenKey, "")
|
||||||
|
val refreshToken = refreshTokenRef.takeIf { it.isNotBlank() }
|
||||||
|
?.let { secretCodec.unprotect(RefreshTokenKey, it) }
|
||||||
|
.orEmpty()
|
||||||
|
val googleRefreshTokenRef = properties.getProperty(GoogleRefreshTokenKey, "")
|
||||||
|
val googleRefreshToken = googleRefreshTokenRef.takeIf { it.isNotBlank() }
|
||||||
|
?.let { secretCodec.unprotect(GoogleRefreshTokenKey, it) }
|
||||||
|
.orEmpty()
|
||||||
|
if (refreshToken.isBlank()) return null
|
||||||
|
DesktopAuthSession(
|
||||||
|
user = UserData(
|
||||||
|
uid = properties.getProperty("uid", ""),
|
||||||
|
displayName = properties.getProperty("displayName", "").takeIf { it.isNotBlank() },
|
||||||
|
photoUrl = properties.getProperty("photoUrl", "").takeIf { it.isNotBlank() },
|
||||||
|
email = properties.getProperty("email", "").takeIf { it.isNotBlank() }
|
||||||
|
),
|
||||||
|
idToken = "",
|
||||||
|
refreshToken = refreshToken,
|
||||||
|
expiresAtEpochMillis = 0L,
|
||||||
|
googleRefreshToken = googleRefreshToken
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(session: DesktopAuthSession) {
|
||||||
|
if (session.refreshToken.isBlank()) {
|
||||||
|
throw IllegalArgumentException("Cannot save a desktop account without a refresh token.")
|
||||||
|
}
|
||||||
|
val protectedTokens = runCatching {
|
||||||
|
ProtectedDesktopAuthTokens(
|
||||||
|
refreshToken = protectRequired(RefreshTokenKey, session.refreshToken),
|
||||||
|
googleRefreshToken = session.googleRefreshToken
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.let { protectRequired(GoogleRefreshTokenKey, it) }
|
||||||
|
)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
if (secretCodec.isAvailable) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
logDesktopCloudSync {
|
||||||
|
"desktop.auth.persist_skipped reason=secure_storage_unavailable codec=${secretCodec.name} " +
|
||||||
|
"error=\"${error.desktopTtsSummary()}\""
|
||||||
|
}
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val properties = Properties().apply {
|
||||||
|
setProperty("uid", session.user.uid)
|
||||||
|
setProperty("displayName", session.user.displayName.orEmpty())
|
||||||
|
setProperty("photoUrl", session.user.photoUrl.orEmpty())
|
||||||
|
setProperty("email", session.user.email.orEmpty())
|
||||||
|
setProperty(RefreshTokenKey, protectedTokens.refreshToken)
|
||||||
|
protectedTokens.googleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) }
|
||||||
|
}
|
||||||
|
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
secretCodec.delete(RefreshTokenKey)
|
||||||
|
secretCodec.delete(GoogleRefreshTokenKey)
|
||||||
|
settingsFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val RefreshTokenKey = "firebaseRefreshTokenProtected"
|
||||||
|
const val GoogleRefreshTokenKey = "googleRefreshTokenProtected"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ProtectedDesktopAuthTokens(
|
||||||
|
val refreshToken: String,
|
||||||
|
val googleRefreshToken: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun protectRequired(keyName: String, value: String): String {
|
||||||
|
val protectedValue = secretCodec.protect(keyName, value)
|
||||||
|
if (protectedValue.isBlank()) {
|
||||||
|
throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.")
|
||||||
|
}
|
||||||
|
return protectedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DesktopAuthJson = Json { ignoreUnknownKeys = true }
|
||||||
|
private const val DesktopGoogleOAuthScopes = "openid email profile https://www.googleapis.com/auth/drive.appdata"
|
||||||
|
|
||||||
|
private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
|
||||||
|
|
||||||
|
private fun JsonObject.errorMessage(): String? {
|
||||||
|
val error = this["error"].jsonObjectOrNull() ?: return null
|
||||||
|
return error.string("message")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomUrlToken(length: Int): String {
|
||||||
|
val bytes = ByteArray(length)
|
||||||
|
SecureRandom().nextBytes(bytes)
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postForm(url: String, body: String): String {
|
||||||
|
return postBody(url, body, "application/x-www-form-urlencoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postJson(url: String, body: String): String {
|
||||||
|
return postBody(url, body, "application/json; charset=UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postBody(url: String, body: String, contentType: String): String {
|
||||||
|
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
setRequestProperty("Content-Type", contentType)
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
doOutput = true
|
||||||
|
doInput = true
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||||
|
val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
val message = runCatching {
|
||||||
|
DesktopAuthJson.parseToJsonElement(text).jsonObject.errorMessage()
|
||||||
|
}.getOrNull()
|
||||||
|
throw IllegalStateException(message ?: "HTTP ${connection.responseCode}: ${text.take(240)}")
|
||||||
|
}
|
||||||
|
text
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formEncode(vararg pairs: Pair<String, String>): String {
|
||||||
|
return formEncode(pairs.asIterable())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formEncode(pairs: Iterable<Pair<String, String>>): String {
|
||||||
|
return pairs.joinToString("&") { (key, value) -> "${urlEncode(key)}=${urlEncode(value)}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
|
||||||
|
|
||||||
|
private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun String.escapeHtml(): String = buildString(length) {
|
||||||
|
this@escapeHtml.forEach { char ->
|
||||||
|
when (char) {
|
||||||
|
'&' -> append("&")
|
||||||
|
'<' -> append("<")
|
||||||
|
'>' -> append(">")
|
||||||
|
'"' -> append(""")
|
||||||
|
'\'' -> append("'")
|
||||||
|
else -> append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,664 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.BookItem
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader
|
||||||
|
import java.awt.Color
|
||||||
|
import java.awt.Font
|
||||||
|
import java.awt.GradientPaint
|
||||||
|
import java.awt.RenderingHints
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
data class DesktopFolderMetadataExtractionResult(
|
||||||
|
val books: List<BookItem>,
|
||||||
|
val stats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DesktopFolderMetadataExtractionStats(
|
||||||
|
val processedBooks: Int = 0,
|
||||||
|
val updatedBooks: Int = 0,
|
||||||
|
val coversUpdated: Int = 0,
|
||||||
|
val failedBooks: Int = 0
|
||||||
|
) {
|
||||||
|
operator fun plus(other: DesktopFolderMetadataExtractionStats): DesktopFolderMetadataExtractionStats {
|
||||||
|
return DesktopFolderMetadataExtractionStats(
|
||||||
|
processedBooks = processedBooks + other.processedBooks,
|
||||||
|
updatedBooks = updatedBooks + other.updatedBooks,
|
||||||
|
coversUpdated = coversUpdated + other.coversUpdated,
|
||||||
|
failedBooks = failedBooks + other.failedBooks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object DesktopFolderMetadataExtractor {
|
||||||
|
private val textMetadataTypes = setOf(
|
||||||
|
FileType.PDF,
|
||||||
|
FileType.EPUB,
|
||||||
|
FileType.HTML,
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.FB2,
|
||||||
|
FileType.DOCX,
|
||||||
|
FileType.ODT,
|
||||||
|
FileType.FODT
|
||||||
|
)
|
||||||
|
private val generatedCoverTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
|
fun enrichFolderBooks(
|
||||||
|
books: List<BookItem>,
|
||||||
|
sourceFolder: String
|
||||||
|
): DesktopFolderMetadataExtractionResult {
|
||||||
|
return enrichBooks(books) { book -> book.sourceFolder == sourceFolder }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enrichFolderBooks(
|
||||||
|
books: List<BookItem>,
|
||||||
|
sourceFolders: Set<String>
|
||||||
|
): DesktopFolderMetadataExtractionResult {
|
||||||
|
if (sourceFolders.isEmpty()) {
|
||||||
|
return DesktopFolderMetadataExtractionResult(books)
|
||||||
|
}
|
||||||
|
return enrichBooks(books) { book -> book.sourceFolder in sourceFolders }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enrichImportedBooks(
|
||||||
|
books: List<BookItem>,
|
||||||
|
importedBookIds: Set<String>
|
||||||
|
): DesktopFolderMetadataExtractionResult {
|
||||||
|
if (importedBookIds.isEmpty()) {
|
||||||
|
return DesktopFolderMetadataExtractionResult(books)
|
||||||
|
}
|
||||||
|
return enrichBooks(books) { book -> book.id in importedBookIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enrichOpenedBook(book: BookItem): BookItem {
|
||||||
|
if (!book.needsFolderMetadataExtraction()) return book
|
||||||
|
return runCatching { enrichBook(book) }.getOrDefault(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enrichBooks(
|
||||||
|
books: List<BookItem>,
|
||||||
|
shouldConsider: (BookItem) -> Boolean
|
||||||
|
): DesktopFolderMetadataExtractionResult {
|
||||||
|
var stats = DesktopFolderMetadataExtractionStats()
|
||||||
|
val updatedBooks = books.map { book ->
|
||||||
|
if (!shouldConsider(book) || !book.needsFolderMetadataExtraction()) {
|
||||||
|
return@map book
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = stats.copy(processedBooks = stats.processedBooks + 1)
|
||||||
|
val updated = runCatching { enrichBook(book) }
|
||||||
|
.onFailure { stats = stats.copy(failedBooks = stats.failedBooks + 1) }
|
||||||
|
.getOrDefault(book)
|
||||||
|
|
||||||
|
if (updated != book) {
|
||||||
|
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
|
||||||
|
if (updated.coverImagePath != book.coverImagePath) {
|
||||||
|
stats = stats.copy(coversUpdated = stats.coversUpdated + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
return DesktopFolderMetadataExtractionResult(updatedBooks, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookItem.needsFolderMetadataExtraction(): Boolean {
|
||||||
|
val path = path?.takeIf { it.isNotBlank() } ?: return false
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.isFile) return false
|
||||||
|
val needsTextMetadata = type in textMetadataTypes && !folderTextMetadataParsed
|
||||||
|
val needsCover = type in generatedCoverTypes && coverImagePath?.let { File(it).isFile } != true
|
||||||
|
return needsTextMetadata || needsCover
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enrichBook(book: BookItem): BookItem {
|
||||||
|
val file = File(book.path.orEmpty())
|
||||||
|
val size = file.length().takeIf { it > 0L } ?: book.fileSize
|
||||||
|
var extractedTitle: String? = null
|
||||||
|
var extractedAuthor: String? = null
|
||||||
|
var extractedDescription: String? = null
|
||||||
|
var extractedSeriesName: String? = null
|
||||||
|
var extractedSeriesIndex: Double? = null
|
||||||
|
var textMetadataParsed = book.folderTextMetadataParsed
|
||||||
|
var embeddedCover: EmbeddedCover? = null
|
||||||
|
|
||||||
|
when (book.type) {
|
||||||
|
FileType.EPUB -> {
|
||||||
|
val metadata = parseEpubMetadata(file)
|
||||||
|
extractedTitle = sanitizeTitle(metadata.title)
|
||||||
|
extractedAuthor = sanitizeAuthor(metadata.author)
|
||||||
|
extractedDescription = sanitizeDescription(metadata.description)
|
||||||
|
extractedSeriesName = sanitizeDescription(metadata.seriesName)
|
||||||
|
extractedSeriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 }
|
||||||
|
embeddedCover = metadata.cover
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
FileType.PDF -> {
|
||||||
|
val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull()
|
||||||
|
extractedTitle = sanitizeTitle(metadata?.title)
|
||||||
|
extractedAuthor = sanitizeAuthor(metadata?.author)
|
||||||
|
extractedDescription = sanitizeDescription(metadata?.description)
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
FileType.HTML -> {
|
||||||
|
extractedTitle = sanitizeTitle(parseHtmlTitle(file))
|
||||||
|
extractedDescription = sanitizeDescription(parseHtmlDescription(file))
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.FB2,
|
||||||
|
FileType.DOCX,
|
||||||
|
FileType.ODT,
|
||||||
|
FileType.FODT -> {
|
||||||
|
runCatching { SharedJvmBookLoader.load(file, book.type) }
|
||||||
|
.onSuccess { loaded ->
|
||||||
|
extractedTitle = sanitizeTitle(loaded.title)
|
||||||
|
extractedAuthor = sanitizeAuthor(loaded.author)
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
val coverPath = book.coverImagePath?.takeIf { File(it).isFile }
|
||||||
|
?: saveEmbeddedCover(book, embeddedCover)
|
||||||
|
?: renderReaderSurfaceCover(book, file)
|
||||||
|
?: saveGeneratedCover(book)
|
||||||
|
|
||||||
|
val nextTitle = if (book.shouldApplyExtractedTitle(file)) {
|
||||||
|
extractedTitle ?: book.title ?: file.nameWithoutExtension
|
||||||
|
} else {
|
||||||
|
book.title
|
||||||
|
}
|
||||||
|
val nextAuthor = if (book.shouldApplyExtractedText(book.author, book.originalAuthor)) {
|
||||||
|
extractedAuthor ?: book.author
|
||||||
|
} else {
|
||||||
|
book.author
|
||||||
|
}
|
||||||
|
val nextDescription = if (book.shouldApplyExtractedText(book.description, book.originalDescription)) {
|
||||||
|
extractedDescription ?: book.description
|
||||||
|
} else {
|
||||||
|
book.description
|
||||||
|
}
|
||||||
|
val nextSeriesName = if (book.shouldApplyExtractedText(book.seriesName, book.originalSeriesName)) {
|
||||||
|
extractedSeriesName ?: book.seriesName
|
||||||
|
} else {
|
||||||
|
book.seriesName
|
||||||
|
}
|
||||||
|
val nextSeriesIndex = if (book.seriesIndex == null || book.seriesIndex == book.originalSeriesIndex) {
|
||||||
|
extractedSeriesIndex ?: book.seriesIndex
|
||||||
|
} else {
|
||||||
|
book.seriesIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return book.copy(
|
||||||
|
title = nextTitle,
|
||||||
|
author = nextAuthor,
|
||||||
|
description = nextDescription,
|
||||||
|
seriesName = nextSeriesName,
|
||||||
|
seriesIndex = nextSeriesIndex,
|
||||||
|
originalTitle = book.originalTitle ?: extractedTitle,
|
||||||
|
originalAuthor = book.originalAuthor ?: extractedAuthor,
|
||||||
|
originalSeriesName = book.originalSeriesName ?: extractedSeriesName,
|
||||||
|
originalSeriesIndex = book.originalSeriesIndex ?: extractedSeriesIndex,
|
||||||
|
originalDescription = book.originalDescription ?: extractedDescription,
|
||||||
|
fileSize = size,
|
||||||
|
fileContentModifiedTimestamp = file.lastModified(),
|
||||||
|
coverImagePath = coverPath,
|
||||||
|
folderTextMetadataParsed = textMetadataParsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubMetadata(file: File): ExtractedBookMetadata {
|
||||||
|
ZipFile(file).use { zip ->
|
||||||
|
val containerXml = zip.readTextOrNull("META-INF/container.xml")
|
||||||
|
val opfPath = containerXml
|
||||||
|
?.let(::parseEpubRootfilePath)
|
||||||
|
?: zip.entries().asSequence()
|
||||||
|
.map { it.name }
|
||||||
|
.firstOrNull { it.endsWith(".opf", ignoreCase = true) }
|
||||||
|
?: return ExtractedBookMetadata()
|
||||||
|
val opf = zip.readTextOrNull(opfPath) ?: return ExtractedBookMetadata()
|
||||||
|
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
.let { if (it.isBlank()) "" else "$it/" }
|
||||||
|
val manifest = parseEpubManifest(opf)
|
||||||
|
val cover = findEpubCover(opf, manifest)
|
||||||
|
?.takeIf { it.isRasterCover }
|
||||||
|
?.let { item ->
|
||||||
|
val coverPath = normalizeZipPath(basePath + item.href)
|
||||||
|
zip.readBytesOrNull(coverPath)?.let { bytes ->
|
||||||
|
EmbeddedCover(bytes = bytes, extension = item.rasterExtension ?: "png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractedBookMetadata(
|
||||||
|
title = opf.tagText("title"),
|
||||||
|
author = opf.tagText("creator"),
|
||||||
|
description = opf.tagInnerContent("description"),
|
||||||
|
seriesName = opf.metaContent("calibre:series"),
|
||||||
|
seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(),
|
||||||
|
cover = cover
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubRootfilePath(containerXml: String): String? {
|
||||||
|
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(containerXml)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
|
||||||
|
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(opf)
|
||||||
|
.mapNotNull { match ->
|
||||||
|
val item = match.value
|
||||||
|
val id = item.attr("id")
|
||||||
|
val href = item.attr("href")
|
||||||
|
if (id.isBlank() || href.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
EpubManifestItem(
|
||||||
|
id = id,
|
||||||
|
href = href,
|
||||||
|
mediaType = item.attr("media-type"),
|
||||||
|
properties = item.attr("properties")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
|
||||||
|
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(opf)
|
||||||
|
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.attr("content")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
return manifest.firstOrNull { it.id == coverId }
|
||||||
|
?: manifest.firstOrNull { it.properties.split(Regex("\\s+")).any { property -> property == "cover-image" } }
|
||||||
|
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("cover", ignoreCase = true) }
|
||||||
|
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("front", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHtmlTitle(file: File): String? {
|
||||||
|
return runCatching {
|
||||||
|
val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
buildString {
|
||||||
|
var remaining = 64 * 1024
|
||||||
|
val buffer = CharArray(2048)
|
||||||
|
while (remaining > 0) {
|
||||||
|
val read = reader.read(buffer, 0, minOf(buffer.size, remaining))
|
||||||
|
if (read <= 0) break
|
||||||
|
append(buffer, 0, read)
|
||||||
|
remaining -= read
|
||||||
|
if (contains("</title>", ignoreCase = true)) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
head.tagText("title")
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHtmlDescription(file: File): String? {
|
||||||
|
return runCatching {
|
||||||
|
val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
buildString {
|
||||||
|
var remaining = 64 * 1024
|
||||||
|
val buffer = CharArray(2048)
|
||||||
|
while (remaining > 0) {
|
||||||
|
val read = reader.read(buffer, 0, minOf(buffer.size, remaining))
|
||||||
|
if (read <= 0) break
|
||||||
|
append(buffer, 0, read)
|
||||||
|
remaining -= read
|
||||||
|
if (contains("</head>", ignoreCase = true)) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(head)
|
||||||
|
.firstOrNull { meta ->
|
||||||
|
val name = meta.value.attr("name")
|
||||||
|
val property = meta.value.attr("property")
|
||||||
|
name.equals("description", ignoreCase = true) ||
|
||||||
|
property.equals("og:description", ignoreCase = true)
|
||||||
|
}
|
||||||
|
?.value
|
||||||
|
?.attr("content")
|
||||||
|
?.decodeEntities()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? {
|
||||||
|
if (cover == null || cover.bytes.isEmpty()) return null
|
||||||
|
val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null
|
||||||
|
return runCatching {
|
||||||
|
deleteExistingCoverFiles(book)
|
||||||
|
val target = coverCacheFile(book, extension)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val temp = File(target.parentFile, "${target.name}.tmp")
|
||||||
|
temp.writeBytes(cover.bytes)
|
||||||
|
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
target.absolutePath
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderReaderSurfaceCover(book: BookItem, file: File): String? {
|
||||||
|
if (book.type == FileType.PDF && !DesktopPdfium.isAvailable()) return null
|
||||||
|
if (book.type != FileType.PDF && !DesktopComicArchive.canLoad(book.type)) return null
|
||||||
|
return runCatching {
|
||||||
|
val document = if (book.type == FileType.PDF) {
|
||||||
|
DesktopPdfium.load(file)
|
||||||
|
} else {
|
||||||
|
DesktopPdfium.loadComic(file, book.type)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (document.pageCount <= 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val firstPage = document.pageSizes.first()
|
||||||
|
val scale = 800f / firstPage.height.coerceAtLeast(1f)
|
||||||
|
val image = DesktopPdfium.renderPageBufferedImage(
|
||||||
|
document = document,
|
||||||
|
pageIndex = 0,
|
||||||
|
scale = scale,
|
||||||
|
renderAnnotations = false
|
||||||
|
)
|
||||||
|
saveCoverImage(book, image)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
document.close()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveGeneratedCover(book: BookItem): String? {
|
||||||
|
if (book.type !in generatedCoverTypes) return null
|
||||||
|
return saveCoverImage(book, generatedCoverImage(book))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCoverImage(book: BookItem, image: BufferedImage): String? {
|
||||||
|
return runCatching {
|
||||||
|
deleteExistingCoverFiles(book)
|
||||||
|
val target = coverCacheFile(book, "png")
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val temp = File(target.parentFile, "${target.name}.tmp")
|
||||||
|
ImageIO.write(image, "png", temp)
|
||||||
|
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
target.absolutePath
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generatedCoverImage(book: BookItem): BufferedImage {
|
||||||
|
val width = 480
|
||||||
|
val height = 720
|
||||||
|
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val base = coverColor(book.type)
|
||||||
|
val title = book.title?.takeIf { it.isNotBlank() }
|
||||||
|
?: book.displayName.substringBeforeLast('.', missingDelimiterValue = book.displayName)
|
||||||
|
val author = book.author?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val g = image.createGraphics()
|
||||||
|
try {
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||||
|
g.paint = GradientPaint(0f, 0f, base.brighter(), 0f, height.toFloat(), base.darker())
|
||||||
|
g.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
g.color = Color(255, 255, 255, 36)
|
||||||
|
g.fillRoundRect(42, 42, width - 84, height - 84, 36, 36)
|
||||||
|
g.color = Color(255, 255, 255, 210)
|
||||||
|
g.font = Font("SansSerif", Font.BOLD, 34)
|
||||||
|
g.drawString(book.type.name, 64, 104)
|
||||||
|
|
||||||
|
g.font = Font("Serif", Font.BOLD, 48)
|
||||||
|
val titleLines = wrapText(title, g.fontMetrics, width - 128, maxLines = 6)
|
||||||
|
var y = 250
|
||||||
|
titleLines.forEach { line ->
|
||||||
|
g.drawString(line, 64, y)
|
||||||
|
y += 58
|
||||||
|
}
|
||||||
|
|
||||||
|
g.font = Font("SansSerif", Font.PLAIN, 28)
|
||||||
|
val footer = author ?: book.displayName
|
||||||
|
val footerLines = wrapText(footer, g.fontMetrics, width - 128, maxLines = 2)
|
||||||
|
val footerStart = max(y + 40, height - 150)
|
||||||
|
footerLines.forEachIndexed { index, line ->
|
||||||
|
g.drawString(line, 64, footerStart + index * 34)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
g.dispose()
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrapText(text: String, metrics: java.awt.FontMetrics, maxWidth: Int, maxLines: Int): List<String> {
|
||||||
|
val words = text.replace(Regex("\\s+"), " ").trim().split(' ').filter { it.isNotBlank() }
|
||||||
|
if (words.isEmpty()) return listOf("Untitled")
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
var current = ""
|
||||||
|
|
||||||
|
for (word in words) {
|
||||||
|
val candidate = if (current.isBlank()) word else "$current $word"
|
||||||
|
if (metrics.stringWidth(candidate) <= maxWidth) {
|
||||||
|
current = candidate
|
||||||
|
} else {
|
||||||
|
if (current.isNotBlank()) lines += current
|
||||||
|
current = trimToWidth(word, metrics, maxWidth)
|
||||||
|
}
|
||||||
|
if (lines.size == maxLines) break
|
||||||
|
}
|
||||||
|
if (lines.size < maxLines && current.isNotBlank()) lines += current
|
||||||
|
return lines.take(maxLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimToWidth(text: String, metrics: java.awt.FontMetrics, maxWidth: Int): String {
|
||||||
|
if (metrics.stringWidth(text) <= maxWidth) return text
|
||||||
|
var candidate = text
|
||||||
|
while (candidate.length > 1 && metrics.stringWidth("$candidate...") > maxWidth) {
|
||||||
|
candidate = candidate.dropLast(1)
|
||||||
|
}
|
||||||
|
return "$candidate..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coverColor(type: FileType): Color {
|
||||||
|
return when (type) {
|
||||||
|
FileType.PDF -> Color(156, 65, 70)
|
||||||
|
FileType.EPUB -> Color(0, 108, 76)
|
||||||
|
FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(112, 93, 73)
|
||||||
|
FileType.MD -> Color(83, 101, 120)
|
||||||
|
FileType.HTML -> Color(122, 87, 42)
|
||||||
|
FileType.TXT -> Color(74, 92, 112)
|
||||||
|
else -> Color(93, 107, 130)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coverCacheFile(book: BookItem, extension: String): File {
|
||||||
|
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
|
||||||
|
val hash = Integer.toUnsignedString(key.hashCode())
|
||||||
|
return File(coverCacheDir(), "cover_$hash.$extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteExistingCoverFiles(book: BookItem) {
|
||||||
|
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
|
||||||
|
val hash = Integer.toUnsignedString(key.hashCode())
|
||||||
|
coverCacheDir().listFiles()
|
||||||
|
?.filter { it.isFile && it.name.startsWith("cover_$hash.") }
|
||||||
|
?.forEach { runCatching { it.delete() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coverCacheDir(): File {
|
||||||
|
val overridePath = System.getProperty("reader.cover.cache.dir")
|
||||||
|
?: System.getenv("READER_COVER_CACHE_DIR")
|
||||||
|
if (!overridePath.isNullOrBlank()) {
|
||||||
|
return File(overridePath).apply { mkdirs() }
|
||||||
|
}
|
||||||
|
return File(desktopUserCacheRoot(), "cover_cache").apply { mkdirs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.readTextOrNull(path: String): String? {
|
||||||
|
val entry = getEntry(path) ?: return null
|
||||||
|
return getInputStream(entry).bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.readBytesOrNull(path: String): ByteArray? {
|
||||||
|
val entry = getEntry(path) ?: return null
|
||||||
|
return getInputStream(entry).use { it.readBytes() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.attr(name: String): String {
|
||||||
|
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.tagText(tag: String): String {
|
||||||
|
return Regex(
|
||||||
|
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
||||||
|
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
||||||
|
)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.replace(Regex("<[^>]+>"), " ")
|
||||||
|
?.decodeEntities()
|
||||||
|
?.replace(Regex("\\s+"), " ")
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.tagInnerContent(tag: String): String {
|
||||||
|
return Regex(
|
||||||
|
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
||||||
|
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
||||||
|
)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.trim()
|
||||||
|
?.removeSurrounding("<![CDATA[", "]]>")
|
||||||
|
?.decodeEntities()
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.metaContent(name: String): String? {
|
||||||
|
return Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(this)
|
||||||
|
.firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.attr("content")
|
||||||
|
?.decodeEntities()
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeEntities(): String {
|
||||||
|
return replace(" ", " ")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
.replace(Regex("&#(\\d+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeZipPath(path: String): String {
|
||||||
|
val parts = ArrayDeque<String>()
|
||||||
|
path.split('/').forEach { part ->
|
||||||
|
when (part) {
|
||||||
|
"", "." -> Unit
|
||||||
|
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||||
|
else -> parts.addLast(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeTitle(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeAuthor(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeDescription(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookItem.shouldApplyExtractedTitle(file: File): Boolean {
|
||||||
|
val current = title?.trim()
|
||||||
|
val fallback = file.nameWithoutExtension
|
||||||
|
return current.isNullOrBlank() || current == fallback || current == originalTitle?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookItem.shouldApplyExtractedText(current: String?, original: String?): Boolean {
|
||||||
|
val normalized = current?.trim()
|
||||||
|
return normalized.isNullOrBlank() || normalized == original?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val EpubManifestItem.isRasterCover: Boolean
|
||||||
|
get() = rasterExtension != null
|
||||||
|
|
||||||
|
private val EpubManifestItem.rasterExtension: String?
|
||||||
|
get() {
|
||||||
|
val extension = href.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
if (extension in rasterCoverExtensions) return extension
|
||||||
|
return when {
|
||||||
|
mediaType.equals("image/jpeg", ignoreCase = true) -> "jpg"
|
||||||
|
mediaType.equals("image/png", ignoreCase = true) -> "png"
|
||||||
|
mediaType.equals("image/gif", ignoreCase = true) -> "gif"
|
||||||
|
mediaType.equals("image/webp", ignoreCase = true) -> "webp"
|
||||||
|
mediaType.equals("image/bmp", ignoreCase = true) -> "bmp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ExtractedBookMetadata(
|
||||||
|
val title: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesIndex: Double? = null,
|
||||||
|
val cover: EmbeddedCover? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class EmbeddedCover(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val extension: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class EpubManifestItem(
|
||||||
|
val id: String,
|
||||||
|
val href: String,
|
||||||
|
val mediaType: String,
|
||||||
|
val properties: String
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||||
|
|
||||||
|
internal fun desktopFolderSyncCompletedState(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
message: String,
|
||||||
|
failedFolderCount: Int,
|
||||||
|
showBanner: Boolean
|
||||||
|
): SharedReaderScreenState {
|
||||||
|
return if (showBanner) {
|
||||||
|
state.withBanner(message, isError = failedFolderCount > 0)
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
private const val DesktopFolderSyncLogTag = "EpistemeFolderSync"
|
||||||
|
|
||||||
|
internal fun logDesktopFolderSync(message: String) {
|
||||||
|
logDesktopDiagnostic(DesktopFolderSyncLogTag) { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Throwable.folderSyncSummary(): String {
|
||||||
|
val type = this::class.java.simpleName.ifBlank { "Throwable" }
|
||||||
|
return "$type: ${message.orEmpty().folderSyncPreview(220)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.folderSyncPreview(maxLength: Int = 160): String {
|
||||||
|
return replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,829 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderAiByokSettings
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderTtsCacheSummary
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderTtsChunk
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderTtsFileCacheManager
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderTtsReadScope
|
||||||
|
import org.dueattendant149.bookreader.shared.TtsAdapter
|
||||||
|
import org.dueattendant149.bookreader.shared.createReaderTtsWavHeaderUnknownLength
|
||||||
|
import org.dueattendant149.bookreader.shared.patchReaderTtsWavHeader
|
||||||
|
import org.dueattendant149.bookreader.shared.splitReaderTextIntoTtsChunks
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.WebSocket
|
||||||
|
import java.net.http.WebSocketHandshakeException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.CompletionStage
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.sound.sampled.AudioFormat
|
||||||
|
import javax.sound.sampled.AudioSystem
|
||||||
|
import javax.sound.sampled.SourceDataLine
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
private data class DesktopTtsSequenceChunk(
|
||||||
|
val text: String,
|
||||||
|
val chapterTitle: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
class DesktopGeminiCloudTtsAdapter(
|
||||||
|
private val settingsProvider: () -> ReaderAiByokSettings,
|
||||||
|
private val networkAccess: () -> Boolean = { true },
|
||||||
|
private val workerUrlProvider: () -> String = { "" },
|
||||||
|
private val authTokenProvider: suspend () -> String? = { null },
|
||||||
|
private val useWorkerProvider: () -> Boolean = { true },
|
||||||
|
private val onWorkerUsageCompleted: suspend () -> Unit = {},
|
||||||
|
httpClient: HttpClient? = null,
|
||||||
|
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
|
||||||
|
) : TtsAdapter {
|
||||||
|
private val providedHttpClient = httpClient
|
||||||
|
private val httpClient: HttpClient by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||||
|
providedHttpClient ?: HttpClient.newHttpClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var activeLine: SourceDataLine? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var activeWebSocket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var activePlayer: DesktopStreamingPcmPlayer? = null
|
||||||
|
|
||||||
|
val isPlaybackActive: Boolean
|
||||||
|
get() = activePlayer != null || activeWebSocket != null || activeLine != null
|
||||||
|
|
||||||
|
override val isAvailable: Boolean
|
||||||
|
get() {
|
||||||
|
val settings = settingsProvider().sanitized()
|
||||||
|
return networkAccess() &&
|
||||||
|
(settings.isByokCloudTtsAvailable ||
|
||||||
|
(useWorkerProvider() && settings.serverBackedCloudTts && workerUrlProvider().isNotBlank()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun speak(text: String) {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
logDesktopTts("speak_start textChars=${trimmed.length}")
|
||||||
|
if (trimmed.isBlank()) return
|
||||||
|
speakSequence(splitReaderTextIntoTtsChunks(trimmed).ifEmpty { listOf(trimmed.take(5_000)) })
|
||||||
|
logDesktopTts("speak_finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun speakSequence(
|
||||||
|
texts: List<String>,
|
||||||
|
onChunkStart: suspend (Int) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val normalizedChunks = texts
|
||||||
|
.flatMap { text -> splitReaderTextIntoTtsChunks(text).ifEmpty { listOf(text.trim()) } }
|
||||||
|
.map { text -> DesktopTtsSequenceChunk(text = text.trim().take(5_000), chapterTitle = null) }
|
||||||
|
.filter { it.text.isNotBlank() }
|
||||||
|
logDesktopTts(
|
||||||
|
"sequence_speak_start chunks=${normalizedChunks.size} totalTextChars=${normalizedChunks.sumOf { it.text.length }}"
|
||||||
|
)
|
||||||
|
if (normalizedChunks.isEmpty()) return
|
||||||
|
val callbackContext = coroutineContext
|
||||||
|
stop()
|
||||||
|
streamSequence("Desktop selection", normalizedChunks, callbackContext, onChunkStart)
|
||||||
|
logDesktopTts("sequence_speak_finished chunks=${normalizedChunks.size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun speakChunks(
|
||||||
|
bookTitle: String,
|
||||||
|
readScope: ReaderTtsReadScope,
|
||||||
|
chunks: List<ReaderTtsChunk>,
|
||||||
|
onChunkStart: suspend (Int) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val sequenceChunks = chunks
|
||||||
|
.map { chunk ->
|
||||||
|
DesktopTtsSequenceChunk(
|
||||||
|
text = chunk.spokenText.trim().ifBlank { chunk.text.trim() }.take(5_000),
|
||||||
|
chapterTitle = chunk.chapterTitle.ifBlank { readScope.label }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { it.text.isNotBlank() }
|
||||||
|
logDesktopTtsStartTrace {
|
||||||
|
"event=adapter_speak_chunks book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
|
||||||
|
"inputChunks=${chunks.size} sequenceChunks=${sequenceChunks.size} " +
|
||||||
|
"inputFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " +
|
||||||
|
"sequenceFirstText=\"${sequenceChunks.firstOrNull()?.text.orEmpty().desktopTtsPreview(180)}\""
|
||||||
|
}
|
||||||
|
logDesktopTts(
|
||||||
|
"chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
|
||||||
|
"chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}"
|
||||||
|
)
|
||||||
|
if (sequenceChunks.isEmpty()) return
|
||||||
|
val callbackContext = coroutineContext
|
||||||
|
stop()
|
||||||
|
streamSequence(bookTitle.ifBlank { "Untitled" }, sequenceChunks, callbackContext, onChunkStart)
|
||||||
|
logDesktopTts("chunk_sequence_speak_finished chunks=${sequenceChunks.size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pause() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
activePlayer?.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resume() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
activePlayer?.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheSummary(bookTitle: String, speakerId: String? = settingsProvider().sanitized().ttsSpeakerId): ReaderTtsCacheSummary {
|
||||||
|
return cacheManager.getCacheSummary(bookTitle.ifBlank { "Untitled" }, speakerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String = settingsProvider().sanitized().ttsSpeakerId) {
|
||||||
|
cacheManager.clearBookCacheForSpeaker(bookTitle.ifBlank { "Untitled" }, speakerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBookCache(bookTitle: String) {
|
||||||
|
cacheManager.clearBookCache(bookTitle.ifBlank { "Untitled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stop() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
logDesktopTts("stop_requested hasWebSocket=${activeWebSocket != null} hasLine=${activeLine != null}")
|
||||||
|
runCatching { activeWebSocket?.abort() }
|
||||||
|
activeWebSocket = null
|
||||||
|
runCatching { activePlayer?.closeNow() }
|
||||||
|
activePlayer = null
|
||||||
|
runCatching { activeLine?.stop() }
|
||||||
|
runCatching { activeLine?.flush() }
|
||||||
|
runCatching { activeLine?.close() }
|
||||||
|
activeLine = null
|
||||||
|
logDesktopTts("stop_complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun streamSequence(
|
||||||
|
bookTitle: String,
|
||||||
|
chunks: List<DesktopTtsSequenceChunk>,
|
||||||
|
callbackContext: CoroutineContext,
|
||||||
|
onChunkStart: suspend (Int) -> Unit
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
val settings = settingsProvider().sanitized()
|
||||||
|
val useWorker = useWorkerProvider() && !settings.isByokCloudTtsAvailable
|
||||||
|
val totalTextChars = chunks.sumOf { it.text.length }
|
||||||
|
logDesktopTts(
|
||||||
|
"stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " +
|
||||||
|
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " +
|
||||||
|
"available=${settings.isCloudTtsAvailable} serverBacked=${settings.serverBackedCloudTts} worker=$useWorker"
|
||||||
|
)
|
||||||
|
if (!networkAccess()) {
|
||||||
|
logDesktopTts("stream_blocked reason=network_disabled")
|
||||||
|
throw IllegalStateException("Cloud TTS is unavailable in this desktop build.")
|
||||||
|
}
|
||||||
|
if (useWorker) {
|
||||||
|
if (!settings.serverBackedCloudTts || workerUrlProvider().isBlank()) {
|
||||||
|
logDesktopTts("stream_blocked reason=server_backed_not_available")
|
||||||
|
throw IllegalStateException("Cloud TTS needs a signed-in account with credits.")
|
||||||
|
}
|
||||||
|
} else if (!settings.isByokCloudTtsAvailable) {
|
||||||
|
logDesktopTts("stream_blocked reason=byok_not_available")
|
||||||
|
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
|
||||||
|
}
|
||||||
|
val authToken = if (useWorker) authTokenProvider() else null
|
||||||
|
if (useWorker && authToken.isNullOrBlank()) {
|
||||||
|
logDesktopTts("stream_blocked reason=missing_auth_token")
|
||||||
|
throw IllegalStateException("Sign in with Google to use cloud TTS.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioBytesReceived = AtomicLong(0)
|
||||||
|
val currentTurnAudioBytesReceived = AtomicLong(0)
|
||||||
|
val player = DesktopStreamingPcmPlayer { activeLine = it }
|
||||||
|
activePlayer = player
|
||||||
|
val setupComplete = CompletableDeferred<Unit>()
|
||||||
|
val currentTurnComplete = AtomicReference<CompletableDeferred<Unit>?>(null)
|
||||||
|
val activeCacheOutput = AtomicReference<FileOutputStream?>(null)
|
||||||
|
val failure = CompletableDeferred<Throwable>()
|
||||||
|
val messageBuffer = StringBuilder()
|
||||||
|
var webSocket: WebSocket? = null
|
||||||
|
var activeTempCacheFile: File? = null
|
||||||
|
var workerGeneratedAudio = false
|
||||||
|
|
||||||
|
fun handleMessage(message: String) {
|
||||||
|
handleGeminiTtsMessage(
|
||||||
|
message = message,
|
||||||
|
setupComplete = setupComplete,
|
||||||
|
turnComplete = currentTurnComplete.get(),
|
||||||
|
failure = failure,
|
||||||
|
onAudioPart = { bytes ->
|
||||||
|
audioBytesReceived.addAndGet(bytes.size.toLong())
|
||||||
|
currentTurnAudioBytesReceived.addAndGet(bytes.size.toLong())
|
||||||
|
activeCacheOutput.get()?.let { output ->
|
||||||
|
runCatching { output.write(bytes) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopTts("cache_write_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching { player.write(bytes) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopTts("stream_audio_write_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val listener = object : WebSocket.Listener {
|
||||||
|
override fun onOpen(webSocket: WebSocket) {
|
||||||
|
activeWebSocket = webSocket
|
||||||
|
webSocket.request(1)
|
||||||
|
logDesktopTts("ws_open send_setup model=\"$GEMINI_CLOUD_TTS_MODEL\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\"")
|
||||||
|
webSocket.sendText(buildGeminiTtsSetup(settings.ttsSpeakerId), true)
|
||||||
|
.whenComplete { _, error ->
|
||||||
|
if (error != null) {
|
||||||
|
logDesktopTts("ws_setup_send_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
} else {
|
||||||
|
logDesktopTts("ws_setup_send_complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*> {
|
||||||
|
messageBuffer.append(data)
|
||||||
|
logDesktopTts("ws_message_text chunkChars=${data.length} last=$last bufferChars=${messageBuffer.length}")
|
||||||
|
if (last) {
|
||||||
|
val message = messageBuffer.toString()
|
||||||
|
messageBuffer.clear()
|
||||||
|
handleMessage(message)
|
||||||
|
}
|
||||||
|
webSocket.request(1)
|
||||||
|
return CompletableFuture.completedFuture(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBinary(webSocket: WebSocket, data: ByteBuffer, last: Boolean): CompletionStage<*> {
|
||||||
|
val bytes = ByteArray(data.remaining())
|
||||||
|
data.get(bytes)
|
||||||
|
messageBuffer.append(bytes.decodeToString())
|
||||||
|
logDesktopTts("ws_message_binary chunkBytes=${bytes.size} last=$last bufferChars=${messageBuffer.length}")
|
||||||
|
if (last) {
|
||||||
|
val message = messageBuffer.toString()
|
||||||
|
messageBuffer.clear()
|
||||||
|
handleMessage(message)
|
||||||
|
}
|
||||||
|
webSocket.request(1)
|
||||||
|
return CompletableFuture.completedFuture(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(webSocket: WebSocket, error: Throwable) {
|
||||||
|
logDesktopTts("ws_error error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*> {
|
||||||
|
val activeTurn = currentTurnComplete.get()
|
||||||
|
logDesktopTts(
|
||||||
|
"ws_close status=$statusCode reason=\"${reason.desktopTtsPreview()}\" " +
|
||||||
|
"setupComplete=${setupComplete.isCompleted} turnComplete=${activeTurn?.isCompleted}"
|
||||||
|
)
|
||||||
|
if (!setupComplete.isCompleted && !failure.isCompleted) {
|
||||||
|
failure.complete(IllegalStateException("Cloud TTS connection closed before setup: $reason"))
|
||||||
|
} else if (activeTurn != null && !activeTurn.isCompleted && !failure.isCompleted) {
|
||||||
|
failure.complete(IllegalStateException("Cloud TTS connection closed: $reason"))
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ensureWebSocket(): WebSocket {
|
||||||
|
webSocket?.let { return it }
|
||||||
|
val uri = if (useWorker) {
|
||||||
|
val workerUrl = workerUrlProvider().removeSuffix("/")
|
||||||
|
val wsUrl = workerUrl
|
||||||
|
.replace("https://", "wss://")
|
||||||
|
.replace("http://", "ws://")
|
||||||
|
val speaker = URLEncoder.encode(settings.ttsSpeakerId, Charsets.UTF_8.name())
|
||||||
|
val token = URLEncoder.encode(authToken.orEmpty(), Charsets.UTF_8.name())
|
||||||
|
URI("$wsUrl/live?speaker=$speaker&token=$token")
|
||||||
|
} else {
|
||||||
|
val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name())
|
||||||
|
URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey")
|
||||||
|
}
|
||||||
|
logDesktopTts("ws_connect_start endpoint=${if (useWorker) "Worker" else "GeminiLive"} keyChars=${settings.geminiKey.length}")
|
||||||
|
val connectedWebSocket = runCatching {
|
||||||
|
httpClient.newWebSocketBuilder()
|
||||||
|
.buildAsync(uri, listener)
|
||||||
|
.get(15, TimeUnit.SECONDS)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
throw IllegalStateException(desktopTtsConnectionMessage(error), error)
|
||||||
|
}
|
||||||
|
activeWebSocket = connectedWebSocket
|
||||||
|
webSocket = connectedWebSocket
|
||||||
|
logDesktopTts("ws_connect_complete")
|
||||||
|
|
||||||
|
logDesktopTts("setup_wait_start timeoutMs=15000")
|
||||||
|
withTimeout(15_000) {
|
||||||
|
select<Unit> {
|
||||||
|
setupComplete.onAwait { }
|
||||||
|
failure.onAwait { throw it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logDesktopTts("setup_wait_complete")
|
||||||
|
return connectedWebSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val totalChunksByChapter = chunks.groupingBy { it.chapterTitle }.eachCount()
|
||||||
|
chunks.forEach { chunk ->
|
||||||
|
cacheManager.saveTotalChunks(
|
||||||
|
bookTitle = bookTitle,
|
||||||
|
chapterTitle = chunk.chapterTitle,
|
||||||
|
totalChunks = totalChunksByChapter[chunk.chapterTitle] ?: chunks.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
chunks.forEachIndexed { index, chunk ->
|
||||||
|
val text = chunk.text
|
||||||
|
val turnComplete = CompletableDeferred<Unit>()
|
||||||
|
currentTurnAudioBytesReceived.set(0)
|
||||||
|
currentTurnComplete.set(turnComplete)
|
||||||
|
logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}")
|
||||||
|
logDesktopTtsStartTrace {
|
||||||
|
"event=adapter_turn_start index=${index + 1}/${chunks.size} chapter=\"${chunk.chapterTitle.orEmpty().desktopTtsPreview()}\" " +
|
||||||
|
"textChars=${text.length} text=\"${text.desktopTtsPreview(220)}\""
|
||||||
|
}
|
||||||
|
withContext(callbackContext) {
|
||||||
|
onChunkStart(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cacheFile = cacheManager.getCacheFile(bookTitle, chunk.chapterTitle, text, settings.ttsSpeakerId)
|
||||||
|
if (cacheFile.exists() && cacheFile.length() > 44) {
|
||||||
|
logDesktopTts(
|
||||||
|
"cache_hit index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
|
||||||
|
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
|
||||||
|
)
|
||||||
|
val cachedBytes = playCachedWav(cacheFile, player)
|
||||||
|
currentTurnAudioBytesReceived.set(cachedBytes)
|
||||||
|
audioBytesReceived.addAndGet(cachedBytes)
|
||||||
|
logDesktopTts("cache_play_complete index=${index + 1}/${chunks.size} audioBytes=$cachedBytes")
|
||||||
|
currentTurnComplete.compareAndSet(turnComplete, null)
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
val socket = ensureWebSocket()
|
||||||
|
val tempCacheFile = File(cacheFile.absolutePath + ".tmp")
|
||||||
|
activeTempCacheFile = tempCacheFile
|
||||||
|
runCatching {
|
||||||
|
tempCacheFile.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(tempCacheFile).also { output ->
|
||||||
|
output.write(createReaderTtsWavHeaderUnknownLength(24_000))
|
||||||
|
activeCacheOutput.set(output)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
activeCacheOutput.set(null)
|
||||||
|
tempCacheFile.delete()
|
||||||
|
logDesktopTts("cache_prepare_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logDesktopTts("text_send_start index=${index + 1}/${chunks.size} textChars=${text.length}")
|
||||||
|
runCatching { socket.sendText(buildGeminiTtsTextInput(text), true).join() }
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopTts("text_send_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
logDesktopTts("text_send_complete index=${index + 1}/${chunks.size}")
|
||||||
|
|
||||||
|
val turnTimeoutMs = (30_000L + text.length * 80L).coerceIn(60_000L, 600_000L)
|
||||||
|
logDesktopTts("turn_wait_start index=${index + 1}/${chunks.size} timeoutMs=$turnTimeoutMs")
|
||||||
|
withTimeout(turnTimeoutMs) {
|
||||||
|
select<Unit> {
|
||||||
|
turnComplete.onAwait { }
|
||||||
|
failure.onAwait { throw it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val turnAudioBytes = currentTurnAudioBytesReceived.get()
|
||||||
|
logDesktopTts(
|
||||||
|
"turn_wait_complete index=${index + 1}/${chunks.size} " +
|
||||||
|
"turnAudioBytes=$turnAudioBytes totalAudioBytes=${audioBytesReceived.get()}"
|
||||||
|
)
|
||||||
|
if (turnAudioBytes == 0L) {
|
||||||
|
logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}")
|
||||||
|
throw IllegalStateException("Cloud TTS returned no audio for a text chunk.")
|
||||||
|
}
|
||||||
|
if (useWorker) {
|
||||||
|
workerGeneratedAudio = true
|
||||||
|
onWorkerUsageCompleted()
|
||||||
|
}
|
||||||
|
activeCacheOutput.getAndSet(null)?.close()
|
||||||
|
runCatching {
|
||||||
|
patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt())
|
||||||
|
if (cacheFile.exists()) cacheFile.delete()
|
||||||
|
if (!tempCacheFile.renameTo(cacheFile)) {
|
||||||
|
throw IllegalStateException("Could not move temp cache file into place.")
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
logDesktopTts(
|
||||||
|
"cache_store_complete index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
|
||||||
|
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
tempCacheFile.delete()
|
||||||
|
logDesktopTts("cache_store_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
}
|
||||||
|
activeTempCacheFile = null
|
||||||
|
} finally {
|
||||||
|
activeCacheOutput.getAndSet(null)?.let { output ->
|
||||||
|
runCatching { output.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentTurnComplete.compareAndSet(turnComplete, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioBytesReceived.get() == 0L) {
|
||||||
|
logDesktopTts("stream_failed reason=empty_audio")
|
||||||
|
throw IllegalStateException("Cloud TTS returned no audio.")
|
||||||
|
}
|
||||||
|
player.drainAndClose()
|
||||||
|
webSocket?.let { socket -> runCatching { socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join() } }
|
||||||
|
activeWebSocket = null
|
||||||
|
activePlayer = null
|
||||||
|
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
|
||||||
|
if (useWorker && workerGeneratedAudio) onWorkerUsageCompleted()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (useWorker && desktopTtsShouldRefreshAccountAfterError(error)) {
|
||||||
|
try {
|
||||||
|
onWorkerUsageCompleted()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Keep the original TTS failure as the visible error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentTurnComplete.set(null)
|
||||||
|
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }
|
||||||
|
activeTempCacheFile?.delete()
|
||||||
|
activeTempCacheFile = null
|
||||||
|
runCatching { webSocket?.abort() }
|
||||||
|
activeWebSocket = null
|
||||||
|
activePlayer = null
|
||||||
|
player.closeNow()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer): Long {
|
||||||
|
var totalBytes = 0L
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
var skipped = 0L
|
||||||
|
while (skipped < 44L) {
|
||||||
|
val next = input.skip(44L - skipped)
|
||||||
|
if (next <= 0L) break
|
||||||
|
skipped += next
|
||||||
|
}
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
while (true) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read <= 0) break
|
||||||
|
player.write(buffer.copyOf(read))
|
||||||
|
totalBytes += read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDesktopTtsCacheRoot(): File {
|
||||||
|
return File(desktopUserCacheRoot(), "TTS_Cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGeminiTtsSetup(speakerId: String): String {
|
||||||
|
val systemPrompt = """
|
||||||
|
You are a professional audiobook narrator.
|
||||||
|
Read the exact text provided, word for word, with neutral emotion and good pacing.
|
||||||
|
Do not add conversational filler, acknowledgments, extra words, summaries, or commentary.
|
||||||
|
Skip non-verbal symbols or formatting noise that cannot be read naturally.
|
||||||
|
""".trimIndent()
|
||||||
|
return buildJsonObject {
|
||||||
|
put(
|
||||||
|
"setup",
|
||||||
|
buildJsonObject {
|
||||||
|
put("model", JsonPrimitive("models/$GEMINI_CLOUD_TTS_MODEL"))
|
||||||
|
put(
|
||||||
|
"systemInstruction",
|
||||||
|
buildJsonObject {
|
||||||
|
put("parts", buildJsonArray {
|
||||||
|
add(buildJsonObject { put("text", JsonPrimitive(systemPrompt)) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"generationConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put("responseModalities", buildJsonArray { add(JsonPrimitive("AUDIO")) })
|
||||||
|
put(
|
||||||
|
"speechConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put(
|
||||||
|
"voiceConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put(
|
||||||
|
"prebuiltVoiceConfig",
|
||||||
|
buildJsonObject { put("voiceName", JsonPrimitive(speakerId)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGeminiTtsTextInput(text: String): String {
|
||||||
|
return buildJsonObject {
|
||||||
|
put(
|
||||||
|
"realtimeInput",
|
||||||
|
buildJsonObject {
|
||||||
|
put("text", JsonPrimitive(text))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGeminiTtsMessage(
|
||||||
|
message: String,
|
||||||
|
setupComplete: CompletableDeferred<Unit>,
|
||||||
|
turnComplete: CompletableDeferred<Unit>?,
|
||||||
|
failure: CompletableDeferred<Throwable>,
|
||||||
|
onAudioPart: (ByteArray) -> Unit
|
||||||
|
) {
|
||||||
|
logDesktopTts("message_handle chars=${message.length} preview=\"${message.desktopTtsPreview()}\"")
|
||||||
|
val json = runCatching { DesktopGeminiTtsJson.parseToJsonElement(message).jsonObject }.getOrElse { error ->
|
||||||
|
logDesktopTts("message_parse_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json["error"]?.let { error ->
|
||||||
|
logDesktopTts("message_provider_error body=\"${error.toString().desktopTtsPreview(300)}\"")
|
||||||
|
failure.complete(IllegalStateException(error.toString()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (json.containsKey("setupComplete") || json.containsKey("setup_complete")) {
|
||||||
|
logDesktopTts("message_setup_complete")
|
||||||
|
setupComplete.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverContent = json.jsonObjectValue("serverContent", "server_content") ?: return
|
||||||
|
val modelTurn = serverContent.jsonObjectValue("modelTurn", "model_turn")
|
||||||
|
val parts = modelTurn?.get("parts")?.jsonArray
|
||||||
|
parts?.forEach { part ->
|
||||||
|
val inlineData = part.jsonObjectOrNull()?.jsonObjectValue("inlineData", "inline_data")
|
||||||
|
val encoded = inlineData?.get("data")?.jsonPrimitive?.contentOrNull
|
||||||
|
if (!encoded.isNullOrBlank()) {
|
||||||
|
val decoded = Base64.getMimeDecoder().decode(encoded)
|
||||||
|
onAudioPart(decoded)
|
||||||
|
logDesktopTts("message_audio_part bytes=${decoded.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (serverContent.booleanValue("turnComplete", "turn_complete")) {
|
||||||
|
logDesktopTts("message_turn_complete")
|
||||||
|
turnComplete?.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DesktopGeminiTtsJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun JsonObject.jsonObjectValue(vararg keys: String): JsonObject? {
|
||||||
|
return keys.firstNotNullOfOrNull { key -> get(key) as? JsonObject }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.booleanValue(vararg keys: String): Boolean {
|
||||||
|
return keys.any { key -> get(key)?.jsonPrimitive?.booleanOrNull == true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||||
|
return this as? JsonObject
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.upsample16BitMonoLe2x(): ByteArray {
|
||||||
|
if (size < 2) return this
|
||||||
|
val sampleCount = size / 2
|
||||||
|
val output = ByteArray(sampleCount * 4)
|
||||||
|
var outputIndex = 0
|
||||||
|
fun sampleAt(index: Int): Int {
|
||||||
|
val byteIndex = index * 2
|
||||||
|
val lo = this[byteIndex].toInt() and 0xFF
|
||||||
|
val hi = this[byteIndex + 1].toInt()
|
||||||
|
return (hi shl 8) or lo
|
||||||
|
}
|
||||||
|
fun writeSample(sample: Int) {
|
||||||
|
output[outputIndex] = (sample and 0xFF).toByte()
|
||||||
|
output[outputIndex + 1] = ((sample shr 8) and 0xFF).toByte()
|
||||||
|
outputIndex += 2
|
||||||
|
}
|
||||||
|
for (index in 0 until sampleCount) {
|
||||||
|
val current = sampleAt(index)
|
||||||
|
val next = sampleAt((index + 1).coerceAtMost(sampleCount - 1))
|
||||||
|
writeSample(current)
|
||||||
|
writeSample(((current + next) / 2).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun desktopTtsConnectionMessage(error: Throwable): String {
|
||||||
|
val causes = generateSequence(error) { it.cause }.toList()
|
||||||
|
val handshake = causes.filterIsInstance<WebSocketHandshakeException>().firstOrNull()
|
||||||
|
return when (handshake?.response?.statusCode()) {
|
||||||
|
401 -> "Sign in again to use cloud TTS."
|
||||||
|
402 -> "Out of credits. Pro and credits can only be purchased from the Android app."
|
||||||
|
403 -> "Cloud TTS is unavailable for this account."
|
||||||
|
405 -> "Cloud TTS is not configured for this desktop build."
|
||||||
|
426 -> "Cloud TTS is not configured for this desktop build."
|
||||||
|
502 -> "Cloud TTS service is temporarily unavailable."
|
||||||
|
else -> {
|
||||||
|
val details = causes
|
||||||
|
.joinToString(" ") { it.message.orEmpty() }
|
||||||
|
.trim()
|
||||||
|
when {
|
||||||
|
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ->
|
||||||
|
"Out of credits. Pro and credits can only be purchased from the Android app."
|
||||||
|
details.contains("401") || details.contains("Unauthorized", ignoreCase = true) ->
|
||||||
|
"Sign in again to use cloud TTS."
|
||||||
|
else -> "Cloud TTS failed to connect."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun desktopTtsShouldRefreshAccountAfterError(error: Throwable): Boolean {
|
||||||
|
val details = generateSequence(error) { it.cause }
|
||||||
|
.joinToString(" ") { it.message.orEmpty() }
|
||||||
|
return details.contains("Out of credits", ignoreCase = true) ||
|
||||||
|
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ||
|
||||||
|
details.contains("402", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DesktopStreamingPcmPlayer(
|
||||||
|
private val onLineChanged: (SourceDataLine?) -> Unit
|
||||||
|
) {
|
||||||
|
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||||
|
private val stateLock = java.lang.Object()
|
||||||
|
private var line: SourceDataLine? = null
|
||||||
|
private var fallbackTo48Khz = true
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
@Volatile
|
||||||
|
private var paused = false
|
||||||
|
private var bytesWritten = 0L
|
||||||
|
|
||||||
|
init {
|
||||||
|
logDesktopTts("play_stream_start mixers=\"${availableAudioMixers().desktopTtsPreview(260)}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
synchronized(stateLock) {
|
||||||
|
if (closed || paused) return
|
||||||
|
paused = true
|
||||||
|
runCatching { line?.stop() }
|
||||||
|
logDesktopTts("play_stream_paused totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume() {
|
||||||
|
synchronized(stateLock) {
|
||||||
|
if (closed || !paused) return
|
||||||
|
paused = false
|
||||||
|
runCatching { line?.start() }
|
||||||
|
stateLock.notifyAll()
|
||||||
|
logDesktopTts("play_stream_resumed totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun write(pcm24Khz: ByteArray) {
|
||||||
|
if (closed || pcm24Khz.isEmpty()) return
|
||||||
|
waitIfPaused()
|
||||||
|
val activeLine = synchronized(stateLock) {
|
||||||
|
if (closed) return
|
||||||
|
line ?: openBestLine()
|
||||||
|
}
|
||||||
|
val bytes = if (fallbackTo48Khz) pcm24Khz.upsample16BitMonoLe2x() else pcm24Khz
|
||||||
|
var offset = 0
|
||||||
|
var lineStarted = activeLine.isRunning
|
||||||
|
val primeTargetBytes = (activeLine.bufferSize / 2).coerceAtLeast(8192)
|
||||||
|
while (offset < bytes.size && !closed) {
|
||||||
|
waitIfPaused()
|
||||||
|
val maxWrite = if (lineStarted) 8192 else primeTargetBytes
|
||||||
|
val written = activeLine.write(bytes, offset, (bytes.size - offset).coerceAtMost(maxWrite))
|
||||||
|
if (written <= 0) break
|
||||||
|
offset += written
|
||||||
|
bytesWritten += written
|
||||||
|
if (!lineStarted && (offset >= bytes.size || offset >= primeTargetBytes)) {
|
||||||
|
activeLine.start()
|
||||||
|
lineStarted = true
|
||||||
|
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lineStarted && !closed) {
|
||||||
|
activeLine.start()
|
||||||
|
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
|
||||||
|
}
|
||||||
|
logDesktopTts("play_stream_write inputBytes=${pcm24Khz.size} writtenBytes=$offset totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drainAndClose() {
|
||||||
|
val activeLine = line
|
||||||
|
if (activeLine != null && !closed) {
|
||||||
|
logDesktopTts("play_stream_drain totalWritten=$bytesWritten")
|
||||||
|
runCatching { activeLine.drain() }
|
||||||
|
.onFailure { error -> logDesktopTts("play_stream_drain_failed error=\"${error.desktopTtsSummary()}\"") }
|
||||||
|
}
|
||||||
|
closeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeNow() {
|
||||||
|
val activeLine = synchronized(stateLock) {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
paused = false
|
||||||
|
stateLock.notifyAll()
|
||||||
|
line.also { line = null }
|
||||||
|
}
|
||||||
|
activeLine?.let {
|
||||||
|
runCatching { it.stop() }
|
||||||
|
runCatching { it.flush() }
|
||||||
|
runCatching { it.close() }
|
||||||
|
}
|
||||||
|
onLineChanged(null)
|
||||||
|
logDesktopTts("play_stream_closed totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitIfPaused() {
|
||||||
|
synchronized(stateLock) {
|
||||||
|
while (paused && !closed) {
|
||||||
|
stateLock.wait(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openBestLine(): SourceDataLine {
|
||||||
|
fallbackTo48Khz = true
|
||||||
|
return runCatching {
|
||||||
|
openLine(48_000f)
|
||||||
|
}.getOrElse { firstError ->
|
||||||
|
logDesktopTts("play_primary_failed sampleRate=48000 error=\"${firstError.desktopTtsSummary()}\"")
|
||||||
|
fallbackTo48Khz = false
|
||||||
|
runCatching {
|
||||||
|
openLine(24_000f)
|
||||||
|
}.onFailure { secondError ->
|
||||||
|
logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"")
|
||||||
|
}.getOrElse {
|
||||||
|
throw firstError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openLine(sampleRate: Float): SourceDataLine {
|
||||||
|
val format = AudioFormat(sampleRate, 16, 1, true, false)
|
||||||
|
val bufferBytes = sampleRate.toInt().coerceAtLeast(16_384)
|
||||||
|
logDesktopTts("play_line_request sampleRate=${sampleRate.toInt()} bufferBytes=$bufferBytes")
|
||||||
|
val openedLine = AudioSystem.getSourceDataLine(format)
|
||||||
|
openedLine.open(format, bufferBytes)
|
||||||
|
line = openedLine
|
||||||
|
onLineChanged(openedLine)
|
||||||
|
logDesktopTts(
|
||||||
|
"play_line_opened sampleRate=${sampleRate.toInt()} output48Khz=$fallbackTo48Khz " +
|
||||||
|
"line=\"${openedLine.lineInfo.toString().desktopTtsPreview(160)}\""
|
||||||
|
)
|
||||||
|
return openedLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun availableAudioMixers(): String {
|
||||||
|
return runCatching {
|
||||||
|
AudioSystem.getMixerInfo()
|
||||||
|
.joinToString(limit = 8, truncated = "...") { "${it.name}/${it.description}" }
|
||||||
|
.ifBlank { "none" }
|
||||||
|
}.getOrDefault("unavailable")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.readerString
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
internal data class DesktopLanguageSettings(
|
||||||
|
val languageTag: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DesktopLanguageOption(
|
||||||
|
val languageTag: String?,
|
||||||
|
val labelKey: String,
|
||||||
|
val fallbackLabel: String
|
||||||
|
) {
|
||||||
|
val normalizedTag: String? = normalizeDesktopLanguageTag(languageTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val DesktopLanguageOptions = listOf(
|
||||||
|
DesktopLanguageOption(null, "language_system_default", "System default"),
|
||||||
|
DesktopLanguageOption("en", "language_english_default", "English (Default)"),
|
||||||
|
DesktopLanguageOption("ar", "language_arabic", "Arabic"),
|
||||||
|
DesktopLanguageOption("de", "language_german", "German"),
|
||||||
|
DesktopLanguageOption("tr", "language_turkish", "Turkish"),
|
||||||
|
DesktopLanguageOption("fr", "language_french", "French"),
|
||||||
|
DesktopLanguageOption("ru", "language_russian", "Russian"),
|
||||||
|
DesktopLanguageOption("be", "language_belarusian", "Belarusian"),
|
||||||
|
DesktopLanguageOption("es", "language_spanish", "Spanish"),
|
||||||
|
DesktopLanguageOption("pt-BR", "language_portuguese_brazilian", "Portuguese (Brazil)"),
|
||||||
|
DesktopLanguageOption("it", "language_italian", "Italian"),
|
||||||
|
DesktopLanguageOption("pl", "language_polish", "Polish"),
|
||||||
|
DesktopLanguageOption("vi", "language_vietnamese", "Vietnamese"),
|
||||||
|
DesktopLanguageOption("ja", "language_japanese", "Japanese"),
|
||||||
|
DesktopLanguageOption("ko", "language_korean", "Korean"),
|
||||||
|
DesktopLanguageOption("hi", "language_hindi", "Hindi"),
|
||||||
|
DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"),
|
||||||
|
DesktopLanguageOption("nl", "language_dutch", "Dutch"),
|
||||||
|
DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"),
|
||||||
|
DesktopLanguageOption("id", "language_indonesian", "Indonesian"),
|
||||||
|
DesktopLanguageOption("et", "language_estonian", "Estonian")
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption {
|
||||||
|
val normalized = normalizeDesktopLanguageTag(languageTag)
|
||||||
|
return DesktopLanguageOptions.firstOrNull { it.normalizedTag == normalized }
|
||||||
|
?: DesktopLanguageOptions.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopLanguageSettingsStore(
|
||||||
|
private val settingsFile: File = File(desktopUserConfigRoot(), "language.properties")
|
||||||
|
) {
|
||||||
|
fun load(): DesktopLanguageSettings {
|
||||||
|
if (!settingsFile.isFile) return DesktopLanguageSettings()
|
||||||
|
val properties = Properties()
|
||||||
|
return runCatching {
|
||||||
|
settingsFile.inputStream().use(properties::load)
|
||||||
|
DesktopLanguageSettings(
|
||||||
|
languageTag = normalizeDesktopLanguageTag(properties.getProperty(LanguageTag))
|
||||||
|
)
|
||||||
|
}.getOrDefault(DesktopLanguageSettings())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(settings: DesktopLanguageSettings) {
|
||||||
|
settingsFile.parentFile?.mkdirs()
|
||||||
|
val properties = Properties().apply {
|
||||||
|
normalizeDesktopLanguageTag(settings.languageTag)?.let { languageTag ->
|
||||||
|
setProperty(LanguageTag, languageTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsFile.outputStream().use { output ->
|
||||||
|
properties.store(output, "Episteme desktop language")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LanguageTag = "languageTag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopLanguageDialog(
|
||||||
|
selectedLanguageTag: String?,
|
||||||
|
onLanguageSelected: (String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val selectedOption = selectedDesktopLanguageOption(selectedLanguageTag)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(readerString("options_language", "Language"), fontWeight = FontWeight.Bold) },
|
||||||
|
text = {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.heightIn(max = 520.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
items(DesktopLanguageOptions, key = { it.languageTag ?: "system" }) { option ->
|
||||||
|
val selected = option.normalizedTag == selectedOption.normalizedTag
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border = if (selected) {
|
||||||
|
BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onLanguageSelected(option.normalizedTag)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
onLanguageSelected(option.normalizedTag)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(readerString(option.labelKey, option.fallbackLabel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (option != DesktopLanguageOptions.last()) {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(readerString("action_cancel", "Cancel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedLibrarySnapshotJson
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
|
||||||
|
class DesktopLibraryDatabase(
|
||||||
|
private val databaseFile: File = defaultDatabaseFile()
|
||||||
|
) {
|
||||||
|
fun load(): SharedLibrarySnapshot {
|
||||||
|
return loadFile(databaseFile)
|
||||||
|
?: loadFile(backupFile())
|
||||||
|
?: SharedLibrarySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(snapshot: SharedLibrarySnapshot) {
|
||||||
|
val encoded = SharedLibrarySnapshotJson.encode(snapshot)
|
||||||
|
databaseFile.writeTextAtomically(encoded)
|
||||||
|
runCatching {
|
||||||
|
backupFile().writeTextAtomically(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFile(file: File): SharedLibrarySnapshot? {
|
||||||
|
if (!file.isFile) return null
|
||||||
|
val raw = runCatching { file.readText() }.getOrNull() ?: return null
|
||||||
|
val isJsonObject = runCatching {
|
||||||
|
libraryDatabaseJson.parseToJsonElement(raw).jsonObject
|
||||||
|
}.isSuccess
|
||||||
|
if (!isJsonObject) return null
|
||||||
|
return SharedLibrarySnapshotJson.decodeOrEmpty(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backupFile(): File {
|
||||||
|
return File(databaseFile.parentFile ?: File("."), "${databaseFile.name}.bak")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaultDatabaseFile(): File {
|
||||||
|
return File(desktopUserDataRoot(), "library.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val libraryDatabaseJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
@ -0,0 +1,493 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.dueattendant149.bookreader.shared.AppAction
|
||||||
|
import org.dueattendant149.bookreader.shared.BannerMessage
|
||||||
|
import org.dueattendant149.bookreader.shared.BookItem
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFolderPathResolver
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||||
|
import org.dueattendant149.bookreader.shared.Shelf
|
||||||
|
import org.dueattendant149.bookreader.shared.SmartCollectionDefinition
|
||||||
|
import org.dueattendant149.bookreader.shared.SmartField
|
||||||
|
import org.dueattendant149.bookreader.shared.SmartOperator
|
||||||
|
import org.dueattendant149.bookreader.shared.SmartRule
|
||||||
|
import org.dueattendant149.bookreader.shared.Tag
|
||||||
|
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||||
|
import org.dueattendant149.bookreader.shared.reduce
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.NonReaderLibraryTab
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.SharedLibraryScreen
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField
|
||||||
|
import org.dueattendant149.bookreader.shared.ui.readerString
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean {
|
||||||
|
return title != updated.title ||
|
||||||
|
author != updated.author ||
|
||||||
|
description != updated.description ||
|
||||||
|
seriesName != updated.seriesName ||
|
||||||
|
seriesIndex != updated.seriesIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.toDesktopSafeFileName(): String {
|
||||||
|
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.desktopSuggestedOriginalFileName(): String {
|
||||||
|
val extension = path
|
||||||
|
?.let(::File)
|
||||||
|
?.extension
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: SharedFileCapabilities.primaryExtensionFor(type)
|
||||||
|
val safeName = displayName
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?: title?.takeIf { it.isNotBlank() }
|
||||||
|
?: "book"
|
||||||
|
val sanitized = safeName.toDesktopSafeFileName()
|
||||||
|
return if (extension != null && !sanitized.endsWith(".$extension", ignoreCase = true)) {
|
||||||
|
"$sanitized.$extension"
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.withDesktopImportMetadata(
|
||||||
|
enriched: BookItem,
|
||||||
|
original: BookItem?
|
||||||
|
): BookItem {
|
||||||
|
fun shouldApplyText(current: String?, originalValue: String?): Boolean {
|
||||||
|
return current.isNullOrBlank() || current == originalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy(
|
||||||
|
title = if (shouldApplyText(title, original?.title)) {
|
||||||
|
enriched.title ?: title
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
},
|
||||||
|
author = if (shouldApplyText(author, original?.author)) {
|
||||||
|
enriched.author ?: author
|
||||||
|
} else {
|
||||||
|
author
|
||||||
|
},
|
||||||
|
description = if (shouldApplyText(description, original?.description)) {
|
||||||
|
enriched.description ?: description
|
||||||
|
} else {
|
||||||
|
description
|
||||||
|
},
|
||||||
|
seriesName = if (shouldApplyText(seriesName, original?.seriesName)) {
|
||||||
|
enriched.seriesName ?: seriesName
|
||||||
|
} else {
|
||||||
|
seriesName
|
||||||
|
},
|
||||||
|
seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) {
|
||||||
|
enriched.seriesIndex ?: seriesIndex
|
||||||
|
} else {
|
||||||
|
seriesIndex
|
||||||
|
},
|
||||||
|
originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title,
|
||||||
|
originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author,
|
||||||
|
originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName,
|
||||||
|
originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex,
|
||||||
|
originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description,
|
||||||
|
fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize,
|
||||||
|
fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L }
|
||||||
|
?: fileContentModifiedTimestamp,
|
||||||
|
coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolvedDesktopReaderSettings(
|
||||||
|
book: BookItem,
|
||||||
|
readerDefaultSettings: ReaderSettings
|
||||||
|
): ReaderSettings {
|
||||||
|
return book.readerSettings ?: readerDefaultSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DesktopReaderOpeningScreen(
|
||||||
|
opening: DesktopReaderOpening,
|
||||||
|
readerSettings: ReaderSettings? = null
|
||||||
|
) {
|
||||||
|
LaunchedEffect(opening.requestId) {
|
||||||
|
logDesktopReaderOpenTrace {
|
||||||
|
opening.openTracePrefix("desktop_opening_screen_composed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val background = readerSettings?.desktopOpeningBackgroundColor() ?: MaterialTheme.colorScheme.background
|
||||||
|
val foreground = readerSettings?.desktopOpeningForegroundColor() ?: MaterialTheme.colorScheme.onBackground
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(background)
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = foreground)
|
||||||
|
Text(
|
||||||
|
text = readerString("desktop_opening_title", "Opening %1\$s", opening.title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = foreground,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = opening.formatLabel,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = foreground.copy(alpha = 0.72f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderSettings.desktopOpeningBackgroundColor(): Color {
|
||||||
|
return backgroundColorArgb?.toDesktopOpeningComposeColor()
|
||||||
|
?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderSettings.desktopOpeningForegroundColor(): Color {
|
||||||
|
return textColorArgb?.toDesktopOpeningComposeColor()
|
||||||
|
?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.toDesktopOpeningComposeColor(): Color {
|
||||||
|
val value = this and 0xFFFFFFFFL
|
||||||
|
val alpha = ((value shr 24) and 0xFF) / 255f
|
||||||
|
val red = ((value shr 16) and 0xFF) / 255f
|
||||||
|
val green = ((value shr 8) and 0xFF) / 255f
|
||||||
|
val blue = (value and 0xFF) / 255f
|
||||||
|
return Color(red = red, green = green, blue = blue, alpha = alpha.takeIf { it > 0f } ?: 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LibraryScreen(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
selectedLibraryTab: NonReaderLibraryTab,
|
||||||
|
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
|
||||||
|
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||||
|
onImportBooks: () -> Unit,
|
||||||
|
onRead: (BookItem) -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onClearSelection: () -> Unit,
|
||||||
|
onRemoveSelected: () -> Unit,
|
||||||
|
onShowBookInfo: (BookItem) -> Unit,
|
||||||
|
onEditBook: (BookItem) -> Unit,
|
||||||
|
onCreateShelf: () -> Unit,
|
||||||
|
onCreateShelfWithBooks: (String, Set<String>) -> Unit,
|
||||||
|
onCreateSmartShelf: () -> Unit,
|
||||||
|
onRenameShelf: (Shelf) -> Unit,
|
||||||
|
onDeleteShelf: (Shelf) -> Unit,
|
||||||
|
onRemoveFolder: (Shelf) -> Unit,
|
||||||
|
onTagSelectedBooks: () -> Unit,
|
||||||
|
onAddSelectedBooksToShelf: () -> Unit,
|
||||||
|
onAddBooksToShelf: (Set<String>) -> Unit,
|
||||||
|
onManageShelfBooks: (Shelf) -> Unit,
|
||||||
|
onImportFolder: () -> Unit,
|
||||||
|
onSyncFolderMetadata: () -> Unit,
|
||||||
|
onScanFolders: () -> Unit,
|
||||||
|
onTogglePinned: (BookItem) -> Unit,
|
||||||
|
onSaveOriginalFile: (BookItem) -> Unit = {}
|
||||||
|
) {
|
||||||
|
SharedLibraryScreen(
|
||||||
|
state = state,
|
||||||
|
selectedTab = selectedLibraryTab,
|
||||||
|
onTabChange = onLibraryTabChange,
|
||||||
|
onStateChange = onStateChange,
|
||||||
|
onImportBooks = onImportBooks,
|
||||||
|
onOpenBook = onRead,
|
||||||
|
onToggleSelection = onSelect,
|
||||||
|
onClearSelection = onClearSelection,
|
||||||
|
onRemoveSelected = onRemoveSelected,
|
||||||
|
onShowBookInfo = onShowBookInfo,
|
||||||
|
onEditBook = onEditBook,
|
||||||
|
onCreateShelf = onCreateShelf,
|
||||||
|
onCreateShelfWithBooks = onCreateShelfWithBooks,
|
||||||
|
onCreateSmartShelf = onCreateSmartShelf,
|
||||||
|
onRenameShelf = onRenameShelf,
|
||||||
|
onDeleteShelf = onDeleteShelf,
|
||||||
|
onRemoveFolder = onRemoveFolder,
|
||||||
|
onTagSelectedBooks = onTagSelectedBooks,
|
||||||
|
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
||||||
|
onAddBooksToShelf = onAddBooksToShelf,
|
||||||
|
onManageShelfBooks = onManageShelfBooks,
|
||||||
|
onImportFolder = onImportFolder,
|
||||||
|
onSyncFolderMetadata = onSyncFolderMetadata,
|
||||||
|
onScanFolders = onScanFolders,
|
||||||
|
onTogglePinned = onTogglePinned,
|
||||||
|
onSaveOriginalFile = onSaveOriginalFile,
|
||||||
|
platform = ReaderPlatform.DESKTOP,
|
||||||
|
useImportEmptyStateWhenLibraryEmpty = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DesktopSmartRuleDraft(
|
||||||
|
val field: SmartField = SmartField.TITLE,
|
||||||
|
val operator: SmartOperator = SmartOperator.CONTAINS,
|
||||||
|
val value: String = ""
|
||||||
|
) {
|
||||||
|
fun toRule(): SmartRule? {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
return SmartRule(field = field, operator = operator, value = trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SmartShelfDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String, SmartCollectionDefinition) -> Unit
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var matchAll by remember { mutableStateOf(true) }
|
||||||
|
var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) }
|
||||||
|
val validRules = rules.mapNotNull { it.toRule() }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(readerString("desktop_create_smart_shelf", "Create smart shelf")) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(readerString("shelf_name_hint", "Shelf name")) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
FilterChip(
|
||||||
|
selected = matchAll,
|
||||||
|
onClick = { matchAll = true },
|
||||||
|
label = { Text(readerString("filter_all", "All")) }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = !matchAll,
|
||||||
|
onClick = { matchAll = false },
|
||||||
|
label = { Text(readerString("desktop_match_any", "Any")) }
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
onClick = { rules = rules + DesktopSmartRuleDraft() },
|
||||||
|
enabled = rules.size < 4
|
||||||
|
) {
|
||||||
|
Text(readerString("tts_replacements_add_rule", "Add rule"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules.forEachIndexed { index, draft ->
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SmartRuleDropdown(
|
||||||
|
label = readerString("desktop_field", "Field"),
|
||||||
|
selected = draft.field,
|
||||||
|
options = SmartField.entries.toList(),
|
||||||
|
optionLabel = { it.localizedLabel() },
|
||||||
|
onSelected = { field ->
|
||||||
|
rules = rules.updateAt(index) {
|
||||||
|
val operator = smartOperatorsFor(field).first()
|
||||||
|
copy(field = field, operator = operator, value = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SmartRuleDropdown(
|
||||||
|
label = readerString("desktop_operator", "Operator"),
|
||||||
|
selected = draft.operator,
|
||||||
|
options = smartOperatorsFor(draft.field),
|
||||||
|
optionLabel = { it.localizedLabel() },
|
||||||
|
onSelected = { operator ->
|
||||||
|
rules = rules.updateAt(index) { copy(operator = operator) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (rules.size > 1) {
|
||||||
|
TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) {
|
||||||
|
Text(readerString("action_remove", "Remove"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedStableOutlinedTextField(
|
||||||
|
value = draft.value,
|
||||||
|
onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } },
|
||||||
|
label = { Text(draft.field.localizedValueLabel()) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
selectionKey = index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules))
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank() && validRules.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text(readerString("action_create", "Create"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(readerString("action_cancel", "Cancel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> SmartRuleDropdown(
|
||||||
|
label: String,
|
||||||
|
selected: T,
|
||||||
|
options: List<T>,
|
||||||
|
optionLabel: @Composable (T) -> String,
|
||||||
|
onSelected: (T) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
TextButton(onClick = { expanded = true }) {
|
||||||
|
val selectedLabel = optionLabel(selected)
|
||||||
|
Text(readerString("filter_facet", "%1\$s: %2\$s", label, selectedLabel))
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(optionLabel(option)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelected(option)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smartOperatorsFor(field: SmartField): List<SmartOperator> {
|
||||||
|
return when (field) {
|
||||||
|
SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS)
|
||||||
|
else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SmartField.localizedLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
SmartField.TITLE -> readerString("label_title", "Title")
|
||||||
|
SmartField.AUTHOR -> readerString("author", "Author")
|
||||||
|
SmartField.PROGRESS -> readerString("desktop_progress", "Progress")
|
||||||
|
SmartField.FILE_TYPE -> readerString("filter_file_type", "File type")
|
||||||
|
SmartField.FOLDER -> readerString("desktop_smart_field_folder", "Folder")
|
||||||
|
SmartField.TAG -> readerString("content_desc_tag", "Tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SmartField.localizedValueLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
SmartField.PROGRESS -> readerString("desktop_percent", "Percent")
|
||||||
|
SmartField.FILE_TYPE -> readerString("desktop_type_example_pdf", "Type, e.g. PDF")
|
||||||
|
SmartField.FOLDER -> readerString("desktop_folder_path", "Folder path")
|
||||||
|
SmartField.TAG -> readerString("desktop_tag_name", "Tag name")
|
||||||
|
SmartField.TITLE -> readerString("desktop_title_text", "Title text")
|
||||||
|
SmartField.AUTHOR -> readerString("desktop_author_text", "Author text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SmartOperator.localizedLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
SmartOperator.EQUALS -> readerString("desktop_equals", "Equals")
|
||||||
|
SmartOperator.CONTAINS -> readerString("desktop_contains", "Contains")
|
||||||
|
SmartOperator.GREATER_THAN -> readerString("desktop_greater_than", "Greater than")
|
||||||
|
SmartOperator.LESS_THAN -> readerString("desktop_less_than", "Less than")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun List<DesktopSmartRuleDraft>.updateAt(
|
||||||
|
index: Int,
|
||||||
|
transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft
|
||||||
|
): List<DesktopSmartRuleDraft> {
|
||||||
|
return mapIndexed { i, draft -> if (i == index) draft.transform() else draft }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState {
|
||||||
|
return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError)))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object DesktopFolderPathResolver : SharedFolderPathResolver {
|
||||||
|
override fun relativeFolderSegments(item: BookItem): List<String> {
|
||||||
|
val sourceFolder = item.sourceFolder ?: return emptyList()
|
||||||
|
val bookPath = item.path ?: return emptyList()
|
||||||
|
val parentFile = File(bookPath).parentFile ?: return emptyList()
|
||||||
|
val paths = runCatching {
|
||||||
|
File(sourceFolder).toPath().toAbsolutePath().normalize() to
|
||||||
|
parentFile.toPath().toAbsolutePath().normalize()
|
||||||
|
}.getOrNull() ?: return emptyList()
|
||||||
|
val (root, parent) = paths
|
||||||
|
if (!parent.startsWith(root) || parent == root) return emptyList()
|
||||||
|
return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<BookItem>.collectTags(): List<Tag> {
|
||||||
|
return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BookItem.cardTitleForMessage(): String {
|
||||||
|
return title?.takeIf { it.isNotBlank() } ?: displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Long.toReadableSize(): String {
|
||||||
|
if (this <= 0L) return "Unknown"
|
||||||
|
val units = listOf("B", "KB", "MB", "GB", "TB")
|
||||||
|
var value = this.toDouble()
|
||||||
|
var unitIndex = 0
|
||||||
|
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||||
|
value /= 1024.0
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return if (unitIndex == 0) {
|
||||||
|
"$this ${units[unitIndex]}"
|
||||||
|
} else {
|
||||||
|
"${String.format("%.1f", value)} ${units[unitIndex]}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,951 @@
|
||||||
|
package org.dueattendant149.bookreader.desktop
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.shared.BookItem
|
||||||
|
import org.dueattendant149.bookreader.shared.BookShelfRef
|
||||||
|
import org.dueattendant149.bookreader.shared.FileType
|
||||||
|
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
|
||||||
|
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
|
||||||
|
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
|
||||||
|
import org.dueattendant149.bookreader.shared.LocalFolderSyncStats
|
||||||
|
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
|
||||||
|
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||||
|
import org.dueattendant149.bookreader.shared.SyncedFolder
|
||||||
|
import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationFileName
|
||||||
|
import org.dueattendant149.bookreader.shared.localFolderSyncAnnotationTempFileName
|
||||||
|
import org.dueattendant149.bookreader.shared.localFolderSyncMetadataFileName
|
||||||
|
import org.dueattendant149.bookreader.shared.localFolderSyncMetadataTempFileName
|
||||||
|
import org.dueattendant149.bookreader.shared.localFolderSyncSidecarStem
|
||||||
|
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer
|
||||||
|
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||||
|
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog
|
||||||
|
import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer
|
||||||
|
import org.dueattendant149.bookreader.shared.toSharedFolderBookMetadata
|
||||||
|
import org.dueattendant149.bookreader.shared.toStablePositionCfi
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
data class DesktopLocalFolderSyncResult(
|
||||||
|
val state: SharedReaderScreenState,
|
||||||
|
val shelfRefs: List<BookShelfRef>,
|
||||||
|
val stats: LocalFolderSyncStats,
|
||||||
|
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
|
||||||
|
val idMigrations: Map<String, String> = emptyMap(),
|
||||||
|
val removedBookIds: Set<String> = emptySet(),
|
||||||
|
val failedFolders: List<String> = emptyList(),
|
||||||
|
val processedFolderUris: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
object DesktopLocalFolderSync {
|
||||||
|
private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
|
||||||
|
fun hasSupportedFiles(folder: File): Boolean {
|
||||||
|
if (!folder.isDirectory) return false
|
||||||
|
return folder.walkTopDown()
|
||||||
|
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
|
||||||
|
.onFail { file, error ->
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.supportedFiles.skipInaccessible path=\"${file.absolutePath.folderSyncPreview()}\" " +
|
||||||
|
"error=${error.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.any { file ->
|
||||||
|
runCatching {
|
||||||
|
file.isFile &&
|
||||||
|
file.shouldSyncBookFile() &&
|
||||||
|
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
shelfRefs: List<BookShelfRef>,
|
||||||
|
targetFolder: File? = null,
|
||||||
|
nowMillis: Long = System.currentTimeMillis(),
|
||||||
|
metadataOnly: Boolean = false,
|
||||||
|
extractMetadata: Boolean = true
|
||||||
|
): DesktopLocalFolderSyncResult {
|
||||||
|
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
|
||||||
|
.filter { it.localSyncEnabled }
|
||||||
|
val mode = if (metadataOnly) "metadata" else "full"
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " +
|
||||||
|
"requestedFolders=${requestedFolders.size} linkedFolders=${state.syncedFolders.size} " +
|
||||||
|
"books=${state.rawLibraryBooks.size}"
|
||||||
|
)
|
||||||
|
var nextState = state
|
||||||
|
var nextShelfRefs = shelfRefs
|
||||||
|
var totalStats = LocalFolderSyncStats()
|
||||||
|
var totalMetadataStats = DesktopFolderMetadataExtractionStats()
|
||||||
|
val allMigrations = linkedMapOf<String, String>()
|
||||||
|
val allRemovedBookIds = linkedSetOf<String>()
|
||||||
|
val failedFolders = mutableListOf<String>()
|
||||||
|
val processedFolderUris = mutableListOf<String>()
|
||||||
|
|
||||||
|
requestedFolders.forEach { folder ->
|
||||||
|
val root = File(folder.uriString)
|
||||||
|
if (!root.isDirectory) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.skipMissing mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||||
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
failedFolders += folder.name
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
processedFolderUris += folder.uriString
|
||||||
|
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||||
|
"root=\"${root.absolutePath.folderSyncPreview()}\" allowed=${folder.allowedFileTypes.sortedBy { it.name }}"
|
||||||
|
)
|
||||||
|
val scannedFiles = if (metadataOnly) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
scanFolder(root = root, sourceFolder = folder.uriString)
|
||||||
|
}
|
||||||
|
val remoteMetadata = readAllMetadata(root)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.inputs mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||||
|
"scanned=${scannedFiles.size} supported=${scannedFiles.count { it.type in folder.allowedFileTypes }} " +
|
||||||
|
"remoteMetadata=${remoteMetadata.size}"
|
||||||
|
)
|
||||||
|
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||||
|
state = nextState,
|
||||||
|
folder = folder,
|
||||||
|
files = scannedFiles,
|
||||||
|
remoteMetadata = remoteMetadata,
|
||||||
|
nowMillis = nowMillis,
|
||||||
|
metadataOnly = metadataOnly
|
||||||
|
)
|
||||||
|
nextState = syncResult.state
|
||||||
|
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
|
||||||
|
nextShelfRefs,
|
||||||
|
syncResult.idMigrations
|
||||||
|
).filterNot { it.bookId in syncResult.removedBookIds }
|
||||||
|
allMigrations += syncResult.idMigrations
|
||||||
|
allRemovedBookIds += syncResult.removedBookIds
|
||||||
|
totalStats += syncResult.stats
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.engine mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||||
|
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
|
||||||
|
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} removed=${syncResult.stats.removedBooks} " +
|
||||||
|
"migrated=${syncResult.stats.migratedBooks} idMigrations=${syncResult.idMigrations.size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}"
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
importAnnotationSidecars(root, syncedBooks)
|
||||||
|
}.onFailure { error ->
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.failed mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||||
|
"root=\"${root.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncedBooks.forEach { book ->
|
||||||
|
remoteMetadata[book.id]?.let { metadata ->
|
||||||
|
runCatching {
|
||||||
|
importDesktopPdfBookmarksMetadata(book, metadata.bookmarksJson, metadata.lastModifiedTimestamp)
|
||||||
|
}.onFailure { error ->
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.bookmarks.importFailed book=${book.id} " +
|
||||||
|
"error=${error.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!metadataOnly && extractMetadata) {
|
||||||
|
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
|
||||||
|
books = nextState.rawLibraryBooks,
|
||||||
|
sourceFolder = folder.uriString
|
||||||
|
)
|
||||||
|
if (metadataResult.stats.updatedBooks > 0) {
|
||||||
|
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
|
||||||
|
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||||
|
}
|
||||||
|
totalMetadataStats += metadataResult.stats
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.metadataExtraction name=\"${folder.name.folderSyncPreview()}\" " +
|
||||||
|
"updated=${metadataResult.stats.updatedBooks} covers=${metadataResult.stats.coversUpdated}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncedBooks.forEach { book ->
|
||||||
|
saveBookMetadata(book)
|
||||||
|
if (!metadataOnly) {
|
||||||
|
savePdfAnnotationSidecar(book)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.done mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||||
|
"savedCandidates=${syncedBooks.size}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = DesktopLocalFolderSyncResult(
|
||||||
|
state = nextState,
|
||||||
|
shelfRefs = nextShelfRefs,
|
||||||
|
stats = totalStats,
|
||||||
|
metadataStats = totalMetadataStats,
|
||||||
|
idMigrations = allMigrations,
|
||||||
|
removedBookIds = allRemovedBookIds,
|
||||||
|
failedFolders = failedFolders,
|
||||||
|
processedFolderUris = processedFolderUris
|
||||||
|
)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " +
|
||||||
|
"updated=${totalStats.updatedBooks} remoteUpdates=${totalStats.remoteMetadataUpdates} " +
|
||||||
|
"removed=${totalStats.removedBooks} metadataExtracted=${totalMetadataStats.updatedBooks}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBookSidecars(book: BookItem) {
|
||||||
|
saveBookMetadata(book)
|
||||||
|
savePdfAnnotationSidecar(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSyncDataFolder(root: File): Boolean {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||||
|
return !syncDir.exists() || syncDir.isDirectory && syncDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBookMetadata(book: BookItem) {
|
||||||
|
val metadata = book.toDesktopFolderBookMetadata()
|
||||||
|
if (metadata == null) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " +
|
||||||
|
"progress=${book.progressPercentage} recent=${book.isRecent} bookmarks=${book.readerBookmarks.size} " +
|
||||||
|
"highlights=${book.readerHighlights.size}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
|
||||||
|
if (root == null) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.export.request book=${book.id} timestamp=${metadata.lastModifiedTimestamp} " +
|
||||||
|
"progress=${metadata.progressPercentage} recent=${metadata.isRecent} " +
|
||||||
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
saveMetadataToFolder(root, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePdfAnnotationSidecar(book: BookItem) {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() }
|
||||||
|
if (path == null) {
|
||||||
|
logDesktopFolderSync("annotation.export.skipNoPath book=${book.id}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (book.type != FileType.PDF) {
|
||||||
|
logDesktopFolderSync("annotation.export.skipNonPdf book=${book.id} type=${book.type}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
|
||||||
|
if (root == null) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val annotationFile = desktopPdfAnnotationFile(path)
|
||||||
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||||
|
val richTextFile = desktopPdfRichTextFile(path)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.export.check book=${book.id} root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||||
|
"pdfPath=\"${path.folderSyncPreview()}\" hasAnnotations=${annotationFile.isFile} " +
|
||||||
|
"hasBookmarks=${bookmarkFile.isFile} hasText=${richTextFile.isFile} " +
|
||||||
|
"localTs=${maxOf(annotationFile.lastModifiedIfFile(), bookmarkFile.lastModifiedIfFile(), richTextFile.lastModifiedIfFile())}"
|
||||||
|
)
|
||||||
|
val data = buildMap {
|
||||||
|
if (annotationFile.isFile) {
|
||||||
|
val annotationJson = annotationFile.readText().trim()
|
||||||
|
desktopPdfAnnotationElementForSync(annotationJson)?.let { annotations ->
|
||||||
|
put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bookmarkFile.isFile) {
|
||||||
|
val bookmarksJson = bookmarkFile.readText().trim()
|
||||||
|
desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) }
|
||||||
|
}
|
||||||
|
if (richTextFile.isFile) {
|
||||||
|
val richTextJson = richTextFile.readText().trim()
|
||||||
|
val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson)
|
||||||
|
if (richTextElement == null) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.exportRichTextParseFailed book=${book.id} " +
|
||||||
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement)
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.exportRichText book=${book.id} " +
|
||||||
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
|
||||||
|
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
|
||||||
|
)
|
||||||
|
desktopPdfRichTextElementForSync(richTextJson)?.let { put("text", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.export.skipNoLocalData book=${book.id} pdfPath=\"${path.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val timestamp = maxOf(
|
||||||
|
annotationFile.lastModifiedIfSyncableAnnotations(),
|
||||||
|
bookmarkFile.lastModifiedIfFile(),
|
||||||
|
richTextFile.lastModifiedIfSyncableRichText(),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
val dataJson = desktopFolderSyncJson.encodeToString(
|
||||||
|
JsonElement.serializer(),
|
||||||
|
JsonObject(data)
|
||||||
|
)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.export.request book=${book.id} timestamp=$timestamp keys=${data.keys.sorted()} " +
|
||||||
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
|
||||||
|
"keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
saveAnnotationSidecar(
|
||||||
|
root = root,
|
||||||
|
bookId = book.id,
|
||||||
|
jsonPayload = dataJson,
|
||||||
|
timestamp = timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun foldersToSync(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
targetFolder: File?,
|
||||||
|
nowMillis: Long
|
||||||
|
): List<SyncedFolder> {
|
||||||
|
if (targetFolder == null) return state.syncedFolders
|
||||||
|
val root = targetFolder.canonicalOrAbsolute()
|
||||||
|
val rootPath = root.absolutePath
|
||||||
|
val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root }
|
||||||
|
return listOf(
|
||||||
|
existing ?: SyncedFolder(
|
||||||
|
uriString = rootPath,
|
||||||
|
name = root.name.takeIf { it.isNotBlank() } ?: rootPath,
|
||||||
|
lastScanTime = nowMillis,
|
||||||
|
allowedFileTypes = desktopSyncableTypes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanFolder(root: File, sourceFolder: String): List<SharedFolderScannedFile> {
|
||||||
|
val rootPath = root.toPath().toAbsolutePath().normalize()
|
||||||
|
return root.walkTopDown()
|
||||||
|
.onEnter { it == root || it.shouldEnterSyncedFolder() }
|
||||||
|
.onFail { file, error ->
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"folder.scan.skipInaccessible root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||||
|
"path=\"${file.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { file ->
|
||||||
|
runCatching { file.isFile && file.shouldSyncBookFile() }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
.mapNotNull { file ->
|
||||||
|
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||||
|
.takeIf { it in desktopSyncableTypes }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val relativePath = runCatching {
|
||||||
|
rootPath.relativize(file.toPath().toAbsolutePath().normalize())
|
||||||
|
.joinToString("/")
|
||||||
|
}.getOrNull() ?: file.name
|
||||||
|
SharedFolderScannedFile(
|
||||||
|
name = file.name,
|
||||||
|
path = file.absolutePath,
|
||||||
|
sourceFolder = sourceFolder,
|
||||||
|
relativePath = relativePath,
|
||||||
|
type = type,
|
||||||
|
size = runCatching { file.length() }.getOrDefault(0L),
|
||||||
|
lastModified = runCatching { file.lastModified() }.getOrDefault(0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||||
|
if (!syncDir.isDirectory) {
|
||||||
|
logDesktopFolderSync("metadata.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
var candidates = 0
|
||||||
|
var parsed = 0
|
||||||
|
var failed = 0
|
||||||
|
val result = syncDir.listFiles().orEmpty()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isFile && it.isMetadataSidecarCandidate() }
|
||||||
|
.mapNotNull { file ->
|
||||||
|
candidates++
|
||||||
|
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
|
||||||
|
.onSuccess { parsed++ }
|
||||||
|
.onFailure { error ->
|
||||||
|
failed++
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.read.parseFailed file=\"${file.absolutePath.folderSyncPreview()}\" " +
|
||||||
|
"error=${error.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
.groupBy { it.bookId }
|
||||||
|
.mapValues { (_, metadata) -> metadata.maxBy { it.lastModifiedTimestamp } }
|
||||||
|
.toMap()
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||||
|
"candidates=$candidates parsed=$parsed failed=$failed winners=${result.size}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||||
|
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
|
||||||
|
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.save.skipNewerRemote book=${metadata.bookId} existingTs=${existing.lastModifiedTimestamp} " +
|
||||||
|
"candidateTs=${metadata.lastModifiedTimestamp} root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = File(syncDir, localFolderSyncMetadataFileName(metadata.bookId))
|
||||||
|
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId)))
|
||||||
|
runCatching {
|
||||||
|
temp.writeText(metadata.toJsonString())
|
||||||
|
moveReplacing(temp, target)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.save.done book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
|
||||||
|
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.save.failed book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
|
||||||
|
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
runCatching { temp.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveMetadataConflicts(
|
||||||
|
syncDir: File,
|
||||||
|
bookId: String,
|
||||||
|
cleanup: Boolean
|
||||||
|
): SharedFolderBookMetadata? {
|
||||||
|
val hashedStem = localFolderSyncSidecarStem(bookId)
|
||||||
|
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
||||||
|
val normalized = file.normalizedSidecarName()
|
||||||
|
file.isFile &&
|
||||||
|
file.isMetadataSidecarCandidate() &&
|
||||||
|
(
|
||||||
|
normalized.matchesJsonSidecarStem(hashedStem) ||
|
||||||
|
normalized.matchesJsonSidecarStem(bookId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty()) return null
|
||||||
|
if (candidates.size > 1) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.conflicts book=$bookId candidates=${candidates.size} " +
|
||||||
|
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = candidates.mapNotNull { file ->
|
||||||
|
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.conflict.parseFailed book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\" " +
|
||||||
|
"error=${error.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
|
||||||
|
}
|
||||||
|
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
|
||||||
|
|
||||||
|
if (cleanup) {
|
||||||
|
candidates
|
||||||
|
.filterNot { it == winner.first }
|
||||||
|
.forEach { file ->
|
||||||
|
runCatching { file.delete() }
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val correctName = localFolderSyncMetadataFileName(bookId)
|
||||||
|
if (winner.first.name != correctName) {
|
||||||
|
val target = File(syncDir, correctName)
|
||||||
|
runCatching { moveReplacing(winner.first, target) }
|
||||||
|
.onSuccess {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"metadata.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner.second
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||||
|
if (!syncDir.isDirectory) {
|
||||||
|
logDesktopFolderSync("annotation.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
var candidates = 0
|
||||||
|
var parsed = 0
|
||||||
|
val result = syncDir.listFiles().orEmpty()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isFile && it.isAnnotationSidecarCandidate() }
|
||||||
|
.mapNotNull { file ->
|
||||||
|
candidates++
|
||||||
|
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
|
||||||
|
?.also { parsed++ }
|
||||||
|
}
|
||||||
|
.groupBy { it.bookId }
|
||||||
|
.mapValues { (_, sidecars) -> sidecars.maxBy { it.timestamp } }
|
||||||
|
.toMap()
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||||
|
"candidates=$candidates parsed=$parsed winners=${result.size}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
|
||||||
|
if (books.isEmpty()) {
|
||||||
|
logDesktopFolderSync("annotation.import.skipNoBooks root=\"${root.absolutePath.folderSyncPreview()}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val sidecars = preloadAnnotationSidecars(root)
|
||||||
|
if (sidecars.isEmpty()) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.skipNoSidecars root=\"${root.absolutePath.folderSyncPreview()}\" books=${books.size}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
books.forEach { book ->
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() }
|
||||||
|
if (path == null) {
|
||||||
|
logDesktopFolderSync("annotation.import.skipNoPath book=${book.id}")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (book.type != FileType.PDF) {
|
||||||
|
logDesktopFolderSync("annotation.import.skipNonPdf book=${book.id} type=${book.type}")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val sidecar = sidecars[book.id]
|
||||||
|
if (sidecar == null) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.skipNoMatchingSidecar book=${book.id} available=${sidecars.keys.size} " +
|
||||||
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val annotationFile = desktopPdfAnnotationFile(path)
|
||||||
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||||
|
val richTextFile = desktopPdfRichTextFile(path)
|
||||||
|
val localTimestamp = maxOf(
|
||||||
|
annotationFile.lastModifiedIfFile(),
|
||||||
|
bookmarkFile.lastModifiedIfFile(),
|
||||||
|
richTextFile.lastModifiedIfFile()
|
||||||
|
)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.compare book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp " +
|
||||||
|
"keys=${sidecar.data.keys.sorted()}"
|
||||||
|
)
|
||||||
|
if (sidecar.timestamp <= localTimestamp + 1000L) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.skipOlder book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp"
|
||||||
|
)
|
||||||
|
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
|
||||||
|
"localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " +
|
||||||
|
"richFile=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (sidecar.data.hasPdfAnnotationPayload()) {
|
||||||
|
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
|
||||||
|
if (annotations.isEmpty()) {
|
||||||
|
if (annotationFile.isFile) annotationFile.delete()
|
||||||
|
logDesktopFolderSync("annotation.import.deleteEmptyAnnotations book=${book.id}")
|
||||||
|
} else {
|
||||||
|
annotationFile.parentFile?.mkdirs()
|
||||||
|
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||||
|
annotationFile.setLastModified(sidecar.timestamp)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
|
||||||
|
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sidecar.data["bookmarks"]?.let { bookmarks ->
|
||||||
|
bookmarkFile.parentFile?.mkdirs()
|
||||||
|
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
|
||||||
|
bookmarkFile.setLastModified(sidecar.timestamp)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.writeBookmarks book=${book.id} file=\"${bookmarkFile.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sidecar.data["text"]?.let { richText ->
|
||||||
|
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " +
|
||||||
|
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
|
||||||
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
|
||||||
|
if (richTextFile.isFile) richTextFile.delete()
|
||||||
|
logDesktopFolderSync("annotation.import.deleteEmptyText book=${book.id}")
|
||||||
|
} else {
|
||||||
|
richTextFile.parentFile?.mkdirs()
|
||||||
|
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||||
|
richTextFile.setLastModified(sidecar.timestamp)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
|
||||||
|
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAnnotationSidecar(
|
||||||
|
root: File,
|
||||||
|
bookId: String,
|
||||||
|
jsonPayload: String,
|
||||||
|
timestamp: Long
|
||||||
|
) {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||||
|
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull()
|
||||||
|
if (data == null) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.save.skipInvalidPayload book=$bookId timestamp=$timestamp " +
|
||||||
|
"root=\"${root.absolutePath.folderSyncPreview()}\" payloadLen=${jsonPayload.length}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
|
||||||
|
if (existing != null && existing.timestamp >= timestamp) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.save.skipNewerExisting book=$bookId existingTs=${existing.timestamp} " +
|
||||||
|
"candidateTs=$timestamp root=\"${root.absolutePath.folderSyncPreview()}\" keys=${data.keys.sorted()}"
|
||||||
|
)
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
|
||||||
|
"candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val wrapper = JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"version" to JsonPrimitive(1),
|
||||||
|
"bookId" to JsonPrimitive(bookId),
|
||||||
|
"timestamp" to JsonPrimitive(timestamp),
|
||||||
|
"data" to data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val target = File(syncDir, localFolderSyncAnnotationFileName(bookId))
|
||||||
|
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId)))
|
||||||
|
runCatching {
|
||||||
|
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
|
||||||
|
moveReplacing(temp, target)
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.save.done book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
|
||||||
|
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
|
||||||
|
)
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
|
||||||
|
"target=\"${target.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.save.failed book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
|
||||||
|
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
|
||||||
|
"target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
runCatching { temp.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveAnnotationConflicts(
|
||||||
|
syncDir: File,
|
||||||
|
bookId: String,
|
||||||
|
cleanup: Boolean
|
||||||
|
): AnnotationSidecar? {
|
||||||
|
val parsed = syncDir.listFiles().orEmpty()
|
||||||
|
.filter { file -> file.isFile && file.isAnnotationSidecarCandidate() }
|
||||||
|
.mapNotNull { file ->
|
||||||
|
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
|
||||||
|
?.takeIf { it.bookId == bookId }
|
||||||
|
?.let { file to it }
|
||||||
|
}
|
||||||
|
if (parsed.isEmpty()) return null
|
||||||
|
if (parsed.size > 1) {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.conflicts book=$bookId candidates=${parsed.size} " +
|
||||||
|
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
|
||||||
|
|
||||||
|
if (cleanup) {
|
||||||
|
parsed.map { it.first }
|
||||||
|
.filterNot { it == winner.first }
|
||||||
|
.forEach { file ->
|
||||||
|
runCatching { file.delete() }
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val correctName = localFolderSyncAnnotationFileName(bookId)
|
||||||
|
if (winner.first.name != correctName) {
|
||||||
|
val target = File(syncDir, correctName)
|
||||||
|
runCatching { moveReplacing(winner.first, target) }
|
||||||
|
.onSuccess {
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AnnotationSidecar(
|
||||||
|
val bookId: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val data: JsonObject
|
||||||
|
)
|
||||||
|
|
||||||
|
private val desktopFolderSyncJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.shouldEnterSyncedFolder(): Boolean {
|
||||||
|
if (!isDirectory) return false
|
||||||
|
if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false
|
||||||
|
if (name.startsWith(".")) return false
|
||||||
|
return runCatching { !isHidden }.getOrDefault(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.shouldSyncBookFile(): Boolean {
|
||||||
|
if (name.startsWith(".")) return false
|
||||||
|
if (extension.equals("json", ignoreCase = true)) return false
|
||||||
|
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.isMetadataSidecarCandidate(): Boolean {
|
||||||
|
val fileName = name
|
||||||
|
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
|
||||||
|
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
|
||||||
|
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.isAnnotationSidecarCandidate(): Boolean {
|
||||||
|
val fileName = name
|
||||||
|
if (!fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
|
||||||
|
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
|
||||||
|
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.legacyAnnotationBookIdOrNull(): String? {
|
||||||
|
var candidate = name
|
||||||
|
if (!isAnnotationSidecarCandidate()) return null
|
||||||
|
if (candidate.contains(".sync-conflict")) {
|
||||||
|
candidate = candidate.substringBefore(".sync-conflict")
|
||||||
|
}
|
||||||
|
candidate = candidate.substringBeforeLast(".json")
|
||||||
|
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
|
||||||
|
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
|
||||||
|
}
|
||||||
|
val normalized = candidate.removePrefix(".")
|
||||||
|
if (normalized.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
|
||||||
|
return normalized.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.normalizedSidecarName(): String {
|
||||||
|
return name.removePrefix(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.matchesJsonSidecarStem(stem: String): Boolean {
|
||||||
|
return this == "$stem.json" ||
|
||||||
|
startsWith("$stem.sync-conflict") ||
|
||||||
|
startsWith("$stem.json.sync-conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.readAnnotationSidecarOrNull(fallbackBookId: String? = null): AnnotationSidecar? {
|
||||||
|
return runCatching {
|
||||||
|
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
|
||||||
|
val bookId = root["bookId"]
|
||||||
|
?.takeUnless { it is JsonNull }
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
?: fallbackBookId
|
||||||
|
?: error("Missing annotation sidecar bookId")
|
||||||
|
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||||
|
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
|
||||||
|
AnnotationSidecar(bookId = bookId, timestamp = timestamp, data = data)
|
||||||
|
}.onFailure { error ->
|
||||||
|
logDesktopFolderSync(
|
||||||
|
"annotation.read.parseFailed file=\"${absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Json.parseElementOrNull(raw: String): JsonElement? {
|
||||||
|
return runCatching { parseToJsonElement(raw) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||||
|
if (this is JsonNull) return null
|
||||||
|
return runCatching { jsonObject }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
|
||||||
|
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.canonicalOrAbsolute(): File {
|
||||||
|
return runCatching { canonicalFile }.getOrElse { absoluteFile }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfFile(): Long {
|
||||||
|
return if (isFile) lastModified() else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.hasSyncablePdfAnnotations(): Boolean {
|
||||||
|
return isFile && desktopPdfAnnotationElementForSync(readText()) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfSyncableAnnotations(): Long {
|
||||||
|
return if (hasSyncablePdfAnnotations()) lastModified() else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.hasSyncablePdfRichText(): Boolean {
|
||||||
|
return isFile && desktopPdfRichTextElementForSync(readText()) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfSyncableRichText(): Long {
|
||||||
|
return if (hasSyncablePdfRichText()) lastModified() else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookItem.toDesktopFolderBookMetadata(): SharedFolderBookMetadata? {
|
||||||
|
val base = toSharedFolderBookMetadata()
|
||||||
|
val pdfBookmarksJson = desktopPdfBookmarksMetadataJson(this)
|
||||||
|
if (base == null && pdfBookmarksJson == null) return null
|
||||||
|
|
||||||
|
val timestamp = maxOf(
|
||||||
|
base?.lastModifiedTimestamp ?: 0L,
|
||||||
|
desktopPdfBookmarkMetadataTimestamp(this),
|
||||||
|
this.timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
return (base ?: SharedFolderBookMetadata(
|
||||||
|
bookId = id,
|
||||||
|
title = null,
|
||||||
|
author = null,
|
||||||
|
displayName = displayName,
|
||||||
|
type = type.name,
|
||||||
|
lastChapterIndex = readerPosition?.chapterIndex,
|
||||||
|
lastPage = readerPosition?.pageIndex ?: lastPageIndex,
|
||||||
|
lastPositionCfi = readerPosition?.toStablePositionCfi(),
|
||||||
|
progressPercentage = progressPercentage ?: 0f,
|
||||||
|
isRecent = isRecent,
|
||||||
|
lastModifiedTimestamp = timestamp,
|
||||||
|
bookmarksJson = null,
|
||||||
|
locatorBlockIndex = readerPosition?.blockIndex,
|
||||||
|
locatorCharOffset = readerPosition?.charOffset,
|
||||||
|
customName = null,
|
||||||
|
highlightsJson = null
|
||||||
|
)).copy(
|
||||||
|
lastModifiedTimestamp = timestamp,
|
||||||
|
bookmarksJson = pdfBookmarksJson ?: base?.bookmarksJson
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueFolderSyncTempName(baseName: String): String {
|
||||||
|
val stem = baseName.removeSuffix(".tmp")
|
||||||
|
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
|
||||||
|
return "$stem.$nonce.tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.richSyncPreview(maxLength: Int = 160): String {
|
||||||
|
return replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveReplacing(source: File, target: File) {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
try {
|
||||||
|
Files.move(
|
||||||
|
source.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE
|
||||||
|
)
|
||||||
|
} catch (_: AtomicMoveNotSupportedException) {
|
||||||
|
Files.move(
|
||||||
|
source.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue