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 7dff5e3..4220b68 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -131,6 +131,13 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = withContext(Dispatchers.IO) { + appendLibraryLog("load recently added library items since=$sinceImportedAt limit=$limit") + openLibraryDatabase().useLibrary { db -> + db.files.listRecentlyAddedLibraryFiles(sinceImportedAt, limit).map { it.toLibraryItem() } + } +} + actual suspend fun searchLibraryItems(query: String, limit: Int): List = withContext(Dispatchers.IO) { val prefixes = query.toSearchPrefixes() if (prefixes.isEmpty()) return@withContext emptyList() @@ -140,6 +147,8 @@ actual suspend fun searchLibraryItems(query: String, limit: Int): List db.files.getLibraryFile(fileId)?.toLibraryItem() diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index f670fff..afac05e 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -96,8 +96,12 @@ expect suspend fun loadLibraryItems(): List expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List +expect suspend fun loadRecentlyAddedLibraryItems(sinceImportedAt: Long, limit: Int = 50): List + expect suspend fun searchLibraryItems(query: String, limit: Int = 100): List +expect fun currentLibraryTimeMillis(): Long + expect suspend fun loadLibraryItem(fileId: String): LibraryItem? expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover? diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 722a2e8..6896d68 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -86,6 +86,7 @@ internal fun LibraryScreen( var nextOffset by remember(state.items) { mutableStateOf(state.items.size) } var loadingPage by remember(state.items) { mutableStateOf(false) } var endReached by remember(state.items) { mutableStateOf(false) } + var recentlyAddedItems by remember(state.items) { mutableStateOf>(emptyList()) } var wasScanning by remember { mutableStateOf(false) } var settingsMenuOpen by remember { mutableStateOf(false) } var autoScanDownloads by remember { mutableStateOf(true) } @@ -94,6 +95,7 @@ internal fun LibraryScreen( var searchResults by remember { mutableStateOf>(emptyList()) } var searching by remember { mutableStateOf(false) } var readingNowCollapsed by remember { mutableStateOf(false) } + var recentlyAddedCollapsed by remember { mutableStateOf(false) } var myLibraryCollapsed by remember { mutableStateOf(false) } var notInterestedCollapsed by remember { mutableStateOf(false) } val coverCache = remember { mutableStateMapOf() } @@ -133,6 +135,11 @@ internal fun LibraryScreen( } } + suspend fun loadRecentlyAdded() { + val since = currentLibraryTimeMillis() - RecentlyAddedWindowMillis + recentlyAddedItems = loadRecentlyAddedLibraryItems(since, RecentlyAddedLimit) + } + fun refresh(nextMessage: String? = message) { message = nextMessage scope.launch { @@ -142,6 +149,7 @@ internal fun LibraryScreen( searching = false } else { loadPage(reset = true) + loadRecentlyAdded() } } } @@ -162,6 +170,7 @@ internal fun LibraryScreen( searching = false } else { loadPage(reset = true) + loadRecentlyAdded() } } else { message = "Could not update ${item.title}." @@ -193,7 +202,10 @@ internal fun LibraryScreen( } LaunchedEffect(state.scanPath, state.message) { - if (items.isEmpty() && !endReached) loadPage(reset = true) + if (items.isEmpty() && !endReached) { + loadPage(reset = true) + loadRecentlyAdded() + } } LaunchedEffect(searchText) { @@ -242,10 +254,12 @@ internal fun LibraryScreen( while (true) { delay(5_000) loadPage(reset = true) + loadRecentlyAdded() } } else if (wasScanning) { wasScanning = false loadPage(reset = true) + loadRecentlyAdded() } } @@ -440,7 +454,13 @@ internal fun LibraryScreen( libraryRows("search", visibleItems) } else { val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING } - val myLibrary = visibleItems.filter { it.readingStatus != BookReadingStatus.READING && it.readingStatus != BookReadingStatus.NOT_INTERESTED } + val recentlyAdded = recentlyAddedItems.filter { it.readingStatus == BookReadingStatus.NEW } + val recentlyAddedIds = recentlyAdded.mapTo(mutableSetOf()) { it.fileId } + val myLibrary = visibleItems.filter { + it.fileId !in recentlyAddedIds && + it.readingStatus != BookReadingStatus.READING && + it.readingStatus != BookReadingStatus.NOT_INTERESTED + } val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED } librarySection( @@ -453,6 +473,16 @@ internal fun LibraryScreen( ) { libraryRows("reading", readingNow) } + librarySection( + key = "recently-added", + title = "recently added", + itemCount = recentlyAdded.size, + displayCount = null, + collapsed = recentlyAddedCollapsed, + onCollapsedChange = { recentlyAddedCollapsed = it }, + ) { + libraryRows("recently-added", recentlyAdded) + } librarySection( key = "library", title = "my library", @@ -858,3 +888,5 @@ private fun LibraryScanProgress.toCatalogScanMessage(): String { private const val LibraryPageSize: Int = 50 private const val SearchResultLimit: Int = 100 +private const val RecentlyAddedLimit: Int = 50 +private const val RecentlyAddedWindowMillis: Long = 30L * 60L * 60L * 1000L 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 52c3822..c929c88 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -94,6 +94,13 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = withContext(Dispatchers.IO) { + appendLibraryLog("load recently added library items since=$sinceImportedAt limit=$limit") + openLibraryDatabase().useLibrary { db -> + db.files.listRecentlyAddedLibraryFiles(sinceImportedAt, limit).map { it.toLibraryItem() } + } +} + actual suspend fun searchLibraryItems(query: String, limit: Int): List = withContext(Dispatchers.IO) { val prefixes = query.toSearchPrefixes() if (prefixes.isEmpty()) return@withContext emptyList() @@ -103,6 +110,8 @@ actual suspend fun searchLibraryItems(query: String, limit: Int): List db.files.getLibraryFile(fileId)?.toLibraryItem() 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 6386843..b3b82f8 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -24,8 +24,12 @@ actual suspend fun loadLibraryItems(): List = emptyList() actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = emptyList() +actual suspend fun loadRecentlyAddedLibraryItems(sinceImportedAt: Long, limit: Int): List = emptyList() + actual suspend fun searchLibraryItems(query: String, limit: Int): List = emptyList() +actual fun currentLibraryTimeMillis(): Long = 0L + actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = null actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = null 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 e6de8ff..0eda58d 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt @@ -205,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 listRecentlyAddedLibraryFiles(sinceImportedAt: Long, limit: Int = 50): List fun searchLibraryFiles(prefixes: List, limit: Int = 100): List fun list(limit: Int = 500, offset: Int = 0): List fun listForBook(bookId: String): List 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 f9c12fb..f679846 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 @@ -715,6 +715,40 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF } } + override fun listRecentlyAddedLibraryFiles(sinceImportedAt: Long, limit: Int): List { + markDuplicateFiles() + return 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, + 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 + AND f.reading_status = 'NEW' + AND f.imported_at >= ? + ORDER BY f.imported_at DESC, f.id + LIMIT ? + """.trimIndent() + ).use { statement -> + statement.setLong(1, sinceImportedAt) + statement.setInt(2, limit) + statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toLibraryFileRecord() } } + } + } + override fun searchLibraryFiles(prefixes: List, limit: Int): List { val normalizedPrefixes = prefixes.mapNotNull { it.trim().lowercase().takeIf(String::isNotBlank) }.distinct() if (normalizedPrefixes.isEmpty()) return emptyList() 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 e500d7b..59e702c 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 @@ -201,6 +201,47 @@ class H2LibraryDatabaseTest { db.close() } + @Test + fun listsRecentlyAddedNewLibraryFiles() { + val db = H2LibraryDatabase.openMemory("listsRecentlyAddedNewLibraryFiles") + val now = 1_700_000_000_000L + + db.transaction { + listOf( + Triple("book-old", "Old", BookReadingStatus.NEW), + Triple("book-newer", "Newer", BookReadingStatus.NEW), + Triple("book-newest", "Newest", BookReadingStatus.NEW), + Triple("book-read", "Read", BookReadingStatus.READ), + ).forEachIndexed { index, (bookId, title, status) -> + books.upsert(BookRecord(id = bookId, title = title, createdAt = now + index, updatedAt = now + index)) + files.upsert( + BookFileRecord( + id = "file-$title", + bookId = bookId, + rawSha256 = "sha-$title", + originalFilename = "$title.fb2", + storageKind = BookFileStorageKind.EXTERNAL_URI, + readingStatus = status, + importedAt = when (title) { + "Old" -> now - 1_000 + "Newer" -> now + 2_000 + "Newest" -> now + 3_000 + else -> now + 4_000 + }, + createdAt = now + index, + updatedAt = now + index, + ) + ) + } + } + + assertEquals( + listOf("Newest", "Newer"), + db.files.listRecentlyAddedLibraryFiles(now, 50).map { it.title }, + ) + db.close() + } + @Test fun hidesDuplicateLibraryFilesAndPrefersZip() { val db = H2LibraryDatabase.openMemory("hidesDuplicateLibraryFilesAndPrefersZip")