library improvements: search

This commit is contained in:
Sergey Chernov 2026-05-17 14:00:39 +03:00
parent ade4fb1896
commit 1239f15836
10 changed files with 618 additions and 28 deletions

View File

@ -125,6 +125,15 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryIt
}
}
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
val prefixes = query.toSearchPrefixes()
if (prefixes.isEmpty()) return@withContext emptyList()
appendLibraryLog("search library items prefixes=${prefixes.joinToString()}")
openLibraryDatabase().useLibrary { db ->
db.files.searchLibraryFiles(prefixes, limit).map { it.toLibraryItem() }
}
}
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.toLibraryItem()
@ -311,6 +320,49 @@ actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO)
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
}
}
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ScanDownloadsAutomaticallyFlag, enabled.toString())
}
}
actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
?: db.files.list(Int.MAX_VALUE, 0).any { it.storageUri?.let(::isUnderDownloadsPath) == true }
}
}
actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(DownloadsWasScannedFlag, scanned.toString())
}
}
actual fun downloadsScanPath(): String? =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
actual fun isDownloadsScanPath(path: String): Boolean {
val downloads = downloadsScanPath() ?: return false
return !path.isContentUri() && runCatching { File(path).canonicalFile == File(downloads).canonicalFile }.getOrDefault(false)
}
private fun isUnderDownloadsPath(path: String): Boolean {
if (path.isContentUri()) return false
val downloads = downloadsScanPath() ?: return false
return runCatching {
val filePath = File(path).canonicalPath
val downloadsPath = File(downloads).canonicalPath.trimEnd(File.separatorChar)
filePath == downloadsPath || filePath.startsWith(downloadsPath + File.separator)
}.getOrDefault(false)
}
actual fun isPlatformDarkTheme(): Boolean {
if (!::appContext.isInitialized) return false
val uiMode = appContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
@ -527,6 +579,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
lastSeenAt = lastSeenAt,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
importedAt = importedAt,
)
private fun libraryLogFile(): File =
@ -541,5 +594,12 @@ private fun String.toReadingPosition(): ReadingPosition? {
return if (index != null && offset != null) ReadingPosition(index, offset) else null
}
private fun String.toSearchPrefixes(): List<String> =
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""")
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -116,6 +116,9 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
}
val message = report.fold(
onSuccess = {
if (isDownloadsScanPath(path)) {
saveDownloadsWasScanned(true)
}
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
},
onFailure = { it.message ?: "Scan failed." },
@ -138,6 +141,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
activeScan = activeScan,
onStateChange = { state = it },
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
onStartScan = ::startScan,
)
is AppState.Scan -> ScanScreen(
state = current,

View File

@ -17,6 +17,7 @@ data class LibraryItem(
val lastSeenAt: Long?,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
val importedAt: Long? = null,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
)
@ -89,6 +90,8 @@ expect suspend fun loadLibraryItems(): List<LibraryItem>
expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem>
expect suspend fun searchLibraryItems(query: String, limit: Int = 100): List<LibraryItem>
expect suspend fun loadLibraryItem(fileId: String): LibraryItem?
expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover?
@ -115,6 +118,18 @@ expect suspend fun loadThemeMode(): ThemeMode
expect suspend fun saveThemeMode(mode: ThemeMode)
expect suspend fun loadScanDownloadsAutomatically(): Boolean
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
expect suspend fun loadDownloadsWasScanned(): Boolean
expect suspend fun saveDownloadsWasScanned(scanned: Boolean)
expect fun downloadsScanPath(): String?
expect fun isDownloadsScanPath(path: String): Boolean
expect fun isPlatformDarkTheme(): Boolean
expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit

View File

@ -14,16 +14,19 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -33,8 +36,11 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -47,6 +53,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -64,6 +75,7 @@ internal fun LibraryScreen(
activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit,
onStartScan: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
@ -73,7 +85,15 @@ internal fun LibraryScreen(
var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) }
var wasScanning by remember { mutableStateOf(false) }
var settingsMenuOpen by remember { mutableStateOf(false) }
var autoScanDownloads by remember { mutableStateOf(true) }
var autoScanSettingLoaded by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank()
val visibleItems = if (searchActive) searchResults else items
suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return
@ -100,13 +120,67 @@ internal fun LibraryScreen(
fun refresh(nextMessage: String? = message) {
message = nextMessage
scope.launch { loadPage(reset = true) }
scope.launch {
if (searchActive) {
searching = true
searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false
} else {
loadPage(reset = true)
}
}
}
fun clearSearch() {
searchText = ""
searchResults = emptyList()
searching = false
}
LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) loadPage(reset = true)
}
LaunchedEffect(searchText) {
val query = searchText
if (query.isBlank()) {
searchResults = emptyList()
searching = false
return@LaunchedEffect
}
searching = true
delay(1_000)
runCatching { searchLibraryItems(query, SearchResultLimit) }
.onSuccess {
if (searchText == query) {
searchResults = it
message = null
}
}
.onFailure {
if (searchText == query) {
searchResults = emptyList()
message = it.message ?: "Search failed."
}
}
if (searchText == query) searching = false
}
LaunchedEffect(Unit) {
autoScanDownloads = loadScanDownloadsAutomatically()
autoScanSettingLoaded = true
}
LaunchedEffect(autoScanSettingLoaded, autoScanDownloads) {
if (!autoScanSettingLoaded || !autoScanDownloads || activeScan != null) return@LaunchedEffect
if (loadDownloadsWasScanned()) {
downloadsScanPath()?.let { path ->
message = "Scanning Downloads..."
onStartScan(path)
}
}
}
LaunchedEffect(activeScan != null) {
if (activeScan != null) {
wasScanning = true
@ -122,8 +196,15 @@ internal fun LibraryScreen(
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Library") },
TopAppBar(
title = {
LibrarySearchField(
value = searchText,
onValueChange = { searchText = it },
onClear = ::clearSearch,
modifier = Modifier.fillMaxWidth(),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
@ -131,6 +212,25 @@ internal fun LibraryScreen(
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
}
Box {
IconButton(onClick = { settingsMenuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Library options")
}
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) {
DropdownMenuItem(
leadingIcon = {
Checkbox(checked = autoScanDownloads, onCheckedChange = null)
},
text = { Text("Scan Downloads automatically") },
onClick = {
val next = !autoScanDownloads
autoScanDownloads = next
settingsMenuOpen = false
scope.launch { saveScanDownloadsAutomatically(next) }
},
)
}
}
},
)
},
@ -144,32 +244,44 @@ internal fun LibraryScreen(
modifier = Modifier
.fillMaxSize()
.padding(it)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && searchText.isNotBlank()) {
clearSearch()
true
} else {
false
}
}
.background(readerBackground()),
) {
val wide = maxWidth >= 800.dp
if (items.isEmpty() && loadingPage) {
if (visibleItems.isEmpty() && (loadingPage || searching)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (items.isEmpty()) {
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else if (visibleItems.isEmpty()) {
if (searchActive) {
EmptySearchPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else {
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING
if (hasReadingNow) {
item(key = "section-reading") {
LibrarySectionHeader("reading now")
}
}
itemsIndexed(items, key = { _, item -> item.fileId }) { index, item ->
itemsIndexed(visibleItems, key = { _, item -> item.fileId }) { index, item ->
if (
hasReadingNow &&
item.readingStatus != BookReadingStatus.READING &&
(index == 0 || items[index - 1].readingStatus == BookReadingStatus.READING)
(index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING)
) {
LibrarySectionHeader("my library")
}
@ -189,12 +301,12 @@ internal fun LibraryScreen(
AppState.Reader(
fileId = item.fileId,
book = book,
libraryItems = items,
libraryItems = visibleItems,
scanPath = state.scanPath,
message = message,
)
}.getOrElse {
AppState.Library(items, state.scanPath, it.message ?: "Could not open book.")
AppState.Library(visibleItems, state.scanPath, it.message ?: "Could not open book.")
}
onStateChange(next)
} finally {
@ -208,7 +320,7 @@ internal fun LibraryScreen(
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
message = "Marked ${item.title} as read."
loadPage(reset = true)
refresh()
} else {
message = "Could not update ${item.title}."
}
@ -223,7 +335,7 @@ internal fun LibraryScreen(
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Marked ${item.title} as unread."
loadPage(reset = true)
refresh()
} else {
message = "Could not update ${item.title}."
}
@ -238,7 +350,7 @@ internal fun LibraryScreen(
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
message = "Marked ${item.title} as not interesting."
loadPage(reset = true)
refresh()
} else {
message = "Could not update ${item.title}."
}
@ -255,6 +367,7 @@ internal fun LibraryScreen(
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
if (deleted) {
items = items.filterNot { it.fileId == item.fileId }
searchResults = searchResults.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId)
nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
}
@ -265,7 +378,7 @@ internal fun LibraryScreen(
},
)
}
if (!endReached) {
if (!searchActive && !endReached) {
item(key = "load-more") {
LaunchedEffect(nextOffset, items.size) {
if (!loadingPage) loadPage()
@ -292,6 +405,63 @@ internal fun LibraryScreen(
}
}
@Composable
private fun LibrarySearchField(
value: String,
onValueChange: (String) -> Unit,
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(18.dp)
OutlinedTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
modifier = modifier
.height(56.dp)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
onClear()
true
} else {
false
}
},
placeholder = {
Text(
"Search library",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
maxLines = 1,
)
},
leadingIcon = {
Icon(
Icons.Filled.Search,
contentDescription = "Search library",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.outline,
)
},
trailingIcon = {
if (value.isNotBlank()) {
IconButton(onClick = onClear, modifier = Modifier.size(34.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(18.dp))
}
}
},
shape = shape,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
}
@Composable
private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
Box(modifier, contentAlignment = Alignment.Center) {
@ -304,6 +474,13 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
}
}
@Composable
private fun EmptySearchPane(modifier: Modifier = Modifier) {
Box(modifier, contentAlignment = Alignment.Center) {
Text("No matches", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline)
}
}
@Composable
private fun LibrarySectionHeader(text: String) {
Text(
@ -519,3 +696,4 @@ private fun LibraryScanProgress.toCatalogScanMessage(): String {
}
private const val LibraryPageSize: Int = 50
private const val SearchResultLimit: Int = 100

View File

@ -90,6 +90,15 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryIt
}
}
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
val prefixes = query.toSearchPrefixes()
if (prefixes.isEmpty()) return@withContext emptyList()
appendLibraryLog("search library items prefixes=${prefixes.joinToString()}")
openLibraryDatabase().useLibrary { db ->
db.files.searchLibraryFiles(prefixes, limit).map { it.toLibraryItem() }
}
}
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.toLibraryItem()
@ -261,6 +270,48 @@ actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO)
}
}
actual suspend fun loadScanDownloadsAutomatically(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ScanDownloadsAutomaticallyFlag)?.toBooleanStrictOrNull() ?: true
}
}
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ScanDownloadsAutomaticallyFlag, enabled.toString())
}
}
actual suspend fun loadDownloadsWasScanned(): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(DownloadsWasScannedFlag)?.toBooleanStrictOrNull()
?: db.files.list(Int.MAX_VALUE, 0).any { it.storageUri?.let(::isUnderDownloadsPath) == true }
}
}
actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(DownloadsWasScannedFlag, scanned.toString())
}
}
actual fun downloadsScanPath(): String? =
File(System.getProperty("user.home"), "Downloads").takeIf { it.isDirectory }?.absolutePath
actual fun isDownloadsScanPath(path: String): Boolean {
val downloads = downloadsScanPath() ?: return false
return runCatching { File(path).canonicalFile == File(downloads).canonicalFile }.getOrDefault(false)
}
private fun isUnderDownloadsPath(path: String): Boolean {
val downloads = downloadsScanPath() ?: return false
return runCatching {
val filePath = File(path).canonicalPath
val downloadsPath = File(downloads).canonicalPath.trimEnd(File.separatorChar)
filePath == downloadsPath || filePath.startsWith(downloadsPath + File.separator)
}.getOrDefault(false)
}
actual fun isPlatformDarkTheme(): Boolean {
val osName = System.getProperty("os.name").lowercase()
if (!osName.contains("linux")) return false
@ -360,6 +411,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
lastSeenAt = lastSeenAt,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
importedAt = importedAt,
)
private fun libraryLogFile(): File =
@ -374,6 +426,11 @@ private fun String.toReadingPosition(): ReadingPosition? {
return if (index != null && offset != null) ReadingPosition(index, offset) else null
}
private fun String.toSearchPrefixes(): List<String> =
SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()
private val SearchPrefixRegex = Regex("""[\p{L}\p{N}]+""")
private fun runCommand(vararg command: String): String? =
runCatching {
val process = ProcessBuilder(*command).redirectErrorStream(true).start()
@ -387,3 +444,5 @@ private fun runCommand(vararg command: String): String? =
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
private const val ScanDownloadsAutomaticallyFlag = "scan_downloads_automatically"
private const val DownloadsWasScannedFlag = "downloads_was_scanned"

View File

@ -24,6 +24,8 @@ actual suspend fun loadLibraryItems(): List<LibraryItem> = emptyList()
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = emptyList()
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = emptyList()
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = null
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = null
@ -54,6 +56,18 @@ actual suspend fun loadThemeMode(): ThemeMode = ThemeMode.SYSTEM
actual suspend fun saveThemeMode(mode: ThemeMode) = Unit
actual suspend fun loadScanDownloadsAutomatically(): Boolean = true
actual suspend fun saveScanDownloadsAutomatically(enabled: Boolean) = Unit
actual suspend fun loadDownloadsWasScanned(): Boolean = false
actual suspend fun saveDownloadsWasScanned(scanned: Boolean) = Unit
actual fun downloadsScanPath(): String? = null
actual fun isDownloadsScanPath(path: String): Boolean = false
actual fun isPlatformDarkTheme(): Boolean =
window.matchMedia("(prefers-color-scheme: dark)").matches

View File

@ -30,6 +30,7 @@ data class BookRecord(
val language: String? = null,
val date: String? = null,
val description: String? = null,
val keywords: String? = null,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
val createdAt: Long,
@ -46,6 +47,7 @@ data class BookRecord(
language == other.language &&
date == other.date &&
description == other.description &&
keywords == other.keywords &&
coverImage.contentEquals(other.coverImage) &&
coverImageMimeType == other.coverImageMimeType &&
createdAt == other.createdAt &&
@ -60,6 +62,7 @@ data class BookRecord(
result = 31 * result + (language?.hashCode() ?: 0)
result = 31 * result + (date?.hashCode() ?: 0)
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + (keywords?.hashCode() ?: 0)
result = 31 * result + (coverImage?.contentHashCode() ?: 0)
result = 31 * result + (coverImageMimeType?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
@ -109,6 +112,7 @@ data class BookFileRecord(
val lastReadAt: Long? = null,
val createdAt: Long,
val updatedAt: Long,
val importedAt: Long = createdAt,
)
data class LibraryFileRecord(
@ -125,6 +129,7 @@ data class LibraryFileRecord(
val lastSeenAt: Long? = null,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
val importedAt: Long,
)
data class ContentAnchor(
@ -200,6 +205,7 @@ interface BookFileRepository {
fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord?
fun markDuplicateFiles(): Int
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
fun searchLibraryFiles(prefixes: List<String>, limit: Int = 100): List<LibraryFileRecord>
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord>
fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean

View File

@ -151,6 +151,7 @@ private fun migrate(connection: Connection) {
language VARCHAR,
published_date VARCHAR,
description CLOB,
keywords CLOB,
cover_image BLOB,
cover_image_mime_type VARCHAR,
created_at BIGINT NOT NULL,
@ -160,6 +161,7 @@ private fun migrate(connection: Connection) {
)
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS authors CLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS published_date VARCHAR")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS keywords CLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image BLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR")
statement.execute(
@ -212,6 +214,7 @@ private fun migrate(connection: Connection) {
last_seen_at BIGINT,
reading_status VARCHAR NOT NULL DEFAULT 'NEW',
last_read_at BIGINT,
imported_at BIGINT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books(id),
@ -223,6 +226,12 @@ private fun migrate(connection: Connection) {
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS imported_at BIGINT")
connection.prepareStatement("UPDATE book_files SET imported_at = ? WHERE imported_at IS NULL").use { update ->
update.setLong(1, System.currentTimeMillis())
update.executeUpdate()
}
statement.execute("ALTER TABLE book_files ALTER COLUMN imported_at SET NOT NULL")
connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
connection.createIndexIfMissing("idx_book_files_duplicate", "CREATE INDEX IF NOT EXISTS idx_book_files_duplicate ON book_files(duplicate_of_file_id)")
@ -358,10 +367,10 @@ private class JdbcBookRepository(private val connection: Connection) : BookRepos
connection.prepareStatement(
"""
MERGE INTO books(
id, title, subtitle, authors, language, published_date, description,
id, title, subtitle, authors, language, published_date, description, keywords,
cover_image, cover_image_mime_type, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, book.id)
@ -371,10 +380,11 @@ private class JdbcBookRepository(private val connection: Connection) : BookRepos
statement.setStringOrNull(5, book.language)
statement.setStringOrNull(6, book.date)
statement.setStringOrNull(7, book.description)
statement.setBytesOrNull(8, book.coverImage)
statement.setStringOrNull(9, book.coverImageMimeType)
statement.setLong(10, book.createdAt)
statement.setLong(11, book.updatedAt)
statement.setStringOrNull(8, book.keywords)
statement.setBytesOrNull(9, book.coverImage)
statement.setStringOrNull(10, book.coverImageMimeType)
statement.setLong(11, book.createdAt)
statement.setLong(12, book.updatedAt)
statement.executeUpdate()
}
}
@ -477,9 +487,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
MERGE INTO book_files(
id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format,
mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id,
last_modified_millis, last_seen_at, reading_status, last_read_at, created_at, updated_at
last_modified_millis, last_seen_at, reading_status, last_read_at, imported_at, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, file.id)
@ -499,8 +509,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
statement.setLongOrNull(15, file.lastSeenAt)
statement.setString(16, file.readingStatus.name)
statement.setLongOrNull(17, file.lastReadAt)
statement.setLong(18, file.createdAt)
statement.setLong(19, file.updatedAt)
statement.setLong(18, file.importedAt)
statement.setLong(19, file.createdAt)
statement.setLong(20, file.updatedAt)
statement.executeUpdate()
}
}
@ -525,7 +536,8 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status,
f.last_read_at AS last_read_at
f.last_read_at AS last_read_at,
f.imported_at AS imported_at
FROM book_files f
LEFT JOIN books b ON b.id = f.book_id
WHERE f.id = ?
@ -680,7 +692,8 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status,
f.last_read_at AS last_read_at
f.last_read_at AS last_read_at,
f.imported_at AS imported_at
FROM book_files f
LEFT JOIN books b ON b.id = f.book_id
WHERE f.duplicate_of_file_id IS NULL
@ -698,6 +711,63 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
}
}
override fun searchLibraryFiles(prefixes: List<String>, limit: Int): List<LibraryFileRecord> {
val normalizedPrefixes = prefixes.mapNotNull { it.trim().lowercase().takeIf(String::isNotBlank) }.distinct()
if (normalizedPrefixes.isEmpty()) return emptyList()
markDuplicateFiles()
val candidates = connection.prepareStatement(
"""
SELECT
f.id AS file_id,
f.book_id AS book_id,
b.title AS book_title,
b.authors AS book_authors,
b.language AS book_language,
b.published_date AS book_date,
b.description AS book_description,
b.keywords AS book_keywords,
f.format AS file_format,
f.size_bytes AS size_bytes,
f.original_filename AS original_filename,
f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status,
f.last_read_at AS last_read_at,
f.imported_at AS imported_at
FROM book_files f
LEFT JOIN books b ON b.id = f.book_id
WHERE f.duplicate_of_file_id IS NULL
""".trimIndent()
).use { statement ->
statement.executeQuery().use { resultSet ->
resultSet.mapRows {
SearchCandidate(
file = it.toLibraryFileRecord(),
title = it.getString("book_title")?.takeIf(String::isNotBlank) ?: it.getString("original_filename"),
authors = it.getString("book_authors").fromDbList().joinToString(" "),
description = it.getString("book_description"),
keywords = it.getString("book_keywords"),
)
}
}
}
return candidates
.mapNotNull { candidate ->
candidate.searchRank(normalizedPrefixes)?.let { rank -> rank to candidate.file }
}
.sortedWith(
compareByDescending<Pair<SearchRank, LibraryFileRecord>> { it.first.hitCount }
.thenBy { it.first.hitSpan }
.thenBy { it.first.firstHit }
.thenBy { it.second.title?.lowercase().orEmpty() }
.thenBy { it.second.fileId }
)
.take(limit)
.map { it.second }
}
override fun list(limit: Int, offset: Int): List<BookFileRecord> {
return connection.prepareStatement("SELECT * FROM book_files ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit)
@ -905,12 +975,56 @@ private fun ResultSet.toBookRecord() = BookRecord(
language = getString("language"),
date = getString("published_date"),
description = getString("description"),
keywords = getString("keywords"),
coverImage = getBytes("cover_image"),
coverImageMimeType = getString("cover_image_mime_type"),
createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"),
)
private data class SearchCandidate(
val file: LibraryFileRecord,
val title: String?,
val authors: String?,
val description: String?,
val keywords: String?,
)
private data class SearchRank(
val hitCount: Int,
val hitSpan: Int,
val firstHit: Int,
)
private fun SearchCandidate.searchRank(prefixes: List<String>): SearchRank? {
val hitPositions = buildList {
addAll(title.matchPositions(prefixes, 0))
addAll(authors.matchPositions(prefixes, 100_000))
addAll(keywords.matchPositions(prefixes, 200_000))
addAll(description.matchPositions(prefixes, 300_000))
}.sorted()
if (hitPositions.isEmpty()) return null
val first = hitPositions.first()
val last = hitPositions.last()
return SearchRank(
hitCount = hitPositions.size,
hitSpan = last - first,
firstHit = first,
)
}
private fun String?.matchPositions(prefixes: List<String>, fieldOffset: Int): List<Int> {
if (isNullOrBlank()) return emptyList()
return SearchWordRegex.findAll(lowercase())
.mapIndexedNotNull { index, match ->
if (prefixes.any { match.value.startsWith(it) }) fieldOffset + index else null
}
.toList()
}
private val SearchWordRegex = Regex("""[\p{L}\p{N}]+""")
private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
fileId = getString("file_id"),
bookId = getString("book_id"),
@ -925,6 +1039,7 @@ private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"),
importedAt = getLong("imported_at"),
)
private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
@ -961,6 +1076,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"),
importedAt = getLong("imported_at"),
createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"),
)

