diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index e94125f..4b96ce0 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -125,6 +125,15 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = 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 = + 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" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 0f6272d..d1c15ac 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 953389f..04e2841 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -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 expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List +expect suspend fun searchLibraryItems(query: String, limit: Int = 100): List + 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 diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index af56de0..b9025cd 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -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>(emptyList()) } + var searching by remember { mutableStateOf(false) } val coverCache = remember { mutableStateMapOf() } + 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 diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index f9d3c37..edfbf03 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -90,6 +90,15 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = 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 = + 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" diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index 174609f..19d3f17 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -24,6 +24,8 @@ actual suspend fun loadLibraryItems(): List = emptyList() actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = emptyList() +actual suspend fun searchLibraryItems(query: String, limit: Int): List = 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 diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt index 488d2e8..1a0ab48 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt @@ -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 + fun searchLibraryFiles(prefixes: List, limit: Int = 100): List fun list(limit: Int = 500, offset: Int = 0): List fun listForBook(bookId: String): List fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt index 4c43f7e..20b1733 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt @@ -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, limit: Int): List { + 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> { 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 { 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): 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, fieldOffset: Int): List { + 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"), ) diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt index 028d705..1d68faa 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt @@ -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, ) diff --git a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt index 409907a..3faf0b0 100644 --- a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt +++ b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt @@ -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() + } }