Compare commits

...

4 Commits

25 changed files with 1153 additions and 216 deletions

View File

@ -16,6 +16,9 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord
@ -224,6 +227,45 @@ actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dis
}
}
actual suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: Fb2Book): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.refreshBookCardFromParsedBook(fileId, book).item
}
}
actual suspend fun rescanAllLibraryBooks(): LibraryRescanReport = withContext(Dispatchers.IO) {
appendLibraryLog("rescan all library requested")
openLibraryDatabase().useLibrary { db ->
var scanned = 0
var updated = 0
var failed = 0
db.files.list(Int.MAX_VALUE, 0).forEach { file ->
val bytes = file.storageUri?.let(::readStorageUriBytes)
if (bytes == null) {
failed += 1
appendLibraryLog("rescan missing fileId=${file.id} uri=${file.storageUri}")
return@forEach
}
scanned += 1
val parsed = runCatching {
Fb2Format.parse(bytes, file.originalFilename ?: file.storageUri ?: file.id)
}
parsed
.onSuccess {
if (db.refreshBookCardFromParsedBook(file.id, it).updated) {
updated += 1
}
}
.onFailure {
failed += 1
appendLibraryLog("rescan failed fileId=${file.id} error=${it.message}")
}
}
appendLibraryLog("rescan all library finished scanned=$scanned updated=$updated failed=$failed")
LibraryRescanReport(scannedFiles = scanned, updatedFiles = updated, failedFiles = failed)
}
}
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
appendLibraryLog("delete fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
@ -268,6 +310,37 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
}
}
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val shareFile = openLibraryDatabase().useLibrary { db ->
val file = db.files.getLibraryFile(fileId) ?: return@useLibrary null
val bytes = readStorageUriBytes(file.storageUri ?: return@useLibrary null) ?: return@useLibrary null
val shareDir = File(appContext.cacheDir, "shared-books").also { it.mkdirs() }
File(shareDir, file.shareFileName()).also { it.writeBytes(bytes) }
} ?: return@withContext false
val uri = FileProvider.getUriForFile(
appContext,
"${appContext.packageName}.imageviewer.fileprovider",
shareFile,
)
val mimeType = shareFile.bookMimeType()
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "Share book").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
appContext.startActivity(chooser)
true
}.getOrDefault(false)
}
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
@ -528,6 +601,26 @@ private fun String.requiresExternalFileAccess(): Boolean {
private fun String.isSupportedBookFile(): Boolean =
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
private fun LibraryFileRecord.shareFileName(): String {
val raw = originalFilename?.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() }?.let { title ->
val extension = when {
format.equals("fb2.zip", ignoreCase = true) || storageUri?.endsWith(".fb2.zip", ignoreCase = true) == true -> ".fb2.zip"
else -> ".fb2"
}
"$title$extension"
}
?: "book.fb2"
return raw.replace(Regex("""[\\/:*?"<>|]+"""), "_")
}
private fun File.bookMimeType(): String =
if (name.endsWith(".zip", ignoreCase = true)) {
"application/zip"
} else {
"application/x-fictionbook+xml"
}
private fun displayNameFor(uri: Uri): String =
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
@ -582,6 +675,58 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
importedAt = importedAt,
)
private data class ParsedBookCover(
val bytes: ByteArray,
val mimeType: String?,
)
private data class BookCardRefresh(
val updated: Boolean,
val item: LibraryItem?,
)
private fun H2LibraryDatabase.refreshBookCardFromParsedBook(fileId: String, book: Fb2Book): BookCardRefresh {
val file = files.get(fileId) ?: return BookCardRefresh(updated = false, item = null)
val bookId = file.bookId ?: return BookCardRefresh(updated = false, item = null)
val stored = books.get(bookId) ?: return BookCardRefresh(updated = false, item = null)
val cover = book.libraryCardCover()
val next = stored.copy(
title = book.title.ifBlank { file.originalFilename?.substringBeforeLast('.') ?: stored.title.orEmpty() },
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
language = book.language,
date = book.date,
description = book.annotation,
keywords = book.keywords,
coverImage = cover?.bytes,
coverImageMimeType = cover?.mimeType,
updatedAt = System.currentTimeMillis(),
)
val updated = !stored.hasSameCardMetadata(next)
if (updated) {
appendLibraryLog("refresh book card fileId=$fileId bookId=$bookId title=${next.title}")
books.upsert(next)
}
return BookCardRefresh(updated = updated, item = files.getLibraryFile(fileId)?.toLibraryItem())
}
private fun Fb2Book.libraryCardCover(): ParsedBookCover? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
val binary = image?.let(::binaryFor) ?: return null
return runCatching {
ParsedBookCover(bytes = binary.imageBytes(), mimeType = binary.contentType)
}.getOrNull()
}
private fun BookRecord.hasSameCardMetadata(other: BookRecord): Boolean =
title == other.title &&
authors == other.authors &&
language == other.language &&
date == other.date &&
description == other.description &&
keywords == other.keywords &&
coverImage.contentEquals(other.coverImage) &&
coverImageMimeType == other.coverImageMimeType
private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/toread.log")

View File

