From 02bde7e644e70c674ae7fc47ece5434bc624d94e Mon Sep 17 00:00:00 2001 From: sergeych Date: Sat, 23 May 2026 13:14:16 +0300 Subject: [PATCH] Add library categories and favorites --- .../sergeych/toread/BookPlatform.android.kt | 7 + .../net/sergeych/toread/LibraryPlatform.kt | 3 + .../net/sergeych/toread/LibraryScreen.kt | 158 ++++++++++++++++-- .../net/sergeych/toread/ReaderScreen.kt | 58 ++++++- .../net/sergeych/toread/BookPlatform.jvm.kt | 7 + .../net/sergeych/toread/BookPlatform.web.kt | 2 + .../sergeych/toread/storage/LibraryStorage.kt | 4 + .../toread/storage/jdbc/H2LibraryDatabase.kt | 39 ++++- .../storage/jdbc/H2LibraryDatabaseTest.kt | 6 + 9 files changed, 263 insertions(+), 21 deletions(-) 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 4220b68..c0f048c 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -337,6 +337,12 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS } } +actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + db.files.updateFavorite(fileId, favorite) + } +} + actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) { runCatching { val shareFile = openLibraryDatabase().useLibrary { db -> @@ -698,6 +704,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem = storageUri = storageUri, lastSeenAt = lastSeenAt, readingStatus = readingStatus, + favorite = favorite, lastReadAt = lastReadAt, importedAt = importedAt, ) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index afac05e..5f3fb86 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -16,6 +16,7 @@ data class LibraryItem( val storageUri: String?, val lastSeenAt: Long?, val readingStatus: BookReadingStatus = BookReadingStatus.NEW, + val favorite: Boolean = false, val lastReadAt: Long? = null, val importedAt: Long? = null, val coverImage: ByteArray? = null, @@ -124,6 +125,8 @@ expect suspend fun clearLibraryReadingPosition(fileId: String) expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean +expect suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean + expect suspend fun shareLibraryBookFile(fileId: String): Boolean expect suspend fun viewLibraryBookFile(fileId: String): Boolean diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index d16c6c6..0ca42e4 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search @@ -53,6 +54,7 @@ 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.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -60,6 +62,7 @@ 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.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextAlign @@ -97,10 +100,14 @@ internal fun LibraryScreen( var settingsMenuOpen by remember { mutableStateOf(false) } var autoScanDownloads by remember { mutableStateOf(true) } var autoScanSettingLoaded by remember { mutableStateOf(false) } + var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } var searchResults by remember { mutableStateOf>(emptyList()) } var searching by remember { mutableStateOf(false) } var readingNowCollapsed by remember { mutableStateOf(false) } + var favoritesCollapsed by remember { mutableStateOf(false) } + var toReadCollapsed by remember { mutableStateOf(false) } + var readCollapsed by remember { mutableStateOf(false) } var recentlyAddedCollapsed by remember { mutableStateOf(false) } var myLibraryCollapsed by remember { mutableStateOf(false) } var notInterestedCollapsed by remember { mutableStateOf(false) } @@ -171,6 +178,31 @@ internal fun LibraryScreen( val updatedItem = loadLibraryItem(item.fileId) ?: item.copy(readingStatus = status) items = items.replaceLibraryItem(updatedItem) searchResults = searchResults.replaceLibraryItem(updatedItem) + recentlyAddedItems = recentlyAddedItems.replaceLibraryItem(updatedItem) + message = successMessage + if (searchActive) { + searching = true + searchResults = searchLibraryItems(searchText, SearchResultLimit) + searching = false + } else { + loadPage(reset = true) + loadRecentlyAdded() + } + } else { + message = "Could not update ${item.title}." + } + } + + suspend fun applyFavorite( + item: LibraryItem, + favorite: Boolean, + successMessage: String, + ) { + if (markLibraryFavorite(item.fileId, favorite)) { + val updatedItem = loadLibraryItem(item.fileId) ?: item.copy(favorite = favorite) + items = items.replaceLibraryItem(updatedItem) + searchResults = searchResults.replaceLibraryItem(updatedItem) + recentlyAddedItems = recentlyAddedItems.replaceLibraryItem(updatedItem) message = successMessage if (searchActive) { searching = true @@ -246,10 +278,18 @@ internal fun LibraryScreen( autoScanSettingLoaded = true } - LaunchedEffect(autoScanSettingLoaded, autoScanDownloads) { - if (!autoScanSettingLoaded || !autoScanDownloads || activeScan != null) return@LaunchedEffect + LaunchedEffect(autoScanSettingLoaded, autoScanDownloads, activeScan == null) { + if ( + !autoScanSettingLoaded || + !autoScanDownloads || + backgroundDownloadsRescanStarted || + activeScan != null + ) { + return@LaunchedEffect + } if (loadDownloadsWasScanned()) { downloadsScanPath()?.let { path -> + backgroundDownloadsRescanStarted = true message = "Scanning Downloads..." onStartScan(path) } @@ -260,7 +300,7 @@ internal fun LibraryScreen( if (activeScan != null) { wasScanning = true while (true) { - delay(5_000) + delay(2_000) loadPage(reset = true) loadRecentlyAdded() } @@ -364,7 +404,9 @@ internal fun LibraryScreen( readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem) coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId) } - if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)) { + if (item.readingStatus == BookReadingStatus.NEW && + markLibraryReadingStatus(item.fileId, BookReadingStatus.READING) + ) { val readingItem = loadLibraryItem(item.fileId) ?: item.copy(readingStatus = BookReadingStatus.READING) items = items.replaceLibraryItem(readingItem) @@ -418,6 +460,16 @@ internal fun LibraryScreen( } } }, + onMarkToRead = { + scope.launch { + busy = true + try { + applyReadingStatus(item, BookReadingStatus.TO_READ, "Marked ${item.title} to read.") + } finally { + busy = false + } + } + }, onNotInterested = { scope.launch { busy = true @@ -428,6 +480,17 @@ internal fun LibraryScreen( } } }, + onFavoriteChange = { favorite -> + scope.launch { + busy = true + try { + val label = if (favorite) "Added ${item.title} to favorites." else "Removed ${item.title} from favorites." + applyFavorite(item, favorite, label) + } finally { + busy = false + } + } + }, onDelete = { val previousItems = items val previousSearchResults = searchResults @@ -473,6 +536,9 @@ internal fun LibraryScreen( libraryRows("search", visibleItems) } else { val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING } + val favorites = visibleItems.filter { it.favorite } + val toRead = visibleItems.filter { it.readingStatus == BookReadingStatus.TO_READ } + val read = visibleItems.filter { it.readingStatus == BookReadingStatus.READ } val recentlyAdded = recentlyAddedItems.filter { it.fileId !in hiddenFileIds && it.readingStatus == BookReadingStatus.NEW } @@ -480,6 +546,8 @@ internal fun LibraryScreen( val myLibrary = visibleItems.filter { it.fileId !in recentlyAddedIds && it.readingStatus != BookReadingStatus.READING && + it.readingStatus != BookReadingStatus.TO_READ && + it.readingStatus != BookReadingStatus.READ && it.readingStatus != BookReadingStatus.NOT_INTERESTED } val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED } @@ -494,6 +562,26 @@ internal fun LibraryScreen( ) { libraryRows("reading", readingNow) } + librarySection( + key = "favorites", + title = "favorites", + itemCount = favorites.size, + displayCount = favorites.size.takeIf { endReached }, + collapsed = favoritesCollapsed, + onCollapsedChange = { favoritesCollapsed = it }, + ) { + libraryRows("favorites", favorites) + } + librarySection( + key = "to-read", + title = "to read", + itemCount = toRead.size, + displayCount = toRead.size.takeIf { endReached }, + collapsed = toReadCollapsed, + onCollapsedChange = { toReadCollapsed = it }, + ) { + libraryRows("to-read", toRead) + } librarySection( key = "recently-added", title = "recently added", @@ -514,6 +602,16 @@ internal fun LibraryScreen( ) { libraryRows("library", myLibrary) } + librarySection( + key = "read", + title = "read", + itemCount = read.size, + displayCount = read.size.takeIf { endReached }, + collapsed = readCollapsed, + onCollapsedChange = { readCollapsed = it }, + ) { + libraryRows("read", read) + } librarySection( key = "not-interested", title = "not interested", @@ -723,6 +821,8 @@ private fun LibraryRow( actions: LibraryItemActions, ) { var menuOpen by remember { mutableStateOf(false) } + val titleStyle = MaterialTheme.typography.titleMedium + val favoriteIconSize = with(LocalDensity.current) { titleStyle.lineHeight.toDp() - 2.dp } Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) { Row( @@ -735,13 +835,27 @@ private fun LibraryRow( ) { LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f)) Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - item.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + item.title, + style = titleStyle, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (item.favorite) { + Icon( + Icons.Filled.Favorite, + contentDescription = "Favorite", + tint = Color(0xFFD32F2F), + modifier = Modifier.size(favoriteIconSize), + ) + } + } Text( item.authors.joinToString().ifBlank { "Unknown author" }, style = MaterialTheme.typography.bodyMedium, @@ -787,15 +901,31 @@ private fun LibraryRow( }, ) } + if (item.readingStatus != BookReadingStatus.TO_READ) { + DropdownMenuItem( + text = { Text("Mark to read") }, + onClick = { + menuOpen = false + actions.onMarkToRead() + }, + ) + } if (item.readingStatus != BookReadingStatus.NEW) { DropdownMenuItem( - text = { Text("Remove marks") }, + text = { Text(if (item.readingStatus == BookReadingStatus.TO_READ) "Remove to read" else "Remove marks") }, onClick = { menuOpen = false actions.onRemoveMarks() }, ) } + DropdownMenuItem( + text = { Text(if (item.favorite) "Remove favorite" else "Add favorite") }, + onClick = { + menuOpen = false + actions.onFavoriteChange(!item.favorite) + }, + ) DropdownMenuItem( text = { Text("Not interested") }, onClick = { @@ -822,7 +952,9 @@ private data class LibraryItemActions( val onMarkAsRead: () -> Unit, val onMarkAsUnread: () -> Unit, val onRemoveMarks: () -> Unit, + val onMarkToRead: () -> Unit, val onNotInterested: () -> Unit, + val onFavoriteChange: (Boolean) -> Unit, val onDelete: () -> Unit, ) @@ -866,6 +998,7 @@ private fun LibraryCover( private fun LibraryItem.libraryMetadataLine(): String = listOfNotNull( + "Favorite".takeIf { favorite }, readingStatus.displayLabel, lastReadAt?.formatLastRead(), date?.yearOrRaw(), @@ -877,6 +1010,7 @@ private fun LibraryItem.libraryMetadataLine(): String = private val BookReadingStatus.displayLabel: String get() = when (this) { BookReadingStatus.NEW -> "New" + BookReadingStatus.TO_READ -> "To read" BookReadingStatus.READING -> "Reading" BookReadingStatus.READ -> "Read" BookReadingStatus.NOT_INTERESTED -> "Not interested" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index 60a3b51..f2be42b 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -81,6 +81,7 @@ internal fun BookView( val snackbarHostState = remember { SnackbarHostState() } var restored by remember(fileId) { mutableStateOf(false) } var markedRead by remember(fileId) { mutableStateOf(false) } + var libraryItem by remember(fileId) { mutableStateOf(null) } var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } val readAloudState by ReadAloudPlatform.state.collectAsState() @@ -102,6 +103,7 @@ internal fun BookView( fun setReadingStatus(status: BookReadingStatus, successMessage: String) { scope.launch { if (markLibraryReadingStatus(fileId, status)) { + libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = status) if (status == BookReadingStatus.READ) markedRead = true if (status == BookReadingStatus.NEW) markedRead = false showMessage(successMessage) @@ -112,7 +114,12 @@ internal fun BookView( } LaunchedEffect(fileId) { - markLibraryReadingStatus(fileId, BookReadingStatus.READING) + val item = loadLibraryItem(fileId) + libraryItem = item + markedRead = item?.readingStatus == BookReadingStatus.READ + if (item?.readingStatus == BookReadingStatus.NEW && markLibraryReadingStatus(fileId, BookReadingStatus.READING)) { + libraryItem = loadLibraryItem(fileId) ?: item.copy(readingStatus = BookReadingStatus.READING) + } } DisposableEffect(fileId) { @@ -144,10 +151,12 @@ internal fun BookView( val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 layoutInfo.totalItemsCount > 0 && lastVisible >= layoutInfo.totalItemsCount - 1 } - .filter { restored && it && !markedRead } + .filter { restored && it && !markedRead && libraryItem?.readingStatus != BookReadingStatus.TO_READ } .collect { markedRead = true - markLibraryReadingStatus(fileId, BookReadingStatus.READ) + if (markLibraryReadingStatus(fileId, BookReadingStatus.READ)) { + libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = BookReadingStatus.READ) + } } } @@ -179,12 +188,27 @@ internal fun BookView( onMarkAsRead = { setReadingStatus(BookReadingStatus.READ, "Marked as read.") }, + onMarkToRead = { + setReadingStatus(BookReadingStatus.TO_READ, "Marked to read.") + }, onNotInterested = { setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.") }, onClearMarks = { setReadingStatus(BookReadingStatus.NEW, "Cleared marks.") }, + readingStatus = libraryItem?.readingStatus, + favorite = libraryItem?.favorite == true, + onFavoriteChange = { favorite -> + scope.launch { + if (markLibraryFavorite(fileId, favorite)) { + libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite) + showMessage(if (favorite) "Added to favorites." else "Removed from favorites.") + } else { + showMessage("Could not update book.") + } + } + }, showShareAction = showShareAction, onShare = { scope.launch { @@ -290,8 +314,12 @@ private fun CompactReaderTopBar( onThemeToggle: () -> Unit, onBookInfo: () -> Unit, onMarkAsRead: () -> Unit, + onMarkToRead: () -> Unit, onNotInterested: () -> Unit, onClearMarks: () -> Unit, + readingStatus: BookReadingStatus?, + favorite: Boolean, + onFavoriteChange: (Boolean) -> Unit, showShareAction: Boolean, onShare: () -> Unit, showViewFileAction: Boolean, @@ -345,6 +373,23 @@ private fun CompactReaderTopBar( onMarkAsRead() }, ) + if (readingStatus == BookReadingStatus.TO_READ) { + DropdownMenuItem( + text = { Text("Remove to read") }, + onClick = { + menuOpen = false + onClearMarks() + }, + ) + } else { + DropdownMenuItem( + text = { Text("Mark to read") }, + onClick = { + menuOpen = false + onMarkToRead() + }, + ) + } DropdownMenuItem( text = { Text("Not interested") }, onClick = { @@ -359,6 +404,13 @@ private fun CompactReaderTopBar( onClearMarks() }, ) + DropdownMenuItem( + text = { Text(if (favorite) "Remove favorite" else "Add favorite") }, + onClick = { + menuOpen = false + onFavoriteChange(!favorite) + }, + ) if (showShareAction || showViewFileAction) { HorizontalDivider() } 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 c929c88..e7a54a2 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -285,6 +285,12 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS } } +actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + db.files.updateFavorite(fileId, favorite) + } +} + actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) { @@ -495,6 +501,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem = storageUri = storageUri, lastSeenAt = lastSeenAt, readingStatus = readingStatus, + favorite = favorite, lastReadAt = lastReadAt, importedAt = importedAt, ) 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 b3b82f8..2a63d5d 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -57,6 +57,8 @@ actual suspend fun clearLibraryReadingPosition(fileId: String) = Unit actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false +actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean = false + actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false 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 0eda58d..4430387 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt @@ -17,6 +17,7 @@ enum class BookImportPolicy { enum class BookReadingStatus { NEW, + TO_READ, READING, READ, NOT_INTERESTED, @@ -109,6 +110,7 @@ data class BookFileRecord( val lastModifiedMillis: Long? = null, val lastSeenAt: Long? = null, val readingStatus: BookReadingStatus = BookReadingStatus.NEW, + val favorite: Boolean = false, val lastReadAt: Long? = null, val createdAt: Long, val updatedAt: Long, @@ -128,6 +130,7 @@ data class LibraryFileRecord( val storageUri: String? = null, val lastSeenAt: Long? = null, val readingStatus: BookReadingStatus = BookReadingStatus.NEW, + val favorite: Boolean = false, val lastReadAt: Long? = null, val importedAt: Long, ) @@ -210,6 +213,7 @@ interface BookFileRepository { fun list(limit: Int = 500, offset: Int = 0): List fun listForBook(bookId: String): List fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean + fun updateFavorite(id: String, favorite: Boolean): Boolean fun touchLastReadAt(id: String, lastReadAt: Long): Boolean fun delete(id: String): 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 f679846..84da4a7 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 @@ -213,6 +213,7 @@ private fun migrate(connection: Connection) { last_modified_millis BIGINT, last_seen_at BIGINT, reading_status VARCHAR NOT NULL DEFAULT 'NEW', + favorite BOOLEAN NOT NULL DEFAULT FALSE, last_read_at BIGINT, imported_at BIGINT NOT NULL, created_at BIGINT NOT NULL, @@ -225,6 +226,7 @@ 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 favorite BOOLEAN NOT NULL DEFAULT FALSE") 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 -> @@ -487,9 +489,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, imported_at, created_at, updated_at + last_modified_millis, last_seen_at, reading_status, favorite, last_read_at, imported_at, created_at, updated_at ) - KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent() ).use { statement -> statement.setString(1, file.id) @@ -508,10 +510,11 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF statement.setLongOrNull(14, file.lastModifiedMillis) statement.setLongOrNull(15, file.lastSeenAt) statement.setString(16, file.readingStatus.name) - statement.setLongOrNull(17, file.lastReadAt) - statement.setLong(18, file.importedAt) - statement.setLong(19, file.createdAt) - statement.setLong(20, file.updatedAt) + statement.setBoolean(17, file.favorite) + statement.setLongOrNull(18, file.lastReadAt) + statement.setLong(19, file.importedAt) + statement.setLong(20, file.createdAt) + statement.setLong(21, file.updatedAt) statement.executeUpdate() } } @@ -536,6 +539,7 @@ 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.favorite AS favorite, f.last_read_at AS last_read_at, f.imported_at AS imported_at FROM book_files f @@ -692,6 +696,7 @@ 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.favorite AS favorite, f.last_read_at AS last_read_at, f.imported_at AS imported_at FROM book_files f @@ -732,6 +737,7 @@ 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.favorite AS favorite, f.last_read_at AS last_read_at, f.imported_at AS imported_at FROM book_files f @@ -771,6 +777,7 @@ 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.favorite AS favorite, f.last_read_at AS last_read_at, f.imported_at AS imported_at FROM book_files f @@ -847,6 +854,24 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF } } + override fun updateFavorite(id: String, favorite: Boolean): Boolean { + val now = System.currentTimeMillis() + return connection.prepareStatement( + """ + UPDATE book_files + SET favorite = ?, updated_at = ? + WHERE id = ? + OR body_cluster_id = (SELECT body_cluster_id FROM book_files WHERE id = ? AND body_cluster_id IS NOT NULL) + """.trimIndent() + ).use { statement -> + statement.setBoolean(1, favorite) + statement.setLong(2, now) + statement.setString(3, id) + statement.setString(4, id) + statement.executeUpdate() > 0 + } + } + override fun touchLastReadAt(id: String, lastReadAt: Long): Boolean { return connection.prepareStatement( """ @@ -1085,6 +1110,7 @@ private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord( storageUri = getString("storage_uri"), lastSeenAt = getLongOrNull("last_seen_at"), readingStatus = getReadingStatus("reading_status"), + favorite = getBoolean("favorite"), lastReadAt = getLongOrNull("last_read_at"), importedAt = getLong("imported_at"), ) @@ -1122,6 +1148,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord( lastModifiedMillis = getLongOrNull("last_modified_millis"), lastSeenAt = getLongOrNull("last_seen_at"), readingStatus = getReadingStatus("reading_status"), + favorite = getBoolean("favorite"), lastReadAt = getLongOrNull("last_read_at"), importedAt = getLong("imported_at"), createdAt = getLong("created_at"), 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 59e702c..363ba31 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 @@ -122,6 +122,9 @@ class H2LibraryDatabaseTest { assertEquals("2024", db.files.getLibraryFile("file-1")?.date) assertEquals("image/jpeg", db.books.getCover("book-1")?.mimeType) assertEquals(BookReadingStatus.NEW, db.files.getLibraryFile("file-1")?.readingStatus) + assertEquals(false, db.files.getLibraryFile("file-1")?.favorite) + assertEquals(true, db.files.updateFavorite("file-1", true)) + assertEquals(true, db.files.getLibraryFile("file-1")?.favorite) assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.READING)) assertEquals(BookReadingStatus.READING, db.files.getLibraryFile("file-1")?.readingStatus) assertEquals("body-1", db.bodies.findByExactTextHash("text-sha", 1)?.id) @@ -299,6 +302,9 @@ class H2LibraryDatabaseTest { assertEquals(true, db.files.updateReadingStatus("file-zip", BookReadingStatus.READING)) assertEquals(BookReadingStatus.READING, db.files.get("file-fb2")?.readingStatus) assertEquals(BookReadingStatus.READING, db.files.get("file-zip")?.readingStatus) + assertEquals(true, db.files.updateFavorite("file-zip", true)) + assertEquals(true, db.files.get("file-fb2")?.favorite) + assertEquals(true, db.files.get("file-zip")?.favorite) db.close() }