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) {
|
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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()) {
|
||||||
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
|
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 {
|
} 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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user