@ -0,0 +1,13 @@
package net.sergeych.toread
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
@Composable
internal actual fun PlatformBackHandler(
enabled: Boolean,
navigationDepth: Int,
onBack: () -> Unit,
) {
BackHandler(enabled = enabled, onBack = onBack)
}

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="clipboard_images" path="clipboard-images/"/>
<cache-path name="shared_books" path="shared-books/"/>
</paths>

View File

@ -103,6 +103,41 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
state = loadStartupState()
}
fun navigateBack() {
imageViewer?.let {
imageViewer = null
return
}
state = when (val current = state) {
is AppState.BookInfo -> AppState.Reader(
fileId = current.fileId,
book = current.book,
libraryItems = current.libraryItems,
scanPath = current.scanPath,
message = current.message,
)
is AppState.Reader -> {
scope.launch { saveActiveReadingFileId(null) }
AppState.Library(current.libraryItems, current.scanPath, current.message)
}
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
is AppState.Error -> AppState.LoadingLibrary
is AppState.Library, AppState.LoadingLibrary -> current
}
}
val navigationDepth = when (state) {
is AppState.BookInfo -> 2
is AppState.Error, is AppState.Reader, is AppState.Scan -> 1
is AppState.Library, AppState.LoadingLibrary -> 0
} + if (imageViewer != null) 1 else 0
PlatformBackHandler(
enabled = navigationDepth > 0,
navigationDepth = navigationDepth,
onBack = ::navigateBack,
)
fun startScan(path: String) {
if (scanJob?.isActive == true) return
activeScan = LibraryScanProgress(0, 0, 0, 0)
@ -126,7 +161,17 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
scanJob = null
activeScan = null
state = when (val current = state) {
is AppState.Library, is AppState.Scan, AppState.LoadingLibrary -> loadLibraryState(message, path)
is AppState.Library -> if (report.getOrNull()?.hasLibraryChanges() == true) {
loadLibraryState(message, path)
} else {
current.copy(scanPath = path, message = message)
}
is AppState.Scan -> if (report.getOrNull()?.hasLibraryChanges() == true) {
loadLibraryState(message, path)
} else {
AppState.Library(current.items, path, message)
}
AppState.LoadingLibrary -> loadLibraryState(message, path)
is AppState.Reader -> current.copy(message = message)
is AppState.BookInfo -> current.copy(message = message)
is AppState.Error -> current
@ -166,31 +211,24 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
message = current.message,
)
},
onBack = {
state = AppState.Library(emptyList(), current.scanPath, current.message)
onDeleted = { message ->
state = AppState.Library(emptyList(), current.scanPath, message)
},
onBack = ::navigateBack,
)
is AppState.BookInfo -> BookInfoScreen(
fileId = current.fileId,
book = current.book,
onImageOpen = { imageViewer = it },
onBack = {
state = AppState.Reader(
fileId = current.fileId,
book = current.book,
libraryItems = current.libraryItems,
scanPath = current.scanPath,
message = current.message,
onBack = ::navigateBack,
)
},
)
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
is AppState.Error -> ErrorScreen(current.message, onBack = ::navigateBack)
}
imageViewer?.let { image ->
ImageViewer(
image = image,
onBack = { imageViewer = null },
onBack = ::navigateBack,
)
}
}
@ -201,3 +239,6 @@ internal data class ViewedBookImage(
val mimeType: String,
val title: String,
)
private fun LibraryScanReport.hasLibraryChanges(): Boolean =
importedFiles > 0

View File

