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