View File

@ -104,6 +104,7 @@ class LibraryScanner(
storageUri = storageUri,
lastModifiedMillis = lastModifiedMillis,
lastSeenAt = now,
importedAt = now,
createdAt = now,
updatedAt = now,
)
@ -134,6 +135,7 @@ class LibraryScanner(
language = book.language,
date = book.date,
description = book.annotation,
keywords = book.keywords,
coverImage = cover?.bytes,
coverImageMimeType = cover?.mimeType,
createdAt = now,
@ -184,6 +186,7 @@ class LibraryScanner(
storageUri = storageUri,
lastModifiedMillis = lastModifiedMillis,
lastSeenAt = now,
importedAt = now,
createdAt = now,
updatedAt = now,
)

View File

@ -16,6 +16,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class H2LibraryDatabaseTest {
@Test
@ -259,6 +260,89 @@ class H2LibraryDatabaseTest {
db.close()
}
@Test
fun searchesLibraryFilesByPrefixesAndRanksByHitCountThenDistance() {
val db = H2LibraryDatabase.openMemory("searchesLibraryFilesByPrefixesAndRanksByHitCountThenDistance")
val now = 1_700_000_000_000L
db.transaction {
books.upsert(
BookRecord(
id = "book-close",
title = "red blue alpha",
authors = listOf("Jane Example"),
createdAt = now,
updatedAt = now,
)
)
books.upsert(
BookRecord(
id = "book-far",
title = "red",
description = "alpha",
createdAt = now,
updatedAt = now,
)
)
books.upsert(
BookRecord(
id = "book-keyword",
title = "Unrelated",
keywords = "nebula archive",
createdAt = now,
updatedAt = now,
)
)
books.upsert(
BookRecord(
id = "book-ruso",
title = "El Ruso",
createdAt = now,
updatedAt = now,
)
)
listOf(
"file-close" to "book-close",
"file-far" to "book-far",
"file-keyword" to "book-keyword",
"file-ruso" to "book-ruso",
).forEachIndexed { index, (fileId, bookId) ->
files.upsert(
BookFileRecord(
id = fileId,
bookId = bookId,
rawSha256 = "sha-$fileId",
storageKind = BookFileStorageKind.EXTERNAL_URI,
createdAt = now + index,
updatedAt = now + index,
)
)
}
}
assertEquals(
listOf("file-close", "file-far"),
db.files.searchLibraryFiles(listOf("red", "alp")).map { it.fileId },
)
assertEquals(
listOf("file-close"),
db.files.searchLibraryFiles(listOf("blu")).map { it.fileId },
)
assertEquals(
listOf("file-keyword"),
db.files.searchLibraryFiles(listOf("neb")).map { it.fileId },
)
assertEquals(
listOf("file-ruso"),
db.files.searchLibraryFiles(listOf("rus")).map { it.fileId },
)
assertEquals(
emptyList(),
db.files.searchLibraryFiles(listOf("ous")).map { it.fileId },
)
db.close()
}
@Test
fun opensDatabaseWithExistingUppercaseIndex() {
val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()
@ -290,4 +374,55 @@ class H2LibraryDatabaseTest {
H2LibraryDatabase.openFile(path).close()
}
@Test
fun migratesLegacyBookFilesWithImportTime() {
val path = Files.createTempDirectory("toread-h2-imported-at-").resolve("library").toString()
Class.forName("org.h2.Driver")
DriverManager.getConnection("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", "sa", "").use { connection ->
connection.createStatement().use { statement ->
statement.execute(
"""
CREATE TABLE book_files (
id VARCHAR PRIMARY KEY,
book_id VARCHAR,
body_id VARCHAR,
body_cluster_id VARCHAR,
duplicate_of_file_id VARCHAR,
raw_sha256 VARCHAR NOT NULL,
format VARCHAR,
mime_type VARCHAR,
size_bytes BIGINT,
original_filename VARCHAR,
storage_kind VARCHAR NOT NULL,
storage_uri VARCHAR,
content_object_id VARCHAR,
last_modified_millis BIGINT,
last_seen_at BIGINT,
reading_status VARCHAR NOT NULL DEFAULT 'NEW',
last_read_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
""".trimIndent()
)
statement.execute(
"""
INSERT INTO book_files(
id, raw_sha256, storage_kind, reading_status, created_at, updated_at
) VALUES('legacy-file', 'legacy-sha', 'EXTERNAL_URI', 'NEW', 1, 2)
""".trimIndent()
)
}
}
val beforeMigration = System.currentTimeMillis()
val db = H2LibraryDatabase.openFile(path)
val file = assertNotNull(db.files.get("legacy-file"))
assertTrue(file.importedAt >= beforeMigration)
assertTrue(file.importedAt <= System.currentTimeMillis())
db.close()
}
}