@ -21,7 +21,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -58,9 +57,7 @@ internal fun BookInfoScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
colors = themedTopAppBarColors(),
)
},
) {

View File

@ -24,7 +24,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -105,7 +104,7 @@ internal fun ImageViewer(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
Surface(color = MaterialTheme.colorScheme.surface) {
ThemedTopBarSurface {
Row(
modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically,

View File

@ -0,0 +1,14 @@
package net.sergeych.toread
internal data class LibraryDeleteResult(
val deleted: Boolean,
val message: String,
)
internal suspend fun deleteLibraryBook(fileId: String, title: String): LibraryDeleteResult {
val deleted = runCatching { deleteLibraryItem(fileId) }.getOrDefault(false)
return LibraryDeleteResult(
deleted = deleted,
message = if (deleted) "Removed $title." else "Could not remove $title.",
)
}

View File

@ -44,6 +44,12 @@ data class LibraryScanProgress(
val totalFiles: Int? = null,
)
data class LibraryRescanReport(
val scannedFiles: Int,
val updatedFiles: Int,
val failedFiles: Int,
)
data class PlatformOpenBookRequest(
val id: String,
val displayName: String,
@ -100,6 +106,10 @@ expect suspend fun scanLibrarySubtree(path: String, onProgress: (LibraryScanProg
expect suspend fun openLibraryBook(fileId: String): ByteArray?
expect suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: Fb2Book): LibraryItem?
expect suspend fun rescanAllLibraryBooks(): LibraryRescanReport
expect suspend fun deleteLibraryItem(fileId: String): Boolean
expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
@ -108,6 +118,10 @@ expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
expect suspend fun shareLibraryBookFile(fileId: String): Boolean
expect suspend fun viewLibraryBookFile(fileId: String): Boolean
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String?

View File

@ -17,13 +17,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
@ -36,12 +39,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -53,6 +53,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
@ -60,6 +61,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Format
@ -91,6 +93,9 @@ internal fun LibraryScreen(
var searchText by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) }
var readingNowCollapsed by remember { mutableStateOf(false) }
var myLibraryCollapsed by remember { mutableStateOf(false) }
var notInterestedCollapsed by remember { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank()
val visibleItems = if (searchActive) searchResults else items
@ -98,18 +103,28 @@ internal fun LibraryScreen(
suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return
loadingPage = true
val previousItems = items
if (reset) {
items = emptyList()
nextOffset = 0
endReached = false
coverCache.clear()
}
val offset = if (reset) 0 else nextOffset
try {
val page = loadLibraryItemsPage(LibraryPageSize, offset)
items = if (reset) page else items + page
val limit = if (reset) maxOf(LibraryPageSize, previousItems.size) else LibraryPageSize
val page = loadLibraryItemsPage(limit, offset)
if (reset) {
if (page != previousItems) {
items = page
}
val visibleFileIds = page.mapTo(mutableSetOf()) { it.fileId }
coverCache.keys.toList().forEach { fileId ->
if (fileId !in visibleFileIds) coverCache.remove(fileId)
}
} else {
items = items + page
}
nextOffset = offset + page.size
endReached = page.size < LibraryPageSize
endReached = page.size < limit
} catch (t: Throwable) {
message = t.message ?: "Could not load library."
endReached = true
@ -131,6 +146,24 @@ internal fun LibraryScreen(
}
}
fun rescanAllLibrary() {
settingsMenuOpen = false
scope.launch {
busy = true
message = "Rescanning library..."
try {
val report = rescanAllLibraryBooks()
refresh(
"Rescanned ${report.scannedFiles}, updated ${report.updatedFiles}, failed ${report.failedFiles}.",
)
} catch (t: Throwable) {
message = t.message ?: "Library rescan failed."
} finally {
busy = false
}
}
}
fun clearSearch() {
searchText = ""
searchResults = emptyList()
@ -205,18 +238,19 @@ internal fun LibraryScreen(
modifier = Modifier.fillMaxWidth(),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
colors = themedTopAppBarColors(),
actions = {
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
}
Box {
IconButton(onClick = { settingsMenuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Library options")
}
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) {
// DropdownMenuItem(
// text = { Text("Rescan all library") },
// enabled = !busy && activeScan == null,
// onClick = ::rescanAllLibrary,
// )
// HorizontalDivider()
DropdownMenuItem(
leadingIcon = {
Checkbox(checked = autoScanDownloads, onCheckedChange = null)
@ -271,24 +305,7 @@ internal fun LibraryScreen(
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING
if (hasReadingNow) {
item(key = "section-reading") {
LibrarySectionHeader("reading now")
}
}
itemsIndexed(visibleItems, key = { _, item -> item.fileId }) { index, item ->
if (
hasReadingNow &&
item.readingStatus != BookReadingStatus.READING &&
(index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING)
) {
LibrarySectionHeader("my library")
}
LibraryRow(
item = item,
coverCache = coverCache,
enabled = !busy,
fun itemActions(item: LibraryItem) = LibraryItemActions(
onOpen = {
scope.launch {
busy = true
@ -296,12 +313,19 @@ internal fun LibraryScreen(
val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
var readerLibraryItems = visibleItems
refreshLibraryItemFromParsedBook(item.fileId, book)?.let { updatedItem ->
items = items.replaceLibraryItem(updatedItem)
searchResults = searchResults.replaceLibraryItem(updatedItem)
readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem)
coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId)
}
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
saveActiveReadingFileId(item.fileId)
AppState.Reader(
fileId = item.fileId,
book = book,
libraryItems = visibleItems,
libraryItems = readerLibraryItems,
scanPath = state.scanPath,
message = message,
)
@ -344,12 +368,27 @@ internal fun LibraryScreen(
}
}
},
onRemoveMarks = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Removed marks from ${item.title}."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onNotInterested = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
message = "Marked ${item.title} as not interesting."
message = "Marked ${item.title} as not interested."
refresh()
} else {
message = "Could not update ${item.title}."
@ -363,9 +402,9 @@ internal fun LibraryScreen(
scope.launch {
busy = true
try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
if (deleted) {
val result = deleteLibraryBook(item.fileId, item.title)
message = result.message
if (result.deleted) {
items = items.filterNot { it.fileId == item.fileId }
searchResults = searchResults.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId)
@ -377,6 +416,52 @@ internal fun LibraryScreen(
}
},
)
fun LazyListScope.libraryRows(sectionKey: String, sectionItems: List<LibraryItem>) {
itemsIndexed(sectionItems, key = { _, item -> "$sectionKey-${item.fileId}" }) { _, item ->
LibraryRow(
item = item,
coverCache = coverCache,
enabled = !busy,
actions = itemActions(item),
)
}
}
if (searchActive) {
libraryRows("search", visibleItems)
} else {
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
val myLibrary = visibleItems.filter { it.readingStatus != BookReadingStatus.READING && it.readingStatus != BookReadingStatus.NOT_INTERESTED }
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
librarySection(
key = "reading",
title = "reading now",
count = readingNow.size,
collapsed = readingNowCollapsed,
onCollapsedChange = { readingNowCollapsed = it },
) {
libraryRows("reading", readingNow)
}
librarySection(
key = "library",
title = "my library",
count = myLibrary.size,
collapsed = myLibraryCollapsed,
onCollapsedChange = { myLibraryCollapsed = it },
) {
libraryRows("library", myLibrary)
}
librarySection(
key = "not-interested",
title = "not interested",
count = notInterested.size,
collapsed = notInterestedCollapsed,
onCollapsedChange = { notInterestedCollapsed = it },
) {
libraryRows("not-interested", notInterested)
}
}
if (!searchActive && !endReached) {
item(key = "load-more") {
@ -412,14 +497,13 @@ private fun LibrarySearchField(
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(18.dp)
OutlinedTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
val shape = RoundedCornerShape(16.dp)
Row(
modifier = modifier
.height(56.dp)
.height(42.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
onClear()
@ -428,38 +512,43 @@ private fun LibrarySearchField(
false
}
},
placeholder = {
Text(
"Search library",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
maxLines = 1,
)
},
leadingIcon = {
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Search,
contentDescription = "Search library",
modifier = Modifier.size(20.dp),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline,
)
},
trailingIcon = {
if (value.isNotBlank()) {
IconButton(onClick = onClear, modifier = Modifier.size(34.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(18.dp))
}
}
},
shape = shape,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
cursorColor = MaterialTheme.colorScheme.primary,
),
Box(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp, end = 6.dp),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
)
if (value.isBlank()) {
Text(
"Search library",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
maxLines = 1,
)
}
}
if (value.isNotBlank()) {
IconButton(onClick = onClear, modifier = Modifier.size(30.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(17.dp))
}
}
}
}
@Composable
@ -481,16 +570,57 @@ private fun EmptySearchPane(modifier: Modifier = Modifier) {
}
}
private fun LazyListScope.librarySection(
key: String,
title: String,
count: Int,
collapsed: Boolean,
onCollapsedChange: (Boolean) -> Unit,
content: LazyListScope.() -> Unit,
) {
if (count == 0) return
item(key = "section-$key") {
LibrarySectionHeader(
text = title,
count = count,
collapsed = collapsed,
onToggle = { onCollapsedChange(!collapsed) },
)
}
if (!collapsed) {
content()
}
}
@Composable
private fun LibrarySectionHeader(text: String) {
private fun LibrarySectionHeader(
text: String,
count: Int,
collapsed: Boolean,
onToggle: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(top = 10.dp, bottom = 4.dp, start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
if (collapsed) Icons.AutoMirrored.Filled.KeyboardArrowRight else Icons.Filled.KeyboardArrowDown,
contentDescription = if (collapsed) "Expand $text" else "Collapse $text",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(18.dp),
)
Text(
text,
"$text ($count)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp),
)
}
}
@Composable
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
@ -527,11 +657,7 @@ private fun LibraryRow(
item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean,
onOpen: () -> Unit,
onMarkAsRead: () -> Unit,
onMarkAsUnread: () -> Unit,
onNotInterested: () -> Unit,
onDelete: () -> Unit,
actions: LibraryItemActions,
) {
var menuOpen by remember { mutableStateOf(false) }
@ -539,7 +665,7 @@ private fun LibraryRow(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onOpen)
.clickable(enabled = enabled, onClick = actions.onOpen)
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
@ -551,12 +677,14 @@ private fun LibraryRow(
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
item.authors.joinToString().ifBlank { "Unknown author" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
item.libraryMetadataLine(),
@ -574,7 +702,7 @@ private fun LibraryRow(
text = { Text("Open") },
onClick = {
menuOpen = false
onOpen()
actions.onOpen()
},
)
HorizontalDivider()
@ -583,7 +711,7 @@ private fun LibraryRow(
text = { Text("Mark as read") },
onClick = {
menuOpen = false
onMarkAsRead()
actions.onMarkAsRead()
},
)
}
@ -592,15 +720,24 @@ private fun LibraryRow(
text = { Text("Mark as unread") },
onClick = {
menuOpen = false
onMarkAsUnread()
actions.onMarkAsUnread()
},
)
}
if (item.readingStatus != BookReadingStatus.NEW) {
DropdownMenuItem(
text = { Text("Remove marks") },
onClick = {
menuOpen = false
actions.onRemoveMarks()
},
)
}
DropdownMenuItem(
text = { Text("Not interesting") },
text = { Text("Not interested") },
onClick = {
menuOpen = false
onNotInterested()
actions.onNotInterested()
},
)
HorizontalDivider()
@ -608,7 +745,7 @@ private fun LibraryRow(
text = { Text("Delete") },
onClick = {
menuOpen = false
onDelete()
actions.onDelete()
},
)
}
@ -617,6 +754,15 @@ private fun LibraryRow(
}
}
private data class LibraryItemActions(
val onOpen: () -> Unit,
val onMarkAsRead: () -> Unit,
val onMarkAsUnread: () -> Unit,
val onRemoveMarks: () -> Unit,
val onNotInterested: () -> Unit,
val onDelete: () -> Unit,
)
@Composable
private fun LibraryCover(
item: LibraryItem,
@ -685,6 +831,9 @@ private fun Long.formatBytes(): String =
else -> "$this B"
}
private fun List<LibraryItem>.replaceLibraryItem(item: LibraryItem): List<LibraryItem> =
map { current -> if (current.fileId == item.fileId) item else current }
private fun LibraryScanProgress.toCatalogScanMessage(): String {
val total = totalFiles ?: return "Scanned $scannedFiles books"
val percent = if (total <= 0) {

View File

@ -0,0 +1,10 @@
package net.sergeych.toread
import androidx.compose.runtime.Composable
@Composable
internal expect fun PlatformBackHandler(
enabled: Boolean,
navigationDepth: Int,
onBack: () -> Unit,
)

View File

@ -3,6 +3,10 @@ package net.sergeych.toread
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -30,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -38,6 +43,9 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
@ -67,6 +75,7 @@ import net.sergeych.toread.fb2.Fb2TextSpan
import net.sergeych.toread.fb2.Fb2TextStyle
import net.sergeych.toread.text.HyphenationRegistry
import net.sergeych.toread.text.SoftHyphen
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.min
@ -79,16 +88,29 @@ internal fun ContinuousBookReader(
onImageOpen: (ViewedBookImage) -> Unit = {},
) {
val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope()
val contentPadding = if (isAndroidPlatform()) {
PaddingValues(start = 6.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
} else {
PaddingValues(horizontal = 8.dp, vertical = 6.dp)
PaddingValues(horizontal = 4.dp, vertical = 6.dp)
}
LazyColumn(
state = listState,
modifier = modifier
.background(MaterialTheme.colorScheme.surface),
.background(MaterialTheme.colorScheme.surface)
.pageTurnOnTouchTap(
onPageDown = {
scope.launch {
listState.animateScrollBy(listState.pageScrollDistance())
}
},
onPageUp = {
scope.launch {
listState.animateScrollBy(-listState.pageScrollDistance())
}
},
),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
@ -116,6 +138,34 @@ internal fun ContinuousBookReader(
}
}
private fun Modifier.pageTurnOnTouchTap(
onPageDown: () -> Unit,
onPageUp: () -> Unit,
): Modifier = pointerInput(onPageDown, onPageUp) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final)
if (down.type != PointerType.Touch) {
waitForUpOrCancellation(pass = PointerEventPass.Final)
return@awaitEachGesture
}
val up = waitForUpOrCancellation(pass = PointerEventPass.Final) ?: return@awaitEachGesture
if (up.isConsumed) return@awaitEachGesture
if (down.position.x < size.width / 2f) {
onPageDown()
} else {
onPageUp()
}
}
}
private fun LazyListState.pageScrollDistance(): Float {
val layoutInfo = layoutInfo
val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
return viewportHeight.toFloat().coerceAtLeast(0f)
}
private fun LazyListScope.sectionItems(
book: Fb2Book,
section: Fb2Section,
@ -411,8 +461,9 @@ private fun ReaderText(
@Composable
private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp,
lineHeight = 27.sp,
fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal,
fontSize = if( isAndroidPlatform()) 21.sp else 18.sp,
lineHeight = 28.sp,
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },

View File

@ -12,14 +12,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -48,13 +53,36 @@ internal fun BookView(
onImageOpen: (ViewedBookImage) -> Unit,
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onDeleted: (String) -> Unit,
onBack: () -> Unit,
) {
val stats = remember(book) { BookStats.from(book) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
val platformName = getPlatform().name
val showShareAction = platformName.startsWith("Android")
val showViewFileAction = platformName.startsWith("Java")
fun showMessage(message: String) {
scope.launch {
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
}
}
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
scope.launch {
if (markLibraryReadingStatus(fileId, status)) {
if (status == BookReadingStatus.READ) markedRead = true
if (status == BookReadingStatus.NEW) markedRead = false
showMessage(successMessage)
} else {
showMessage("Could not update book.")
}
}
}
LaunchedEffect(fileId) {
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
@ -92,6 +120,7 @@ internal fun BookView(
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
CompactReaderTopBar(
title = book.title,
@ -105,6 +134,40 @@ internal fun BookView(
onBookInfo()
}
},
onMarkAsRead = {
setReadingStatus(BookReadingStatus.READ, "Marked as read.")
},
onNotInterested = {
setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.")
},
onClearMarks = {
setReadingStatus(BookReadingStatus.NEW, "Cleared marks.")
},
showShareAction = showShareAction,
onShare = {
scope.launch {
val shared = shareLibraryBookFile(fileId)
showMessage(if (shared) "Share opened." else "Could not share book.")
}
},
showViewFileAction = showViewFileAction,
onViewFile = {
scope.launch {
val opened = viewLibraryBookFile(fileId)
showMessage(if (opened) "Opened file location." else "Could not open file location.")
}
},
onDelete = {
scope.launch {
val result = deleteLibraryBook(fileId, book.title)
if (result.deleted) {
saveActiveReadingFileId(null)
onDeleted(result.message)
} else {
showMessage(result.message)
}
}
},
onBack = {
scope.launch {
saveLibraryReadingPosition(
@ -140,9 +203,19 @@ private fun CompactReaderTopBar(
title: String,
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onMarkAsRead: () -> Unit,
onNotInterested: () -> Unit,
onClearMarks: () -> Unit,
showShareAction: Boolean,
onShare: () -> Unit,
showViewFileAction: Boolean,
onViewFile: () -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surface) {
var menuOpen by remember { mutableStateOf(false) }
ThemedTopBarSurface {
Row(
modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically,
@ -159,12 +232,74 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = "Theme")
}
IconButton(onClick = onBookInfo) {
Icon(Icons.Filled.Info, contentDescription = "Properties")
}
IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Info...") },
onClick = {
menuOpen = false
onBookInfo()
},
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Mark as read") },
onClick = {
menuOpen = false
onMarkAsRead()
},
)
DropdownMenuItem(
text = { Text("Not interested") },
onClick = {
menuOpen = false
onNotInterested()
},
)
DropdownMenuItem(
text = { Text("Clear marks") },
onClick = {
menuOpen = false
onClearMarks()
},
)
if (showShareAction || showViewFileAction) {
HorizontalDivider()
}
if (showShareAction) {
DropdownMenuItem(
text = { Text("Share") },
onClick = {
menuOpen = false
onShare()
},
)
}
if (showViewFileAction) {
DropdownMenuItem(
text = { Text("View file") },
onClick = {
menuOpen = false
onViewFile()
},
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
menuOpen = false
onDelete()
},
)
}
}
}
}
}

View File

@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -62,9 +61,7 @@ internal fun ScanScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
colors = themedTopAppBarColors(),
)
},
) {

View File

@ -3,16 +3,21 @@ package net.sergeych.toread
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -75,6 +80,30 @@ internal fun quietCardColors() = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
)
@Composable
internal fun ThemedTopBarSurface(content: @Composable ColumnScope.() -> Unit) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Column {
content()
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun themedTopAppBarColors(): TopAppBarColors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
@Composable
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background)

View File

@ -3,6 +3,9 @@ package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord
@ -10,6 +13,7 @@ import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image
import java.awt.Desktop
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
@ -174,6 +178,45 @@ actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dis
}
}
actual suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: Fb2Book): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.refreshBookCardFromParsedBook(fileId, book).item
}
}
actual suspend fun rescanAllLibraryBooks(): LibraryRescanReport = withContext(Dispatchers.IO) {
appendLibraryLog("rescan all library requested")
openLibraryDatabase().useLibrary { db ->
var scanned = 0
var updated = 0
var failed = 0
db.files.list(Int.MAX_VALUE, 0).forEach { file ->
val bytes = file.storageUri?.let { File(it) }?.takeIf { it.isFile }?.readBytes()
if (bytes == null) {
failed += 1
appendLibraryLog("rescan missing fileId=${file.id} uri=${file.storageUri}")
return@forEach
}
scanned += 1
val parsed = runCatching {
Fb2Format.parse(bytes, file.originalFilename ?: file.storageUri ?: file.id)
}
parsed
.onSuccess {
if (db.refreshBookCardFromParsedBook(file.id, it).updated) {
updated += 1
}
}
.onFailure {
failed += 1
appendLibraryLog("rescan failed fileId=${file.id} error=${it.message}")
}
}
appendLibraryLog("rescan all library finished scanned=$scanned updated=$updated failed=$failed")
LibraryRescanReport(scannedFiles = scanned, updatedFiles = updated, failedFiles = failed)
}
}
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
appendLibraryLog("delete fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
@ -218,6 +261,24 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
}
}
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
if (!Desktop.isDesktopSupported()) return@withContext false
val file = openLibraryDatabase().useLibrary { db ->
db.files.get(fileId)?.storageUri?.let(::File)?.takeIf { it.isFile }
} ?: return@withContext false
val desktop = Desktop.getDesktop()
if (desktop.isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
desktop.browseFileDirectory(file)
} else {
desktop.open(file.parentFile ?: file)
}
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()
@ -414,6 +475,58 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
importedAt = importedAt,
)
private data class ParsedBookCover(
val bytes: ByteArray,
val mimeType: String?,
)
private data class BookCardRefresh(
val updated: Boolean,
val item: LibraryItem?,
)
private fun H2LibraryDatabase.refreshBookCardFromParsedBook(fileId: String, book: Fb2Book): BookCardRefresh {
val file = files.get(fileId) ?: return BookCardRefresh(updated = false, item = null)
val bookId = file.bookId ?: return BookCardRefresh(updated = false, item = null)
val stored = books.get(bookId) ?: return BookCardRefresh(updated = false, item = null)
val cover = book.libraryCardCover()
val next = stored.copy(
title = book.title.ifBlank { file.originalFilename?.substringBeforeLast('.') ?: stored.title.orEmpty() },
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
language = book.language,
date = book.date,
description = book.annotation,
keywords = book.keywords,
coverImage = cover?.bytes,
coverImageMimeType = cover?.mimeType,
updatedAt = System.currentTimeMillis(),
)
val updated = !stored.hasSameCardMetadata(next)
if (updated) {
appendLibraryLog("refresh book card fileId=$fileId bookId=$bookId title=${next.title}")
books.upsert(next)
}
return BookCardRefresh(updated = updated, item = files.getLibraryFile(fileId)?.toLibraryItem())
}
private fun Fb2Book.libraryCardCover(): ParsedBookCover? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
val binary = image?.let(::binaryFor) ?: return null
return runCatching {
ParsedBookCover(bytes = binary.imageBytes(), mimeType = binary.contentType)
}.getOrNull()
}
private fun BookRecord.hasSameCardMetadata(other: BookRecord): Boolean =
title == other.title &&
authors == other.authors &&
language == other.language &&
date == other.date &&
description == other.description &&
keywords == other.keywords &&
coverImage.contentEquals(other.coverImage) &&
coverImageMimeType == other.coverImageMimeType
private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log")

View File

@ -0,0 +1,10 @@
package net.sergeych.toread
import androidx.compose.runtime.Composable
@Composable
internal actual fun PlatformBackHandler(
enabled: Boolean,
navigationDepth: Int,
onBack: () -> Unit,
) = Unit

View File

@ -38,6 +38,11 @@ actual suspend fun scanLibrarySubtree(
actual suspend fun openLibraryBook(fileId: String): ByteArray? = null
actual suspend fun refreshLibraryItemFromParsedBook(fileId: String, book: net.sergeych.toread.fb2.Fb2Book): LibraryItem? = null
actual suspend fun rescanAllLibraryBooks(): LibraryRescanReport =
LibraryRescanReport(scannedFiles = 0, updatedFiles = 0, failedFiles = 0)
actual suspend fun deleteLibraryItem(fileId: String): Boolean = false
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = null
@ -46,6 +51,10 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null

View File

@ -0,0 +1,44 @@
package net.sergeych.toread
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import kotlin.js.ExperimentalWasmJsInterop
import kotlinx.browser.window
import org.w3c.dom.events.Event
private var currentBrowserNavigationDepth = 0
@Composable
@OptIn(ExperimentalWasmJsInterop::class)
internal actual fun PlatformBackHandler(
enabled: Boolean,
navigationDepth: Int,
onBack: () -> Unit,
) {
val currentOnBack = rememberUpdatedState(onBack)
LaunchedEffect(navigationDepth) {
if (navigationDepth > currentBrowserNavigationDepth) {
repeat(navigationDepth - currentBrowserNavigationDepth) {
window.history.pushState(null, "", window.location.href)
}
}
currentBrowserNavigationDepth = navigationDepth
}
DisposableEffect(enabled) {
if (!enabled) {
onDispose { }
} else {
val listener: (Event) -> Unit = {
currentOnBack.value()
}
window.addEventListener("popstate", listener)
onDispose {
window.removeEventListener("popstate", listener)
}
}
}
}

View File

@ -20,7 +20,7 @@ The common API is `Fb2Format.parse(input: ByteArray, fileName: String? = null)`.
Import detection:
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
- Otherwise bytes are decoded as UTF-8 XML.
- Otherwise bytes are decoded according to the XML declaration when supported. UTF-8 is the default, and unsupported or missing encodings fall back to UTF-8. `windows-1251` is supported for legacy FB2 files.
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
ZIP support:

View File

@ -10,7 +10,7 @@ object Fb2Format {
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
Fb2Zip.extractFb2Xml(input)
} else {
input.decodeToString()
Fb2XmlEncoding.decodeXml(input)
}
return parseXml(xml)
}

View File

@ -0,0 +1,103 @@
package net.sergeych.toread.fb2
internal object Fb2XmlEncoding {
private val EncodingPattern = Regex("""encoding\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE)
fun decodeXml(bytes: ByteArray): String =
when (declaredEncoding(bytes)?.lowercase()) {
"windows-1251" -> decodeWindows1251(bytes)
else -> bytes.decodeToString()
}
private fun declaredEncoding(bytes: ByteArray): String? {
val probe = bytes
.copyOfRange(0, minOf(bytes.size, 256))
.map { byte ->
val value = byte.toInt() and 0xff
if (value in 0x20..0x7e || value == '\n'.code || value == '\r'.code || value == '\t'.code) {
value.toChar()
} else {
' '
}
}
.joinToString("")
return EncodingPattern.find(probe)?.groupValues?.getOrNull(1)?.trim()
}
private fun decodeWindows1251(bytes: ByteArray): String = buildString(bytes.size) {
bytes.forEach { byte ->
append(windows1251Char(byte.toInt() and 0xff))
}
}
private fun windows1251Char(value: Int): Char =
when (value) {
in 0x00..0x7f -> value.toChar()
0x80 -> '\u0402'
0x81 -> '\u0403'
0x82 -> '\u201a'
0x83 -> '\u0453'
0x84 -> '\u201e'
0x85 -> '\u2026'
0x86 -> '\u2020'
0x87 -> '\u2021'
0x88 -> '\u20ac'
0x89 -> '\u2030'
0x8a -> '\u0409'
0x8b -> '\u2039'
0x8c -> '\u040a'
0x8d -> '\u040c'
0x8e -> '\u040b'
0x8f -> '\u040f'
0x90 -> '\u0452'
0x91 -> '\u2018'
0x92 -> '\u2019'
0x93 -> '\u201c'
0x94 -> '\u201d'
0x95 -> '\u2022'
0x96 -> '\u2013'
0x97 -> '\u2014'
0x98 -> '\uFFFD'
0x99 -> '\u2122'
0x9a -> '\u0459'
0x9b -> '\u203a'
0x9c -> '\u045a'
0x9d -> '\u045c'
0x9e -> '\u045b'
0x9f -> '\u045f'
0xa0 -> '\u00a0'
0xa1 -> '\u040e'
0xa2 -> '\u045e'
0xa3 -> '\u0408'
0xa4 -> '\u00a4'
0xa5 -> '\u0490'
0xa6 -> '\u00a6'
0xa7 -> '\u00a7'
0xa8 -> '\u0401'
0xa9 -> '\u00a9'
0xaa -> '\u0404'
0xab -> '\u00ab'
0xac -> '\u00ac'
0xad -> '\u00ad'
0xae -> '\u00ae'
0xaf -> '\u0407'
0xb0 -> '\u00b0'
0xb1 -> '\u00b1'
0xb2 -> '\u0406'
0xb3 -> '\u0456'
0xb4 -> '\u0491'
0xb5 -> '\u00b5'
0xb6 -> '\u00b6'
0xb7 -> '\u00b7'
0xb8 -> '\u0451'
0xb9 -> '\u2116'
0xba -> '\u0454'
0xbb -> '\u00bb'
0xbc -> '\u0458'
0xbd -> '\u0405'
0xbe -> '\u0455'
0xbf -> '\u0457'
in 0xc0..0xff -> (0x0410 + value - 0xc0).toChar()
else -> '\uFFFD'
}
}

View File

@ -12,7 +12,7 @@ internal object Fb2Zip {
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
?: entries.firstOrNull { !it.name.endsWith("/") }
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
return readEntry(zip, entry).decodeToString()
return Fb2XmlEncoding.decodeXml(readEntry(zip, entry))
}
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {

View File

@ -43,6 +43,31 @@ class Fb2FormatTest {
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04)))
}
@Test
fun parsesWindows1251PlainXml() {
val book = Fb2Format.parse(windows1251Xml.encodeWindows1251(), "legacy.fb2")
assertEquals("Тестовая книга", book.title)
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
}
@Test
fun parsesWindows1251StoredZip() {
val zip = Fb2Zip.createStoredZip("legacy.fb2", windows1251Xml.encodeWindows1251())
val book = Fb2Format.parse(zip, "legacy.fb2.zip")
assertEquals("Тестовая книга", book.title)
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
}
@Test
fun fallsBackToUtf8ForUnknownEncoding() {
val xml = sampleXml.replace("encoding=\"UTF-8\"", "encoding=\"KOI8-R\"")
val book = Fb2Format.parse(xml.encodeToByteArray(), "unknown.fb2")
assertEquals("The Test Book", book.title)
}
@Test
fun preservesReadableBlocksAndInlineStyles() {
val book = Fb2Format.parseXml(richXml)
@ -108,4 +133,37 @@ class Fb2FormatTest {
</body>
</FictionBook>
""".trimIndent()
private val windows1251Xml = """
<?xml version="1.0" encoding="windows-1251"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<author><nickname>Автор</nickname></author>
<book-title>Тестовая книга</book-title>
<lang>ru</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>legacy</id>
<version>1.0</version>
</document-info>
</description>
<body><section><p>Привет, мир.</p></section></body>
</FictionBook>
""".trimIndent()
private fun String.encodeWindows1251(): ByteArray =
ByteArray(length) { index ->
val char = this[index]
val value = when {
char.code <= 0x7f -> char.code
char in 'А'..'я' -> 0xc0 + char.code - 'А'.code
char == 'Ё' -> 0xa8
char == 'ё' -> 0xb8
else -> error("Test character $char is not mapped to windows-1251")
}
value.toByte()
}
}

View File

@ -698,7 +698,11 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
LEFT JOIN books b ON b.id = f.book_id
WHERE f.duplicate_of_file_id IS NULL
ORDER BY
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
CASE
WHEN f.reading_status = 'READING' THEN 0
WHEN f.reading_status = 'NOT_INTERESTED' THEN 2
ELSE 1
END,
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
f.id

View File

@ -158,8 +158,8 @@ class H2LibraryDatabaseTest {
}
@Test
fun listsReadingBooksFirstThenSortsByTitle() {
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstThenSortsByTitle")
fun listsReadingBooksFirstNotInterestedLastThenSortsByTitle() {
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstNotInterestedLastThenSortsByTitle")
val now = 1_700_000_000_000L
db.transaction {
@ -168,6 +168,7 @@ class H2LibraryDatabaseTest {
Triple("book-alpha", "Alpha", BookReadingStatus.READ),
Triple("book-gamma", "Gamma", BookReadingStatus.READING),
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING),
Triple("book-omega", "Omega", BookReadingStatus.NOT_INTERESTED),
).forEachIndexed { index, (bookId, title, status) ->
books.upsert(
BookRecord(
@ -194,7 +195,7 @@ class H2LibraryDatabaseTest {
}
assertEquals(
listOf("Aardvark", "Gamma", "Alpha", "Beta"),
listOf("Aardvark", "Gamma", "Alpha", "Beta", "Omega"),
db.files.listLibraryFiles().map { it.title },
)
db.close()