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.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import net.sergeych.toread.fb2.Fb2Binary 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.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord 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) { actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
appendLibraryLog("delete fileId=$fileId") appendLibraryLog("delete fileId=$fileId")
openLibraryDatabase().useLibrary { db -> 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) { 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()
@ -528,6 +601,26 @@ private fun String.requiresExternalFileAccess(): Boolean {
private fun String.isSupportedBookFile(): Boolean = private fun String.isSupportedBookFile(): Boolean =
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true) 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 = private fun displayNameFor(uri: Uri): String =
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null if (cursor.moveToFirst()) cursor.getString(0) else null
@ -582,6 +675,58 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
importedAt = importedAt, 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 = private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/toread.log") 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"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="clipboard_images" path="clipboard-images/"/> <cache-path name="clipboard_images" path="clipboard-images/"/>
<cache-path name="shared_books" path="shared-books/"/>
</paths> </paths>

View File

@ -103,6 +103,41 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
state = loadStartupState() 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) { fun startScan(path: String) {
if (scanJob?.isActive == true) return if (scanJob?.isActive == true) return
activeScan = LibraryScanProgress(0, 0, 0, 0) activeScan = LibraryScanProgress(0, 0, 0, 0)
@ -126,7 +161,17 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
scanJob = null scanJob = null
activeScan = null activeScan = null
state = when (val current = state) { 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.Reader -> current.copy(message = message)
is AppState.BookInfo -> current.copy(message = message) is AppState.BookInfo -> current.copy(message = message)
is AppState.Error -> current is AppState.Error -> current
@ -166,31 +211,24 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
message = current.message, message = current.message,
) )
}, },
onBack = { onDeleted = { message ->
state = AppState.Library(emptyList(), current.scanPath, current.message) state = AppState.Library(emptyList(), current.scanPath, message)
}, },
onBack = ::navigateBack,
) )
is AppState.BookInfo -> BookInfoScreen( is AppState.BookInfo -> BookInfoScreen(
fileId = current.fileId, fileId = current.fileId,
book = current.book, book = current.book,
onImageOpen = { imageViewer = it }, onImageOpen = { imageViewer = it },
onBack = { onBack = ::navigateBack,
state = AppState.Reader(
fileId = current.fileId,
book = current.book,
libraryItems = current.libraryItems,
scanPath = current.scanPath,
message = current.message,
) )
}, is AppState.Error -> ErrorScreen(current.message, onBack = ::navigateBack)
)
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
} }
imageViewer?.let { image -> imageViewer?.let { image ->
ImageViewer( ImageViewer(
image = image, image = image,
onBack = { imageViewer = null }, onBack = ::navigateBack,
) )
} }
} }
@ -201,3 +239,6 @@ internal data class ViewedBookImage(
val mimeType: String, val mimeType: String,
val title: 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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -58,9 +57,7 @@ internal fun BookInfoScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to reader")
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = themedTopAppBarColors(),
containerColor = MaterialTheme.colorScheme.surface,
),
) )
}, },
) { ) {

View File

@ -24,7 +24,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -105,7 +104,7 @@ internal fun ImageViewer(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
Surface(color = MaterialTheme.colorScheme.surface) { ThemedTopBarSurface {
Row( Row(
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically, 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, val totalFiles: Int? = null,
) )
data class LibraryRescanReport(
val scannedFiles: Int,
val updatedFiles: Int,
val failedFiles: Int,
)
data class PlatformOpenBookRequest( data class PlatformOpenBookRequest(
val id: String, val id: String,
val displayName: 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 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 deleteLibraryItem(fileId: String): Boolean
expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? 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 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 loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String? 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons 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.Add
import androidx.compose.material.icons.filled.Close 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.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@ -36,12 +39,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -53,6 +53,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key 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.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Format import net.sergeych.toread.fb2.Fb2Format
@ -91,6 +93,9 @@ internal fun LibraryScreen(
var searchText by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) } var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) } 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 coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank() val searchActive = searchText.isNotBlank()
val visibleItems = if (searchActive) searchResults else items val visibleItems = if (searchActive) searchResults else items
@ -98,18 +103,28 @@ internal fun LibraryScreen(
suspend fun loadPage(reset: Boolean = false) { suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return if (loadingPage) return
loadingPage = true loadingPage = true
val previousItems = items
if (reset) { if (reset) {
items = emptyList()
nextOffset = 0 nextOffset = 0
endReached = false endReached = false
coverCache.clear()
} }
val offset = if (reset) 0 else nextOffset val offset = if (reset) 0 else nextOffset
try { try {
val page = loadLibraryItemsPage(LibraryPageSize, offset) val limit = if (reset) maxOf(LibraryPageSize, previousItems.size) else LibraryPageSize
items = if (reset) page else items + page 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 nextOffset = offset + page.size
endReached = page.size < LibraryPageSize endReached = page.size < limit
} catch (t: Throwable) { } catch (t: Throwable) {
message = t.message ?: "Could not load library." message = t.message ?: "Could not load library."
endReached = true 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() { fun clearSearch() {
searchText = "" searchText = ""
searchResults = emptyList() searchResults = emptyList()
@ -205,18 +238,19 @@ internal fun LibraryScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = themedTopAppBarColors(),
containerColor = MaterialTheme.colorScheme.surface,
),
actions = { actions = {
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
}
Box { Box {
IconButton(onClick = { settingsMenuOpen = true }) { IconButton(onClick = { settingsMenuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Library options") Icon(Icons.Filled.MoreVert, contentDescription = "Library options")
} }
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) { DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) {
// DropdownMenuItem(
// text = { Text("Rescan all library") },
// enabled = !busy && activeScan == null,
// onClick = ::rescanAllLibrary,
// )
// HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {
Checkbox(checked = autoScanDownloads, onCheckedChange = null) Checkbox(checked = autoScanDownloads, onCheckedChange = null)
@ -271,24 +305,7 @@ internal fun LibraryScreen(
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp), contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING fun itemActions(item: LibraryItem) = LibraryItemActions(
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,
onOpen = { onOpen = {
scope.launch { scope.launch {
busy = true busy = true
@ -296,12 +313,19 @@ internal fun LibraryScreen(
val next = runCatching { val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.") val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title) 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) markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
saveActiveReadingFileId(item.fileId) saveActiveReadingFileId(item.fileId)
AppState.Reader( AppState.Reader(
fileId = item.fileId, fileId = item.fileId,
book = book, book = book,
libraryItems = visibleItems, libraryItems = readerLibraryItems,
scanPath = state.scanPath, scanPath = state.scanPath,
message = message, 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 = { onNotInterested = {
scope.launch { scope.launch {
busy = true busy = true
try { try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) { if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
message = "Marked ${item.title} as not interesting." message = "Marked ${item.title} as not interested."
refresh() refresh()
} else { } else {
message = "Could not update ${item.title}." message = "Could not update ${item.title}."
@ -363,9 +402,9 @@ internal fun LibraryScreen(
scope.launch { scope.launch {
busy = true busy = true
try { try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false) val result = deleteLibraryBook(item.fileId, item.title)
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}." message = result.message
if (deleted) { if (result.deleted) {
items = items.filterNot { it.fileId == item.fileId } items = items.filterNot { it.fileId == item.fileId }
searchResults = searchResults.filterNot { it.fileId == item.fileId } searchResults = searchResults.filterNot { it.fileId == item.fileId }
coverCache.remove(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) { if (!searchActive && !endReached) {
item(key = "load-more") { item(key = "load-more") {
@ -412,14 +497,13 @@ private fun LibrarySearchField(
onClear: () -> Unit, onClear: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val shape = RoundedCornerShape(18.dp) val shape = RoundedCornerShape(16.dp)
OutlinedTextField( Row(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
modifier = modifier modifier = modifier
.height(56.dp) .height(42.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) { if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
onClear() onClear()
@ -428,38 +512,43 @@ private fun LibrarySearchField(
false false
} }
}, },
placeholder = { verticalAlignment = Alignment.CenterVertically,
Text( ) {
"Search library",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
maxLines = 1,
)
},
leadingIcon = {
Icon( Icon(
Icons.Filled.Search, Icons.Filled.Search,
contentDescription = "Search library", contentDescription = "Search library",
modifier = Modifier.size(20.dp), modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline, tint = MaterialTheme.colorScheme.outline,
) )
}, Box(
trailingIcon = { modifier = Modifier
if (value.isNotBlank()) { .weight(1f)
IconButton(onClick = onClear, modifier = Modifier.size(34.dp)) { .padding(start = 8.dp, end = 6.dp),
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(18.dp)) contentAlignment = Alignment.CenterStart,
} ) {
} BasicTextField(
}, value = value,
shape = shape, onValueChange = onValueChange,
colors = OutlinedTextFieldDefaults.colors( singleLine = true,
focusedBorderColor = MaterialTheme.colorScheme.primary, textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
focusedContainerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth(),
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
cursorColor = MaterialTheme.colorScheme.primary,
),
) )
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 @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 @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, "$text ($count)",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp),
) )
} }
}
@Composable @Composable
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) { private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
@ -527,11 +657,7 @@ private fun LibraryRow(
item: LibraryItem, item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>, coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean, enabled: Boolean,
onOpen: () -> Unit, actions: LibraryItemActions,
onMarkAsRead: () -> Unit,
onMarkAsUnread: () -> Unit,
onNotInterested: () -> Unit,
onDelete: () -> Unit,
) { ) {
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
@ -539,7 +665,7 @@ private fun LibraryRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled, onClick = onOpen) .clickable(enabled = enabled, onClick = actions.onOpen)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -551,12 +677,14 @@ private fun LibraryRow(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
Text( Text(
item.authors.joinToString().ifBlank { "Unknown author" }, item.authors.joinToString().ifBlank { "Unknown author" },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
Text( Text(
item.libraryMetadataLine(), item.libraryMetadataLine(),
@ -574,7 +702,7 @@ private fun LibraryRow(
text = { Text("Open") }, text = { Text("Open") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onOpen() actions.onOpen()
}, },
) )
HorizontalDivider() HorizontalDivider()
@ -583,7 +711,7 @@ private fun LibraryRow(
text = { Text("Mark as read") }, text = { Text("Mark as read") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onMarkAsRead() actions.onMarkAsRead()
}, },
) )
} }
@ -592,15 +720,24 @@ private fun LibraryRow(
text = { Text("Mark as unread") }, text = { Text("Mark as unread") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onMarkAsUnread() actions.onMarkAsUnread()
},
)
}
if (item.readingStatus != BookReadingStatus.NEW) {
DropdownMenuItem(
text = { Text("Remove marks") },
onClick = {
menuOpen = false
actions.onRemoveMarks()
}, },
) )
} }
DropdownMenuItem( DropdownMenuItem(
text = { Text("Not interesting") }, text = { Text("Not interested") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onNotInterested() actions.onNotInterested()
}, },
) )
HorizontalDivider() HorizontalDivider()
@ -608,7 +745,7 @@ private fun LibraryRow(
text = { Text("Delete") }, text = { Text("Delete") },
onClick = { onClick = {
menuOpen = false 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 @Composable
private fun LibraryCover( private fun LibraryCover(
item: LibraryItem, item: LibraryItem,
@ -685,6 +831,9 @@ private fun Long.formatBytes(): String =
else -> "$this B" 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 { private fun LibraryScanProgress.toCatalogScanMessage(): String {
val total = totalFiles ?: return "Scanned $scannedFiles books" val total = totalFiles ?: return "Scanned $scannedFiles books"
val percent = if (total <= 0) { 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -30,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap 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.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle 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.fb2.Fb2TextStyle
import net.sergeych.toread.text.HyphenationRegistry import net.sergeych.toread.text.HyphenationRegistry
import net.sergeych.toread.text.SoftHyphen import net.sergeych.toread.text.SoftHyphen
import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -79,16 +88,29 @@ internal fun ContinuousBookReader(
onImageOpen: (ViewedBookImage) -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {},
) { ) {
val hyphenation = remember { HyphenationRegistry() } val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope()
val contentPadding = if (isAndroidPlatform()) { 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 { } else {
PaddingValues(horizontal = 8.dp, vertical = 6.dp) PaddingValues(horizontal = 4.dp, vertical = 6.dp)
} }
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier 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, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(10.dp), 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( private fun LazyListScope.sectionItems(
book: Fb2Book, book: Fb2Book,
section: Fb2Section, section: Fb2Section,
@ -411,8 +461,9 @@ private fun ReaderText(
@Composable @Composable
private fun readerParagraphTextStyle(language: String?): TextStyle = private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy( MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp, fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal,
lineHeight = 27.sp, fontSize = if( isAndroidPlatform()) 21.sp else 18.sp,
lineHeight = 28.sp,
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified, hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified, lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) }, 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp 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.material.icons.filled.Palette
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -48,13 +53,36 @@ internal fun BookView(
onImageOpen: (ViewedBookImage) -> Unit, onImageOpen: (ViewedBookImage) -> Unit,
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onDeleted: (String) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val stats = remember(book) { BookStats.from(book) } val stats = remember(book) { BookStats.from(book) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var restored by remember(fileId) { mutableStateOf(false) } var restored by remember(fileId) { mutableStateOf(false) }
var markedRead 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) { LaunchedEffect(fileId) {
markLibraryReadingStatus(fileId, BookReadingStatus.READING) markLibraryReadingStatus(fileId, BookReadingStatus.READING)
@ -92,6 +120,7 @@ internal fun BookView(
Scaffold( Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
CompactReaderTopBar( CompactReaderTopBar(
title = book.title, title = book.title,
@ -105,6 +134,40 @@ internal fun BookView(
onBookInfo() 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 = { onBack = {
scope.launch { scope.launch {
saveLibraryReadingPosition( saveLibraryReadingPosition(
@ -140,9 +203,19 @@ private fun CompactReaderTopBar(
title: String, title: String,
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onMarkAsRead: () -> Unit,
onNotInterested: () -> Unit,
onClearMarks: () -> Unit,
showShareAction: Boolean,
onShare: () -> Unit,
showViewFileAction: Boolean,
onViewFile: () -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
Surface(color = MaterialTheme.colorScheme.surface) { var menuOpen by remember { mutableStateOf(false) }
ThemedTopBarSurface {
Row( Row(
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -159,12 +232,74 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) { IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = "Theme") Icon(Icons.Filled.Palette, contentDescription = "Theme")
} }
IconButton(onClick = onBookInfo) {
Icon(Icons.Filled.Info, contentDescription = "Properties")
}
IconButton(onClick = { }) { IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud") 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.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -62,9 +61,7 @@ internal fun ScanScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = themedTopAppBarColors(),
containerColor = MaterialTheme.colorScheme.surface,
),
) )
}, },
) { ) {

View File

@ -3,16 +3,21 @@ package net.sergeych.toread
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -75,6 +80,30 @@ internal fun quietCardColors() = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), 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 @Composable
internal fun readerBackground(): Brush = SolidColor(MaterialTheme.colorScheme.background) 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.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import net.sergeych.toread.fb2.Fb2Binary 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.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord 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.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.awt.Desktop
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable 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) { actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
appendLibraryLog("delete fileId=$fileId") appendLibraryLog("delete fileId=$fileId")
openLibraryDatabase().useLibrary { db -> 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) { 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()
@ -414,6 +475,58 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
importedAt = importedAt, 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 = private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log") 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 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 deleteLibraryItem(fileId: String): Boolean = false
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = null 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 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 loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null 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: 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`. - 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. - 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: ZIP support:

View File

@ -10,7 +10,7 @@ object Fb2Format {
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) { val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
Fb2Zip.extractFb2Xml(input) Fb2Zip.extractFb2Xml(input)
} else { } else {
input.decodeToString() Fb2XmlEncoding.decodeXml(input)
} }
return parseXml(xml) 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) } val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
?: entries.firstOrNull { !it.name.endsWith("/") } ?: entries.firstOrNull { !it.name.endsWith("/") }
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry") ?: 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 { 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))) 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 @Test
fun preservesReadableBlocksAndInlineStyles() { fun preservesReadableBlocksAndInlineStyles() {
val book = Fb2Format.parseXml(richXml) val book = Fb2Format.parseXml(richXml)
@ -108,4 +133,37 @@ class Fb2FormatTest {
</body> </body>
</FictionBook> </FictionBook>
""".trimIndent() """.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 LEFT JOIN books b ON b.id = f.book_id
WHERE f.duplicate_of_file_id IS NULL WHERE f.duplicate_of_file_id IS NULL
ORDER BY 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, 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)), LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
f.id f.id

View File

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