From cde4d89eeda95c1431b5d95e13c3b871d138c069 Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 25 May 2026 20:14:33 +0300 Subject: [PATCH] Added URL detection in reader, improved platform URL handling, library item auto-rescan, and file association support. --- composeApp/build.gradle.kts | 2 + .../src/androidMain/AndroidManifest.xml | 7 ++ .../sergeych/toread/BookPlatform.android.kt | 38 +++++++- .../kotlin/net/sergeych/toread/App.kt | 80 +++++++++++++++- .../kotlin/net/sergeych/toread/AppState.kt | 5 +- .../net/sergeych/toread/LibraryPlatform.kt | 2 + .../net/sergeych/toread/LibraryScreen.kt | 22 ----- .../net/sergeych/toread/ReaderContent.kt | 92 ++++++++++++++++++- .../net/sergeych/toread/ReaderScreen.kt | 12 ++- .../toread/ReaderLinkDetectionTest.kt | 24 +++++ .../net/sergeych/toread/BookPlatform.jvm.kt | 42 ++++++++- .../kotlin/net/sergeych/toread/main.kt | 15 +-- .../net/sergeych/toread/BookPlatform.web.kt | 3 + .../toread/storage/jdbc/LibraryScanner.kt | 32 +++++-- .../toread/storage/jdbc/LibraryScannerTest.kt | 39 +++++++- 15 files changed, 367 insertions(+), 48 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/net/sergeych/toread/ReaderLinkDetectionTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c668488..949b3ab 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -140,6 +140,8 @@ compose.desktop { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "To Read" packageVersion = appVersionDisplay + fileAssociation("application/x-fictionbook+xml", "fb2", "FictionBook document") + fileAssociation("application/zip", "fb2.zip", "Zipped FictionBook document") macOS { iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns")) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 908b0ab..6eb1fc6 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -24,6 +24,7 @@ android:theme="@style/AppTheme"> @@ -39,6 +40,8 @@ + + @@ -48,8 +51,12 @@ + + + + + LibraryScanner(db, ::appendLibraryLog).importExternalFileAndGetResult( + bytes = bytes, + displayName = displayName, + storageUri = uri.toString(), + sizeBytes = sizeBytes, + lastModifiedMillis = null, + ) + } + appendLibraryLog("platform open imported=${importResult.imported} fileId=${importResult.fileId} uri=$uri") PlatformOpenBookRequest( - id = "platform:${uri}", - displayName = displayNameFor(uri), + id = importResult.fileId, + displayName = displayName, bytes = bytes, ) } @@ -374,6 +394,15 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(D actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false +actual fun openPlatformUrl(url: String): Boolean = + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + appContext.startActivity(intent) + true + }.getOrDefault(false) + actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) { openLibraryDatabase().useLibrary { db -> val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() @@ -689,6 +718,11 @@ private fun displayNameFor(uri: Uri): String = if (cursor.moveToFirst()) cursor.getString(0) else null } ?: uri.lastPathSegment ?: uri.toString() +private fun sizeBytesFor(uri: Uri): Long? = + appContext.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else null + } + private fun LibraryScanSummary.toLibraryScanProgress(): LibraryScanProgress = LibraryScanProgress( scannedFiles = scannedFiles, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 6bd5567..45f682b 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -24,6 +24,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import net.sergeych.toread.fb2.Fb2Format import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -180,6 +184,9 @@ private fun BookReaderApp( var nextLibraryItemRefreshId by remember { mutableStateOf(0L) } var libraryItemRefreshRequest by remember { mutableStateOf(null) } var imageViewer by remember { mutableStateOf(null) } + var resumeGeneration by remember { mutableStateOf(0L) } + var downloadsRescanRequestGeneration by remember { mutableStateOf(0L) } + val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() fun refreshLibraryItem(fileId: String) { @@ -191,6 +198,60 @@ private fun BookReaderApp( state = loadStartupState() } + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + resumeGeneration += 1 + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + LaunchedEffect(Unit) { + while (true) { + delay(500) + val requestResult = runCatching { loadPlatformOpenBookRequest() } + if (requestResult.isFailure) { + val scanPath = when (val current = state) { + is AppState.Library -> current.scanPath + is AppState.Scan -> current.scanPath + is AppState.Reader -> current.scanPath + is AppState.BookInfo -> current.scanPath + is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty() + } + state = AppState.Library(emptyList(), scanPath, requestResult.exceptionOrNull()?.message ?: strings.couldNotOpenLibrary) + continue + } + val request = requestResult.getOrNull() ?: continue + val scanPath = when (val current = state) { + is AppState.Library -> current.scanPath + is AppState.Scan -> current.scanPath + is AppState.Reader -> current.scanPath + is AppState.BookInfo -> current.scanPath + is AppState.Error, AppState.LoadingStartup -> defaultLibraryScanPath().orEmpty() + } + val nextState = runCatching { + AppState.Reader( + fileId = request.id, + book = Fb2Format.parse(request.bytes, request.displayName), + libraryItems = emptyList(), + scanPath = scanPath, + ) + }.getOrElse { + AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpen(request.displayName)) + } + libraryBackState = when (val current = state) { + is AppState.Library -> current + is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message) + is AppState.Reader, is AppState.BookInfo -> libraryBackState + is AppState.Error, AppState.LoadingStartup -> null + } + state = nextState + refreshLibraryItem(request.id) + } + } + val activeBookState = when (val current = state) { is AppState.Reader -> current.fileId to current.libraryItems.isEmpty() is AppState.BookInfo -> current.fileId to current.libraryItems.isEmpty() @@ -296,6 +357,7 @@ private fun BookReaderApp( val backState = libraryBackState?.copy(message = current.message) ?: AppState.Library(current.libraryItems, current.scanPath, current.message) libraryBackState = null + downloadsRescanRequestGeneration += 1 backState } is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message) @@ -357,6 +419,14 @@ private fun BookReaderApp( } } + LaunchedEffect(resumeGeneration, downloadsRescanRequestGeneration, state !is AppState.LoadingStartup) { + if (state is AppState.LoadingStartup || activeScan != null || scanJob?.isActive == true) return@LaunchedEffect + if (!loadScanDownloadsAutomatically() || !loadDownloadsWasScanned()) return@LaunchedEffect + val path = downloadsScanPath() ?: return@LaunchedEffect + state = state.withMessage(strings.scanningDownloads) + startScan(path) + } + Box(Modifier.fillMaxSize()) { val currentLibraryState = when (val current = state) { is AppState.Library -> current @@ -380,7 +450,6 @@ private fun BookReaderApp( libraryBackState = null state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message) }, - onStartScan = ::startScan, onDeleteRequested = ::requestDelete, ) } @@ -447,3 +516,12 @@ internal data class ViewedBookImage( private fun LibraryScanReport.hasLibraryChanges(): Boolean = importedFiles > 0 + +private fun AppState.withMessage(message: String): AppState = + when (this) { + is AppState.Library -> copy(message = message) + is AppState.Scan -> copy(message = message) + is AppState.Reader -> copy(message = message) + is AppState.BookInfo -> copy(message = message) + is AppState.Error, AppState.LoadingStartup -> this + } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt index 0d5a318..2474675 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/AppState.kt @@ -42,7 +42,10 @@ internal suspend fun loadStartupState(): AppState { } catch (t: Throwable) { return AppState.Error(t.message ?: strings.couldNotOpenLibrary) } - loadPlatformOpenBookRequest()?.let { request -> + val platformRequest = runCatching { loadPlatformOpenBookRequest() }.getOrElse { + return AppState.Library(emptyList(), scanPath, it.message ?: strings.couldNotOpenLibrary) + } + platformRequest?.let { request -> return runCatching { AppState.Reader( fileId = request.id, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index a80875d..09ed7fd 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -142,6 +142,8 @@ expect suspend fun shareLibraryBookFile(fileId: String): Boolean expect suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean +expect fun openPlatformUrl(url: String): Boolean + expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras expect suspend fun loadActiveReadingFileId(): String? diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 8ebb350..900c806 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -98,7 +98,6 @@ internal fun LibraryScreen( hiddenFileIds: Set, onStateChange: (AppState) -> Unit, onNavigateToScan: () -> Unit, - onStartScan: (String) -> Unit, onDeleteRequested: ( request: LibraryDeleteRequest, remove: () -> Unit, @@ -116,8 +115,6 @@ internal fun LibraryScreen( var settingsMenuOpen by remember { mutableStateOf(false) } var localeMenuOpen by remember { mutableStateOf(false) } var autoScanDownloads by remember { mutableStateOf(true) } - var autoScanSettingLoaded by remember { mutableStateOf(false) } - var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } var searchFocused by remember { mutableStateOf(false) } var searchResults by remember { mutableStateOf>(emptyList()) } @@ -314,25 +311,6 @@ internal fun LibraryScreen( LaunchedEffect(Unit) { autoScanDownloads = loadScanDownloadsAutomatically() - autoScanSettingLoaded = true - } - - LaunchedEffect(autoScanSettingLoaded, autoScanDownloads, activeScan == null) { - if ( - !autoScanSettingLoaded || - !autoScanDownloads || - backgroundDownloadsRescanStarted || - activeScan != null - ) { - return@LaunchedEffect - } - if (loadDownloadsWasScanned()) { - downloadsScanPath()?.let { path -> - backgroundDownloadsRescanStarted = true - message = strings.scanningDownloads - onStartScan(path) - } - } } LaunchedEffect(activeScan != null) { diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index c95e49c..02742be 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -1293,14 +1293,100 @@ private fun Fb2Text.toAnnotatedString( span.href?.takeIf { it.isNotBlank() }?.let { pushStringAnnotation(ReaderLinkAnnotationTag, it) } - withStyle(spanStyle) { - appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) + if (isLink) { + withStyle(spanStyle) { + appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) + } + } else { + appendWithDetectedWebLinks( + text = span.text, + spanStyle = spanStyle, + plainOffset = plainOffset, + highlightedRange = highlightedRange, + highlightColor = highlightColor, + language = language, + hyphenation = hyphenation, + ) } if (isLink) pop() plainOffset += span.text.length } } +private fun AnnotatedString.Builder.appendWithDetectedWebLinks( + text: String, + spanStyle: SpanStyle, + plainOffset: Int, + highlightedRange: ReaderSentenceRange?, + highlightColor: Color, + language: String?, + hyphenation: HyphenationRegistry, +) { + var cursor = 0 + text.webUrlRanges().forEach { range -> + if (cursor < range.start) { + withStyle(spanStyle) { + appendWithHighlight( + text = text.substring(cursor, range.start), + plainOffset = plainOffset + cursor, + highlightedRange = highlightedRange, + highlightColor = highlightColor, + language = language, + hyphenation = hyphenation, + ) + } + } + + val url = text.substring(range) + pushStringAnnotation(ReaderLinkAnnotationTag, url) + withStyle(spanStyle.readerLinkStyle()) { + appendWithHighlight( + text = url, + plainOffset = plainOffset + range.start, + highlightedRange = highlightedRange, + highlightColor = highlightColor, + language = null, + hyphenation = hyphenation, + ) + } + pop() + cursor = range.endInclusive + 1 + } + + if (cursor < text.length) { + withStyle(spanStyle) { + appendWithHighlight( + text = text.substring(cursor), + plainOffset = plainOffset + cursor, + highlightedRange = highlightedRange, + highlightColor = highlightColor, + language = language, + hyphenation = hyphenation, + ) + } + } +} + +private fun SpanStyle.readerLinkStyle(): SpanStyle = + copy( + color = LinkTextColor, + textDecoration = when (textDecoration) { + TextDecoration.LineThrough -> + TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) + else -> TextDecoration.Underline + }, + ) + +internal fun String.webUrlRanges(): List = + WebUrlRegex.findAll(this).mapNotNull { match -> + val trimmed = match.value.trimEnd(*WebUrlTrailingPunctuation) + if (trimmed.substringAfter("://").isEmpty()) return@mapNotNull null + match.range.first..<(match.range.first + trimmed.length) + }.toList() + +internal fun String.isWebUrl(): Boolean = + startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true) + private fun AnnotatedString.Builder.appendWithHighlight( text: String, plainOffset: Int, @@ -1678,6 +1764,8 @@ private const val EllipsisPauseAfterMillis = 350L private const val CombiningAcuteAccent = '\u0301' private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY" private const val ReaderLinkAnnotationTag = "fb2-link" +private val WebUrlRegex = Regex("""https?://\S+""", RegexOption.IGNORE_CASE) +private val WebUrlTrailingPunctuation = charArrayOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '>', '"', '\'', '»', '”', '’') private val LinkTextColor = Color(0xFF0B57D0) private val ReaderLinkMinimumTouchWidth = 44.dp private val ReaderLinkMinimumTouchHeight = 40.dp diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index ec1e772..0dbd1c9 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -130,6 +130,14 @@ internal fun BookView( } } + fun openReaderLink(href: String) { + if (href.isWebUrl()) { + openPlatformUrl(href) + } else { + selectedNoteId = href.removePrefix("#") + } + } + fun updateReaderFontSettings(transform: (ReaderFontSettings) -> ReaderFontSettings) { val next = transform(readerFontSettings).coerced() if (next == readerFontSettings) return @@ -353,7 +361,7 @@ internal fun BookView( }, onUserScroll = { userScrollGeneration += 1 }, onImageOpen = onImageOpen, - onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") }, + onNoteOpen = ::openReaderLink, ) if (readerSettingsPanelVisible) { ReaderFontSettingsPanel( @@ -420,7 +428,7 @@ internal fun BookView( noteId = noteId, onDismiss = { selectedNoteId = null }, onImageOpen = onImageOpen, - onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") }, + onNoteOpen = ::openReaderLink, ) } } diff --git a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReaderLinkDetectionTest.kt b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReaderLinkDetectionTest.kt new file mode 100644 index 0000000..1d0e200 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReaderLinkDetectionTest.kt @@ -0,0 +1,24 @@ +package net.sergeych.toread + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ReaderLinkDetectionTest { + @Test + fun detectsWebUrlsAndTrimsSentencePunctuation() { + val text = "See http://example.org/path?q=1, then https://site.test/end." + + val urls = text.webUrlRanges().map { text.substring(it) } + + assertEquals(listOf("http://example.org/path?q=1", "https://site.test/end"), urls) + } + + @Test + fun ignoresNonWebUrls() { + val text = "ftp://files.test https://secure.test" + + val urls = text.webUrlRanges().map { text.substring(it) } + + assertEquals(listOf("https://secure.test"), urls) + } +} diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index 01901ec..ffddcdd 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -20,6 +20,7 @@ import java.awt.datatransfer.Transferable import java.awt.datatransfer.UnsupportedFlavorException import java.io.File import java.io.ByteArrayInputStream +import java.net.URI import javax.imageio.ImageIO import java.text.SimpleDateFormat import java.util.Date @@ -36,6 +37,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private var pendingOpenBookFile: File? = null + +fun rememberPlatformOpenBookArguments(args: Array) { + pendingOpenBookFile = args.asSequence() + .map(::File) + .firstOrNull { it.isFile && it.name.isSupportedBookFile() } +} + actual fun loadDefaultBookBytes(): ByteArray? { var current = File(System.getProperty("user.dir")).absoluteFile while (true) { @@ -70,7 +79,26 @@ actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, labe actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath } -actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null +actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = withContext(Dispatchers.IO) { + val file = pendingOpenBookFile ?: return@withContext null + pendingOpenBookFile = null + val bytes = file.readBytes() + val importResult = openLibraryDatabase().useLibrary { db -> + LibraryScanner(db, ::appendLibraryLog).importExternalFileAndGetResult( + bytes = bytes, + displayName = file.name, + storageUri = file.absolutePath, + sizeBytes = file.length(), + lastModifiedMillis = file.lastModified(), + ) + } + appendLibraryLog("platform open imported=${importResult.imported} fileId=${importResult.fileId} path=${file.absolutePath}") + PlatformOpenBookRequest( + id = importResult.fileId, + displayName = file.name, + bytes = bytes, + ) +} actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) { val chooser = JFileChooser(defaultLibraryScanPath()) @@ -301,6 +329,15 @@ actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolea }.getOrDefault(false) } +actual fun openPlatformUrl(url: String): Boolean = + runCatching { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + return@runCatching false + } + Desktop.getDesktop().browse(URI(url)) + true + }.getOrDefault(false) + private fun selectOrOpenBookFile(directory: File, file: File): Boolean { if (isLinuxDesktop() && trySelectLinuxFile(file)) return true if (!Desktop.isDesktopSupported()) return false @@ -654,6 +691,9 @@ private fun runCommand(vararg command: String): String? = } }.getOrNull() +private fun String.isSupportedBookFile(): Boolean = + endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true) + private const val ActiveReadingFileIdFlag = "active_reading_file_id" private const val ThemeModeFlag = "theme_mode" private const val ReaderFontSettingsFlag = "reader_font_settings" diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt index 992fb42..694cc7a 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/main.kt @@ -3,11 +3,14 @@ package net.sergeych.toread import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - title = strings.appName, - ) { - App() +fun main(args: Array) { + rememberPlatformOpenBookArguments(args) + application { + Window( + onCloseRequest = ::exitApplication, + title = strings.appName, + ) { + App() + } } } diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index 368880d..ece7951 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -63,6 +63,9 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolean = false +actual fun openPlatformUrl(url: String): Boolean = + window.open(url, target = "_blank") != null + actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras() actual suspend fun loadActiveReadingFileId(): String? = null diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt index d74ccd6..b119440 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt @@ -26,6 +26,11 @@ data class LibraryScanSummary( val currentFile: String? = null, ) +data class LibraryImportResult( + val fileId: String, + val imported: Boolean, +) + class LibraryScanner( private val database: H2LibraryDatabase, private val log: (String) -> Unit = {}, @@ -85,17 +90,29 @@ class LibraryScanner( storageUri: String, sizeBytes: Long?, lastModifiedMillis: Long?, - ): Boolean { + ): Boolean = + importExternalFileAndGetResult(bytes, displayName, storageUri, sizeBytes, lastModifiedMillis).imported + + fun importExternalFileAndGetResult( + bytes: ByteArray, + displayName: String, + storageUri: String, + sizeBytes: Long?, + lastModifiedMillis: Long?, + ): LibraryImportResult { val rawSha256 = bytes.sha256Hex() - if (sizeBytes != null && database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).isNotEmpty()) { - return false + if (sizeBytes != null) { + database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).firstOrNull()?.let { + return LibraryImportResult(fileId = it.id, imported = false) + } } val now = System.currentTimeMillis() database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget -> + val fileId = "file-${UUID.randomUUID()}" database.files.upsert( BookFileRecord( - id = "file-${UUID.randomUUID()}", + id = fileId, bookId = duplicateTarget.bookId, bodyId = duplicateTarget.bodyId, bodyClusterId = duplicateTarget.bodyClusterId, @@ -114,7 +131,7 @@ class LibraryScanner( updatedAt = now, ) ) - return true + return LibraryImportResult(fileId = fileId, imported = true) } val book = Fb2Format.parse(bytes, displayName) @@ -129,6 +146,7 @@ class LibraryScanner( database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256) } val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}" + val fileId = "file-${UUID.randomUUID()}" database.transaction { if (duplicateTarget == null || duplicateTarget.bookId == null) { @@ -177,7 +195,7 @@ class LibraryScanner( } files.upsert( BookFileRecord( - id = "file-${UUID.randomUUID()}", + id = fileId, bookId = bookId, bodyId = bodyId, bodyClusterId = clusterId, @@ -197,7 +215,7 @@ class LibraryScanner( ) ) } - return true + return LibraryImportResult(fileId = fileId, imported = true) } private fun importLinkedFile(file: File): Boolean { diff --git a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt index 2db168d..6e88749 100644 --- a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt +++ b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt @@ -15,13 +15,14 @@ class LibraryScannerTest { val db = H2LibraryDatabase.openMemory("scansFb2ZipSubtreeIntoLinkedLibraryRecords") try { val summary = LibraryScanner(db).scanSubtree(File(root, "test_books")) + val expectedFiles = LibraryScanner.countSupportedBookFiles(File(root, "test_books")) - assertEquals(1, summary.scannedFiles) - assertEquals(1, summary.importedFiles) + assertEquals(expectedFiles, summary.scannedFiles) + assertEquals(expectedFiles, summary.importedFiles) assertEquals(0, summary.skippedFiles) assertEquals(0, summary.failedFiles) - val file = db.files.list().single() + val file = db.files.list().first { it.storageUri?.endsWith(".fb2.zip") == true } assertEquals("fb2.zip", file.format) assertTrue(file.storageUri?.endsWith(".fb2.zip") == true) val book = assertNotNull(file.bookId?.let(db.books::get)) @@ -31,7 +32,7 @@ class LibraryScannerTest { val second = LibraryScanner(db).scanSubtree(File(root, "test_books")) assertEquals(0, second.importedFiles) - assertEquals(1, second.skippedFiles) + assertEquals(expectedFiles, second.skippedFiles) } finally { db.close() } @@ -92,6 +93,36 @@ class LibraryScannerTest { } } + @Test + fun importExternalFileResultReturnsExistingFileForExactRepeat() { + val root = findProjectRoot() + val bytes = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip").readBytes() + val db = H2LibraryDatabase.openMemory("importExternalFileResultReturnsExistingFileForExactRepeat") + try { + val scanner = LibraryScanner(db) + val first = scanner.importExternalFileAndGetResult( + bytes = bytes, + displayName = "opened.fb2.zip", + storageUri = "/tmp/opened.fb2.zip", + sizeBytes = bytes.size.toLong(), + lastModifiedMillis = 1L, + ) + val second = scanner.importExternalFileAndGetResult( + bytes = bytes, + displayName = "opened.fb2.zip", + storageUri = "/tmp/opened.fb2.zip", + sizeBytes = bytes.size.toLong(), + lastModifiedMillis = 1L, + ) + + assertTrue(first.imported) + assertEquals(false, second.imported) + assertEquals(first.fileId, second.fileId) + } finally { + db.close() + } + } + private fun findProjectRoot(): File { var current = File(System.getProperty("user.dir")).absoluteFile while (true) {