Add library categories and favorites

This commit is contained in:
Sergey Chernov 2026-05-23 13:14:16 +03:00
parent d749352333
commit 02bde7e644
9 changed files with 263 additions and 21 deletions

View File

@ -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,
)

View File

@ -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

View File

@ -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<List<LibraryItem>>(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"

View File

@ -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<LibraryItem?>(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()
}

View File

@ -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,
)

View File

@ -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

View File

@ -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<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord>
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
}

View File

@ -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"),

View File

@ -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()
}