library improvements: search
This commit is contained in:
parent
ade4fb1896
commit
1239f15836
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user