Added URL detection in reader, improved platform URL handling, library item auto-rescan, and file association support.

This commit is contained in:
Sergey Chernov 2026-05-25 20:14:33 +03:00
parent 2e9a52f4af
commit cde4d89eed
15 changed files with 367 additions and 48 deletions

View File

@ -140,6 +140,8 @@ compose.desktop {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "To Read" packageName = "To Read"
packageVersion = appVersionDisplay packageVersion = appVersionDisplay
fileAssociation("application/x-fictionbook+xml", "fb2", "FictionBook document")
fileAssociation("application/zip", "fb2.zip", "Zipped FictionBook document")
macOS { macOS {
iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns")) iconFile.set(project.file("src/jvmMain/resources/icons/icon.icns"))

View File

@ -24,6 +24,7 @@
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity <activity
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:name=".MainActivity"> android:name=".MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
@ -39,6 +40,8 @@
<data android:scheme="content"/> <data android:scheme="content"/>
<data android:scheme="file"/> <data android:scheme="file"/>
<data android:mimeType="application/x-fictionbook+xml"/> <data android:mimeType="application/x-fictionbook+xml"/>
<data android:mimeType="application/xml"/>
<data android:mimeType="text/xml"/>
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
@ -48,8 +51,12 @@
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/> <data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/> <data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
<data android:scheme="content" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2"/>
<data android:scheme="content" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2\\.zip"/>
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/> <data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/> <data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
<data android:scheme="file" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2"/>
<data android:scheme="file" android:mimeType="application/octet-stream" android:pathPattern=".*\\.fb2\\.zip"/>
</intent-filter> </intent-filter>
</activity> </activity>
<provider <provider

View File

@ -102,6 +102,14 @@ actual fun defaultLibraryScanPath(): String? =
?: appContext.filesDir.absolutePath ?: appContext.filesDir.absolutePath
fun rememberPlatformOpenBookIntent(intent: Intent?) { fun rememberPlatformOpenBookIntent(intent: Intent?) {
val uri = intent
?.takeIf { it.action == Intent.ACTION_VIEW }
?.data
if (uri != null && intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) {
runCatching {
appContext.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
pendingOpenBookUri = intent pendingOpenBookUri = intent
?.takeIf { it.action == Intent.ACTION_VIEW } ?.takeIf { it.action == Intent.ACTION_VIEW }
?.data ?.data
@ -111,9 +119,21 @@ actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = wit
val uri = pendingOpenBookUri ?: return@withContext null val uri = pendingOpenBookUri ?: return@withContext null
pendingOpenBookUri = null pendingOpenBookUri = null
val bytes = readStorageUriBytes(uri.toString()) ?: return@withContext null val bytes = readStorageUriBytes(uri.toString()) ?: return@withContext null
val displayName = displayNameFor(uri)
val sizeBytes = sizeBytesFor(uri) ?: bytes.size.toLong()
val importResult = openLibraryDatabase().useLibrary { db ->
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( PlatformOpenBookRequest(
id = "platform:${uri}", id = importResult.fileId,
displayName = displayNameFor(uri), displayName = displayName,
bytes = bytes, 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 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) { actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() 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 if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: uri.toString() } ?: 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 = private fun LibraryScanSummary.toLibraryScanProgress(): LibraryScanProgress =
LibraryScanProgress( LibraryScanProgress(
scannedFiles = scannedFiles, scannedFiles = scannedFiles,

View File

@ -24,6 +24,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -180,6 +184,9 @@ private fun BookReaderApp(
var nextLibraryItemRefreshId by remember { mutableStateOf(0L) } var nextLibraryItemRefreshId by remember { mutableStateOf(0L) }
var libraryItemRefreshRequest by remember { mutableStateOf<LibraryItemRefreshRequest?>(null) } var libraryItemRefreshRequest by remember { mutableStateOf<LibraryItemRefreshRequest?>(null) }
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) } var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
var resumeGeneration by remember { mutableStateOf(0L) }
var downloadsRescanRequestGeneration by remember { mutableStateOf(0L) }
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
fun refreshLibraryItem(fileId: String) { fun refreshLibraryItem(fileId: String) {
@ -191,6 +198,60 @@ private fun BookReaderApp(
state = loadStartupState() 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) { val activeBookState = when (val current = state) {
is AppState.Reader -> current.fileId to current.libraryItems.isEmpty() is AppState.Reader -> current.fileId to current.libraryItems.isEmpty()
is AppState.BookInfo -> 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) val backState = libraryBackState?.copy(message = current.message)
?: AppState.Library(current.libraryItems, current.scanPath, current.message) ?: AppState.Library(current.libraryItems, current.scanPath, current.message)
libraryBackState = null libraryBackState = null
downloadsRescanRequestGeneration += 1
backState backState
} }
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message) 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()) { Box(Modifier.fillMaxSize()) {
val currentLibraryState = when (val current = state) { val currentLibraryState = when (val current = state) {
is AppState.Library -> current is AppState.Library -> current
@ -380,7 +450,6 @@ private fun BookReaderApp(
libraryBackState = null libraryBackState = null
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message) state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message)
}, },
onStartScan = ::startScan,
onDeleteRequested = ::requestDelete, onDeleteRequested = ::requestDelete,
) )
} }
@ -447,3 +516,12 @@ internal data class ViewedBookImage(
private fun LibraryScanReport.hasLibraryChanges(): Boolean = private fun LibraryScanReport.hasLibraryChanges(): Boolean =
importedFiles > 0 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
}

View File

@ -42,7 +42,10 @@ internal suspend fun loadStartupState(): AppState {
} catch (t: Throwable) { } catch (t: Throwable) {
return AppState.Error(t.message ?: strings.couldNotOpenLibrary) 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 { return runCatching {
AppState.Reader( AppState.Reader(
fileId = request.id, fileId = request.id,

View File

@ -142,6 +142,8 @@ expect suspend fun shareLibraryBookFile(fileId: String): Boolean
expect suspend fun viewLibraryBookFile(folder: String, fileName: 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 loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String? expect suspend fun loadActiveReadingFileId(): String?

View File

@ -98,7 +98,6 @@ internal fun LibraryScreen(
hiddenFileIds: Set<String>, hiddenFileIds: Set<String>,
onStateChange: (AppState) -> Unit, onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit, onNavigateToScan: () -> Unit,
onStartScan: (String) -> Unit,
onDeleteRequested: ( onDeleteRequested: (
request: LibraryDeleteRequest, request: LibraryDeleteRequest,
remove: () -> Unit, remove: () -> Unit,
@ -116,8 +115,6 @@ internal fun LibraryScreen(
var settingsMenuOpen by remember { mutableStateOf(false) } var settingsMenuOpen by remember { mutableStateOf(false) }
var localeMenuOpen by remember { mutableStateOf(false) } var localeMenuOpen by remember { mutableStateOf(false) }
var autoScanDownloads by remember { mutableStateOf(true) } var autoScanDownloads by remember { mutableStateOf(true) }
var autoScanSettingLoaded by remember { mutableStateOf(false) }
var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
var searchFocused by remember { mutableStateOf(false) } var searchFocused by remember { mutableStateOf(false) }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) } var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
@ -314,25 +311,6 @@ internal fun LibraryScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
autoScanDownloads = loadScanDownloadsAutomatically() 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) { LaunchedEffect(activeScan != null) {

View File

@ -1293,14 +1293,100 @@ private fun Fb2Text.toAnnotatedString(
span.href?.takeIf { it.isNotBlank() }?.let { span.href?.takeIf { it.isNotBlank() }?.let {
pushStringAnnotation(ReaderLinkAnnotationTag, it) pushStringAnnotation(ReaderLinkAnnotationTag, it)
} }
withStyle(spanStyle) { if (isLink) {
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) 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() if (isLink) pop()
plainOffset += span.text.length 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<IntRange> =
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( private fun AnnotatedString.Builder.appendWithHighlight(
text: String, text: String,
plainOffset: Int, plainOffset: Int,
@ -1678,6 +1764,8 @@ private const val EllipsisPauseAfterMillis = 350L
private const val CombiningAcuteAccent = '\u0301' private const val CombiningAcuteAccent = '\u0301'
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY" private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
private const val ReaderLinkAnnotationTag = "fb2-link" 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 LinkTextColor = Color(0xFF0B57D0)
private val ReaderLinkMinimumTouchWidth = 44.dp private val ReaderLinkMinimumTouchWidth = 44.dp
private val ReaderLinkMinimumTouchHeight = 40.dp private val ReaderLinkMinimumTouchHeight = 40.dp

View File

@ -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) { fun updateReaderFontSettings(transform: (ReaderFontSettings) -> ReaderFontSettings) {
val next = transform(readerFontSettings).coerced() val next = transform(readerFontSettings).coerced()
if (next == readerFontSettings) return if (next == readerFontSettings) return
@ -353,7 +361,7 @@ internal fun BookView(
}, },
onUserScroll = { userScrollGeneration += 1 }, onUserScroll = { userScrollGeneration += 1 },
onImageOpen = onImageOpen, onImageOpen = onImageOpen,
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") }, onNoteOpen = ::openReaderLink,
) )
if (readerSettingsPanelVisible) { if (readerSettingsPanelVisible) {
ReaderFontSettingsPanel( ReaderFontSettingsPanel(
@ -420,7 +428,7 @@ internal fun BookView(
noteId = noteId, noteId = noteId,
onDismiss = { selectedNoteId = null }, onDismiss = { selectedNoteId = null },
onImageOpen = onImageOpen, onImageOpen = onImageOpen,
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") }, onNoteOpen = ::openReaderLink,
) )
} }
} }

View File

@ -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)
}
}

View File

@ -20,6 +20,7 @@ import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException import java.awt.datatransfer.UnsupportedFlavorException
import java.io.File import java.io.File
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.net.URI
import javax.imageio.ImageIO import javax.imageio.ImageIO
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -36,6 +37,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private var pendingOpenBookFile: File? = null
fun rememberPlatformOpenBookArguments(args: Array<String>) {
pendingOpenBookFile = args.asSequence()
.map(::File)
.firstOrNull { it.isFile && it.name.isSupportedBookFile() }
}
actual fun loadDefaultBookBytes(): ByteArray? { actual fun loadDefaultBookBytes(): ByteArray? {
var current = File(System.getProperty("user.dir")).absoluteFile var current = File(System.getProperty("user.dir")).absoluteFile
while (true) { 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 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) { actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
val chooser = JFileChooser(defaultLibraryScanPath()) val chooser = JFileChooser(defaultLibraryScanPath())
@ -301,6 +329,15 @@ actual suspend fun viewLibraryBookFile(folder: String, fileName: String): Boolea
}.getOrDefault(false) }.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 { private fun selectOrOpenBookFile(directory: File, file: File): Boolean {
if (isLinuxDesktop() && trySelectLinuxFile(file)) return true if (isLinuxDesktop() && trySelectLinuxFile(file)) return true
if (!Desktop.isDesktopSupported()) return false if (!Desktop.isDesktopSupported()) return false
@ -654,6 +691,9 @@ private fun runCommand(vararg command: String): String? =
} }
}.getOrNull() }.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 ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" private const val ThemeModeFlag = "theme_mode"
private const val ReaderFontSettingsFlag = "reader_font_settings" private const val ReaderFontSettingsFlag = "reader_font_settings"

View File

@ -3,11 +3,14 @@ package net.sergeych.toread
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
fun main() = application { fun main(args: Array<String>) {
Window( rememberPlatformOpenBookArguments(args)
onCloseRequest = ::exitApplication, application {
title = strings.appName, Window(
) { onCloseRequest = ::exitApplication,
App() title = strings.appName,
) {
App()
}
} }
} }

View File

@ -63,6 +63,9 @@ actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(folder: String, fileName: 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 loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null actual suspend fun loadActiveReadingFileId(): String? = null

View File

@ -26,6 +26,11 @@ data class LibraryScanSummary(
val currentFile: String? = null, val currentFile: String? = null,
) )
data class LibraryImportResult(
val fileId: String,
val imported: Boolean,
)
class LibraryScanner( class LibraryScanner(
private val database: H2LibraryDatabase, private val database: H2LibraryDatabase,
private val log: (String) -> Unit = {}, private val log: (String) -> Unit = {},
@ -85,17 +90,29 @@ class LibraryScanner(
storageUri: String, storageUri: String,
sizeBytes: Long?, sizeBytes: Long?,
lastModifiedMillis: 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() val rawSha256 = bytes.sha256Hex()
if (sizeBytes != null && database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).isNotEmpty()) { if (sizeBytes != null) {
return false database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).firstOrNull()?.let {
return LibraryImportResult(fileId = it.id, imported = false)
}
} }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget -> database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget ->
val fileId = "file-${UUID.randomUUID()}"
database.files.upsert( database.files.upsert(
BookFileRecord( BookFileRecord(
id = "file-${UUID.randomUUID()}", id = fileId,
bookId = duplicateTarget.bookId, bookId = duplicateTarget.bookId,
bodyId = duplicateTarget.bodyId, bodyId = duplicateTarget.bodyId,
bodyClusterId = duplicateTarget.bodyClusterId, bodyClusterId = duplicateTarget.bodyClusterId,
@ -114,7 +131,7 @@ class LibraryScanner(
updatedAt = now, updatedAt = now,
) )
) )
return true return LibraryImportResult(fileId = fileId, imported = true)
} }
val book = Fb2Format.parse(bytes, displayName) val book = Fb2Format.parse(bytes, displayName)
@ -129,6 +146,7 @@ class LibraryScanner(
database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256) database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256)
} }
val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}" val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}"
val fileId = "file-${UUID.randomUUID()}"
database.transaction { database.transaction {
if (duplicateTarget == null || duplicateTarget.bookId == null) { if (duplicateTarget == null || duplicateTarget.bookId == null) {
@ -177,7 +195,7 @@ class LibraryScanner(
} }
files.upsert( files.upsert(
BookFileRecord( BookFileRecord(
id = "file-${UUID.randomUUID()}", id = fileId,
bookId = bookId, bookId = bookId,
bodyId = bodyId, bodyId = bodyId,
bodyClusterId = clusterId, bodyClusterId = clusterId,
@ -197,7 +215,7 @@ class LibraryScanner(
) )
) )
} }
return true return LibraryImportResult(fileId = fileId, imported = true)
} }
private fun importLinkedFile(file: File): Boolean { private fun importLinkedFile(file: File): Boolean {

View File

@ -15,13 +15,14 @@ class LibraryScannerTest {
val db = H2LibraryDatabase.openMemory("scansFb2ZipSubtreeIntoLinkedLibraryRecords") val db = H2LibraryDatabase.openMemory("scansFb2ZipSubtreeIntoLinkedLibraryRecords")
try { try {
val summary = LibraryScanner(db).scanSubtree(File(root, "test_books")) val summary = LibraryScanner(db).scanSubtree(File(root, "test_books"))
val expectedFiles = LibraryScanner.countSupportedBookFiles(File(root, "test_books"))
assertEquals(1, summary.scannedFiles) assertEquals(expectedFiles, summary.scannedFiles)
assertEquals(1, summary.importedFiles) assertEquals(expectedFiles, summary.importedFiles)
assertEquals(0, summary.skippedFiles) assertEquals(0, summary.skippedFiles)
assertEquals(0, summary.failedFiles) 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) assertEquals("fb2.zip", file.format)
assertTrue(file.storageUri?.endsWith(".fb2.zip") == true) assertTrue(file.storageUri?.endsWith(".fb2.zip") == true)
val book = assertNotNull(file.bookId?.let(db.books::get)) val book = assertNotNull(file.bookId?.let(db.books::get))
@ -31,7 +32,7 @@ class LibraryScannerTest {
val second = LibraryScanner(db).scanSubtree(File(root, "test_books")) val second = LibraryScanner(db).scanSubtree(File(root, "test_books"))
assertEquals(0, second.importedFiles) assertEquals(0, second.importedFiles)
assertEquals(1, second.skippedFiles) assertEquals(expectedFiles, second.skippedFiles)
} finally { } finally {
db.close() 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 { private fun findProjectRoot(): File {
var current = File(System.getProperty("user.dir")).absoluteFile var current = File(System.getProperty("user.dir")).absoluteFile
while (true) { while (true) {