View/logic improvements
This commit is contained in:
parent
da88a6e221
commit
26343cb8a1
@ -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 ->
|
||||||
@ -633,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")
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -169,31 +214,21 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
onDeleted = { message ->
|
onDeleted = { message ->
|
||||||
state = AppState.Library(emptyList(), current.scanPath, message)
|
state = AppState.Library(emptyList(), current.scanPath, message)
|
||||||
},
|
},
|
||||||
onBack = {
|
onBack = ::navigateBack,
|
||||||
state = AppState.Library(emptyList(), current.scanPath, current.message)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
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 = { state = AppState.LoadingLibrary })
|
is AppState.Error -> ErrorScreen(current.message, onBack = ::navigateBack)
|
||||||
}
|
}
|
||||||
|
|
||||||
imageViewer?.let { image ->
|
imageViewer?.let { image ->
|
||||||
ImageViewer(
|
ImageViewer(
|
||||||
image = image,
|
image = image,
|
||||||
onBack = { imageViewer = null },
|
onBack = ::navigateBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -204,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
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -27,7 +27,6 @@ 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.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
|
||||||
@ -62,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
|
||||||
@ -103,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
|
||||||
@ -136,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()
|
||||||
@ -212,14 +240,17 @@ internal fun LibraryScreen(
|
|||||||
},
|
},
|
||||||
colors = themedTopAppBarColors(),
|
colors = themedTopAppBarColors(),
|
||||||
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)
|
||||||
@ -282,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,
|
||||||
)
|
)
|
||||||
@ -639,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(),
|
||||||
@ -791,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) {
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal expect fun PlatformBackHandler(
|
||||||
|
enabled: Boolean,
|
||||||
|
navigationDepth: Int,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
)
|
||||||
@ -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
|
||||||
@ -175,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 ->
|
||||||
@ -433,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")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user