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) { actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.toLibraryItem() 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 { actual fun isPlatformDarkTheme(): Boolean {
if (!::appContext.isInitialized) return false if (!::appContext.isInitialized) return false
val uiMode = appContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK val uiMode = appContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
@ -527,6 +579,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
lastSeenAt = lastSeenAt, lastSeenAt = lastSeenAt,
readingStatus = readingStatus, readingStatus = readingStatus,
lastReadAt = lastReadAt, lastReadAt = lastReadAt,
importedAt = importedAt,
) )
private fun libraryLogFile(): File = private fun libraryLogFile(): File =
@ -541,5 +594,12 @@ private fun String.toReadingPosition(): ReadingPosition? {
return if (index != null && offset != null) ReadingPosition(index, offset) else null 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 ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" 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( val message = report.fold(
onSuccess = { onSuccess = {
if (isDownloadsScanPath(path)) {
saveDownloadsWasScanned(true)
}
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}." "Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
}, },
onFailure = { it.message ?: "Scan failed." }, onFailure = { it.message ?: "Scan failed." },
@ -138,6 +141,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
activeScan = activeScan, activeScan = activeScan,
onStateChange = { state = it }, onStateChange = { state = it },
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) }, onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
onStartScan = ::startScan,
) )
is AppState.Scan -> ScanScreen( is AppState.Scan -> ScanScreen(
state = current, state = current,

View File

@ -17,6 +17,7 @@ data class LibraryItem(
val lastSeenAt: Long?, val lastSeenAt: Long?,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW, val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null, val lastReadAt: Long? = null,
val importedAt: Long? = null,
val coverImage: ByteArray? = null, val coverImage: ByteArray? = null,
val coverImageMimeType: String? = 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 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 loadLibraryItem(fileId: String): LibraryItem?
expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover? 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 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 isPlatformDarkTheme(): Boolean
expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
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.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -33,8 +36,11 @@ 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.TopAppBarDefaults 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
@ -47,6 +53,11 @@ 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.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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -64,6 +75,7 @@ internal fun LibraryScreen(
activeScan: LibraryScanProgress?, activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit, onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit, onNavigateToScan: () -> Unit,
onStartScan: (String) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
@ -73,7 +85,15 @@ internal fun LibraryScreen(
var loadingPage by remember(state.items) { mutableStateOf(false) } var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) } var endReached by remember(state.items) { mutableStateOf(false) }
var wasScanning by remember { 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 coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank()
val visibleItems = if (searchActive) searchResults else items
suspend fun loadPage(reset: Boolean = false) { suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return if (loadingPage) return
@ -100,13 +120,67 @@ internal fun LibraryScreen(
fun refresh(nextMessage: String? = message) { fun refresh(nextMessage: String? = message) {
message = nextMessage 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) { LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) loadPage(reset = true) 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) { LaunchedEffect(activeScan != null) {
if (activeScan != null) { if (activeScan != null) {
wasScanning = true wasScanning = true
@ -122,8 +196,15 @@ internal fun LibraryScreen(
Scaffold( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( TopAppBar(
title = { Text("Library") }, title = {
LibrarySearchField(
value = searchText,
onValueChange = { searchText = it },
onClear = ::clearSearch,
modifier = Modifier.fillMaxWidth(),
)
},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
@ -131,6 +212,25 @@ internal fun LibraryScreen(
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) { IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library") 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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it) .padding(it)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && searchText.isNotBlank()) {
clearSearch()
true
} else {
false
}
}
.background(readerBackground()), .background(readerBackground()),
) { ) {
val wide = maxWidth >= 800.dp val wide = maxWidth >= 800.dp
if (items.isEmpty() && loadingPage) { if (visibleItems.isEmpty() && (loadingPage || searching)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
} }
} else if (items.isEmpty()) { } 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)) EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
}
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
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 = items.firstOrNull()?.readingStatus == BookReadingStatus.READING val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING
if (hasReadingNow) { if (hasReadingNow) {
item(key = "section-reading") { item(key = "section-reading") {
LibrarySectionHeader("reading now") LibrarySectionHeader("reading now")
} }
} }
itemsIndexed(items, key = { _, item -> item.fileId }) { index, item -> itemsIndexed(visibleItems, key = { _, item -> item.fileId }) { index, item ->
if ( if (
hasReadingNow && hasReadingNow &&
item.readingStatus != BookReadingStatus.READING && item.readingStatus != BookReadingStatus.READING &&
(index == 0 || items[index - 1].readingStatus == BookReadingStatus.READING) (index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING)
) { ) {
LibrarySectionHeader("my library") LibrarySectionHeader("my library")
} }
@ -189,12 +301,12 @@ internal fun LibraryScreen(
AppState.Reader( AppState.Reader(
fileId = item.fileId, fileId = item.fileId,
book = book, book = book,
libraryItems = items, libraryItems = visibleItems,
scanPath = state.scanPath, scanPath = state.scanPath,
message = message, message = message,
) )
}.getOrElse { }.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) onStateChange(next)
} finally { } finally {
@ -208,7 +320,7 @@ internal fun LibraryScreen(
try { try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) { if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
message = "Marked ${item.title} as read." message = "Marked ${item.title} as read."
loadPage(reset = true) refresh()
} else { } else {
message = "Could not update ${item.title}." message = "Could not update ${item.title}."
} }
@ -223,7 +335,7 @@ internal fun LibraryScreen(
try { try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) { if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Marked ${item.title} as unread." message = "Marked ${item.title} as unread."
loadPage(reset = true) refresh()
} else { } else {
message = "Could not update ${item.title}." message = "Could not update ${item.title}."
} }
@ -238,7 +350,7 @@ internal fun LibraryScreen(
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 interesting."
loadPage(reset = true) refresh()
} else { } else {
message = "Could not update ${item.title}." 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}." message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
if (deleted) { if (deleted) {
items = items.filterNot { it.fileId == item.fileId } items = items.filterNot { it.fileId == item.fileId }
searchResults = searchResults.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId) coverCache.remove(item.fileId)
nextOffset = (nextOffset - 1).coerceAtLeast(items.size) nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
} }
@ -265,7 +378,7 @@ internal fun LibraryScreen(
}, },
) )
} }
if (!endReached) { if (!searchActive && !endReached) {
item(key = "load-more") { item(key = "load-more") {
LaunchedEffect(nextOffset, items.size) { LaunchedEffect(nextOffset, items.size) {
if (!loadingPage) loadPage() 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 @Composable
private fun EmptyLibraryPane(modifier: Modifier = Modifier) { private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
Box(modifier, contentAlignment = Alignment.Center) { 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 @Composable
private fun LibrarySectionHeader(text: String) { private fun LibrarySectionHeader(text: String) {
Text( Text(
@ -519,3 +696,4 @@ private fun LibraryScanProgress.toCatalogScanMessage(): String {
} }
private const val LibraryPageSize: Int = 50 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) { actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.toLibraryItem() 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 { actual fun isPlatformDarkTheme(): Boolean {
val osName = System.getProperty("os.name").lowercase() val osName = System.getProperty("os.name").lowercase()
if (!osName.contains("linux")) return false if (!osName.contains("linux")) return false
@ -360,6 +411,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
lastSeenAt = lastSeenAt, lastSeenAt = lastSeenAt,
readingStatus = readingStatus, readingStatus = readingStatus,
lastReadAt = lastReadAt, lastReadAt = lastReadAt,
importedAt = importedAt,
) )
private fun libraryLogFile(): File = private fun libraryLogFile(): File =
@ -374,6 +426,11 @@ private fun String.toReadingPosition(): ReadingPosition? {
return if (index != null && offset != null) ReadingPosition(index, offset) else null 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? = private fun runCommand(vararg command: String): String? =
runCatching { runCatching {
val process = ProcessBuilder(*command).redirectErrorStream(true).start() 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 ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode" 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 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 loadLibraryItem(fileId: String): LibraryItem? = null
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = 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 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 = actual fun isPlatformDarkTheme(): Boolean =
window.matchMedia("(prefers-color-scheme: dark)").matches window.matchMedia("(prefers-color-scheme: dark)").matches

View File

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

View File

@ -151,6 +151,7 @@ private fun migrate(connection: Connection) {
language VARCHAR, language VARCHAR,
published_date VARCHAR, published_date VARCHAR,
description CLOB, description CLOB,
keywords CLOB,
cover_image BLOB, cover_image BLOB,
cover_image_mime_type VARCHAR, cover_image_mime_type VARCHAR,
created_at BIGINT NOT NULL, 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 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 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 BLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR") statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR")
statement.execute( statement.execute(
@ -212,6 +214,7 @@ private fun migrate(connection: Connection) {
last_seen_at BIGINT, last_seen_at BIGINT,
reading_status VARCHAR NOT NULL DEFAULT 'NEW', reading_status VARCHAR NOT NULL DEFAULT 'NEW',
last_read_at BIGINT, last_read_at BIGINT,
imported_at BIGINT NOT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL, updated_at BIGINT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books(id), 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 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 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 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_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_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)") 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( connection.prepareStatement(
""" """
MERGE INTO books( 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 cover_image, cover_image_mime_type, created_at, updated_at
) )
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent() """.trimIndent()
).use { statement -> ).use { statement ->
statement.setString(1, book.id) statement.setString(1, book.id)
@ -371,10 +380,11 @@ private class JdbcBookRepository(private val connection: Connection) : BookRepos
statement.setStringOrNull(5, book.language) statement.setStringOrNull(5, book.language)
statement.setStringOrNull(6, book.date) statement.setStringOrNull(6, book.date)
statement.setStringOrNull(7, book.description) statement.setStringOrNull(7, book.description)
statement.setBytesOrNull(8, book.coverImage) statement.setStringOrNull(8, book.keywords)
statement.setStringOrNull(9, book.coverImageMimeType) statement.setBytesOrNull(9, book.coverImage)
statement.setLong(10, book.createdAt) statement.setStringOrNull(10, book.coverImageMimeType)
statement.setLong(11, book.updatedAt) statement.setLong(11, book.createdAt)
statement.setLong(12, book.updatedAt)
statement.executeUpdate() statement.executeUpdate()
} }
} }
@ -477,9 +487,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
MERGE INTO book_files( MERGE INTO book_files(
id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format, 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, 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() """.trimIndent()
).use { statement -> ).use { statement ->
statement.setString(1, file.id) statement.setString(1, file.id)
@ -499,8 +509,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
statement.setLongOrNull(15, file.lastSeenAt) statement.setLongOrNull(15, file.lastSeenAt)
statement.setString(16, file.readingStatus.name) statement.setString(16, file.readingStatus.name)
statement.setLongOrNull(17, file.lastReadAt) statement.setLongOrNull(17, file.lastReadAt)
statement.setLong(18, file.createdAt) statement.setLong(18, file.importedAt)
statement.setLong(19, file.updatedAt) statement.setLong(19, file.createdAt)
statement.setLong(20, file.updatedAt)
statement.executeUpdate() statement.executeUpdate()
} }
} }
@ -525,7 +536,8 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri, f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at, f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status, 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 FROM book_files f
LEFT JOIN books b ON b.id = f.book_id LEFT JOIN books b ON b.id = f.book_id
WHERE f.id = ? WHERE f.id = ?
@ -680,7 +692,8 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri, f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at, f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status, 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 FROM book_files f
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
@ -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> { 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 -> return connection.prepareStatement("SELECT * FROM book_files ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit) statement.setInt(1, limit)
@ -905,12 +975,56 @@ private fun ResultSet.toBookRecord() = BookRecord(
language = getString("language"), language = getString("language"),
date = getString("published_date"), date = getString("published_date"),
description = getString("description"), description = getString("description"),
keywords = getString("keywords"),
coverImage = getBytes("cover_image"), coverImage = getBytes("cover_image"),
coverImageMimeType = getString("cover_image_mime_type"), coverImageMimeType = getString("cover_image_mime_type"),
createdAt = getLong("created_at"), createdAt = getLong("created_at"),
updatedAt = getLong("updated_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( private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
fileId = getString("file_id"), fileId = getString("file_id"),
bookId = getString("book_id"), bookId = getString("book_id"),
@ -925,6 +1039,7 @@ private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
lastSeenAt = getLongOrNull("last_seen_at"), lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"), readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"), lastReadAt = getLongOrNull("last_read_at"),
importedAt = getLong("imported_at"),
) )
private fun ResultSet.toBookBodyRecord() = BookBodyRecord( private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
@ -961,6 +1076,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
lastSeenAt = getLongOrNull("last_seen_at"), lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"), readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"), lastReadAt = getLongOrNull("last_read_at"),
importedAt = getLong("imported_at"),
createdAt = getLong("created_at"), createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"), updatedAt = getLong("updated_at"),
) )

View File

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

View File

@ -16,6 +16,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue
class H2LibraryDatabaseTest { class H2LibraryDatabaseTest {
@Test @Test
@ -259,6 +260,89 @@ class H2LibraryDatabaseTest {
db.close() 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 @Test
fun opensDatabaseWithExistingUppercaseIndex() { fun opensDatabaseWithExistingUppercaseIndex() {
val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString() val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()
@ -290,4 +374,55 @@ class H2LibraryDatabaseTest {
H2LibraryDatabase.openFile(path).close() 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()
}
} }