From d8b39057d525d298d51ad37241203f361e09e194 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 17 May 2026 01:56:33 +0300 Subject: [PATCH] improved UI --- .../sergeych/toread/BookPlatform.android.kt | 19 +- .../kotlin/net/sergeych/toread/App.kt | 164 +++++++++++++++++- .../net/sergeych/toread/LibraryPlatform.kt | 7 + .../net/sergeych/toread/BookPlatform.jvm.kt | 21 ++- .../net/sergeych/toread/BookPlatform.web.kt | 29 ++++ .../sergeych/toread/storage/LibraryStorage.kt | 13 ++ .../toread/storage/jdbc/H2LibraryDatabase.kt | 116 ++++++++++++- .../storage/jdbc/H2LibraryDatabaseTest.kt | 107 ++++++++++++ 8 files changed, 460 insertions(+), 16 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 8a54ee0..666ecda 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -13,6 +13,7 @@ import android.provider.OpenableColumns import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import net.sergeych.toread.fb2.Fb2Binary +import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.LibraryFileRecord import net.sergeych.toread.storage.ReadingStateRecord @@ -20,6 +21,9 @@ import net.sergeych.toread.storage.jdbc.H2LibraryDatabase import net.sergeych.toread.storage.jdbc.LibraryScanner import net.sergeych.toread.storage.jdbc.LibraryScanSummary import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -154,6 +158,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP openLibraryDatabase().useLibrary { db -> val file = db.files.get(fileId) ?: return@useLibrary val clusterId = file.bodyClusterId ?: return@useLibrary + val now = System.currentTimeMillis() db.readingStates.upsert( ReadingStateRecord( id = "state-$clusterId", @@ -163,9 +168,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP progress = position.itemIndex.toDouble(), formatHintsJson = position.toFormatHintsJson(), ), - updatedAt = System.currentTimeMillis(), + updatedAt = now, ), ) + db.files.touchLastReadAt(fileId, now) + } +} + +actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + db.files.updateReadingStatus(fileId, status) } } @@ -243,6 +255,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit { actual fun libraryLogPath(): String? = libraryLogFile().absolutePath +actual fun formatLibraryLastReadTime(millis: Long): String = + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis)) + private data class AndroidLibraryDocument( val uri: Uri, val name: String, @@ -432,6 +447,8 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem = sizeBytes = sizeBytes, storageUri = storageUri, lastSeenAt = lastSeenAt, + readingStatus = readingStatus, + lastReadAt = lastReadAt, ) private fun libraryLogFile(): File = diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 17b6da0..f96d469 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -31,9 +31,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Scanner @@ -42,9 +42,12 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -99,6 +102,7 @@ import net.sergeych.toread.fb2.Fb2Section import net.sergeych.toread.fb2.Fb2Text import net.sergeych.toread.fb2.Fb2TextSpan import net.sergeych.toread.fb2.Fb2TextStyle +import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.text.HyphenationRegistry import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -210,7 +214,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) { ) }, onBack = { - state = AppState.Library(current.libraryItems, current.scanPath, current.message) + state = AppState.Library(emptyList(), current.scanPath, current.message) }, ) is AppState.BookInfo -> BookInfoScreen( @@ -317,7 +321,20 @@ private fun LibraryScreen( contentPadding = PaddingValues(0.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - items(items, key = { it.fileId }) { item -> + val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING + if (hasReadingNow) { + item(key = "section-reading") { + LibrarySectionHeader("reading now") + } + } + itemsIndexed(items, key = { _, item -> item.fileId }) { index, item -> + if ( + hasReadingNow && + item.readingStatus != BookReadingStatus.READING && + (index == 0 || items[index - 1].readingStatus == BookReadingStatus.READING) + ) { + LibrarySectionHeader("my library") + } LibraryRow( item = item, coverCache = coverCache, @@ -329,6 +346,7 @@ private fun LibraryScreen( val next = runCatching { val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.") val book = Fb2Format.parse(bytes, item.storageUri ?: item.title) + markLibraryReadingStatus(item.fileId, BookReadingStatus.READING) saveActiveReadingFileId(item.fileId) AppState.Reader( fileId = item.fileId, @@ -346,6 +364,51 @@ private fun LibraryScreen( } } }, + onMarkAsRead = { + scope.launch { + busy = true + try { + if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) { + message = "Marked ${item.title} as read." + loadPage(reset = true) + } else { + message = "Could not update ${item.title}." + } + } finally { + busy = false + } + } + }, + onMarkAsUnread = { + scope.launch { + busy = true + try { + if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) { + message = "Marked ${item.title} as unread." + loadPage(reset = true) + } else { + message = "Could not update ${item.title}." + } + } finally { + busy = false + } + } + }, + onNotInterested = { + scope.launch { + busy = true + try { + if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) { + message = "Marked ${item.title} as not interesting." + loadPage(reset = true) + } else { + message = "Could not update ${item.title}." + } + } finally { + busy = false + } + } + }, onDelete = { scope.launch { busy = true @@ -525,14 +588,30 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) { } } +@Composable +private fun LibrarySectionHeader(text: String) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp), + ) +} + @Composable private fun LibraryRow( item: LibraryItem, coverCache: MutableMap, enabled: Boolean, onOpen: () -> Unit, + onMarkAsRead: () -> Unit, + onMarkAsUnread: () -> Unit, + onNotInterested: () -> Unit, onDelete: () -> Unit, ) { + var menuOpen by remember { mutableStateOf(false) } + Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -563,8 +642,53 @@ private fun LibraryRow( maxLines = 1, ) } - IconButton(onClick = onDelete, enabled = enabled) { - Icon(Icons.Filled.Delete, contentDescription = "Remove ${item.title}") + Box { + IconButton(onClick = { menuOpen = true }, enabled = enabled) { + Icon(Icons.Filled.MoreVert, contentDescription = "Book menu for ${item.title}") + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Open") }, + onClick = { + menuOpen = false + onOpen() + }, + ) + HorizontalDivider() + if (item.readingStatus != BookReadingStatus.READ) { + DropdownMenuItem( + text = { Text("Mark as read") }, + onClick = { + menuOpen = false + onMarkAsRead() + }, + ) + } + if (item.readingStatus == BookReadingStatus.READ) { + DropdownMenuItem( + text = { Text("Mark as unread") }, + onClick = { + menuOpen = false + onMarkAsUnread() + }, + ) + } + DropdownMenuItem( + text = { Text("Not interesting") }, + onClick = { + menuOpen = false + onNotInterested() + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + menuOpen = false + onDelete() + }, + ) + } } } } @@ -621,6 +745,11 @@ private fun BookView( val listState = rememberLazyListState() val scope = rememberCoroutineScope() var restored by remember(fileId) { mutableStateOf(false) } + var markedRead by remember(fileId) { mutableStateOf(false) } + + LaunchedEffect(fileId) { + markLibraryReadingStatus(fileId, BookReadingStatus.READING) + } LaunchedEffect(fileId) { loadLibraryReadingPosition(fileId)?.let { position -> @@ -639,6 +768,19 @@ private fun BookView( .collect { saveLibraryReadingPosition(fileId, it) } } + LaunchedEffect(fileId, listState) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + layoutInfo.totalItemsCount > 0 && lastVisible >= layoutInfo.totalItemsCount - 1 + } + .filter { restored && it && !markedRead } + .collect { + markedRead = true + markLibraryReadingStatus(fileId, BookReadingStatus.READ) + } + } + Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -1399,12 +1541,24 @@ private val ThemeMode.displayName: String private fun LibraryItem.libraryMetadataLine(): String = listOfNotNull( + readingStatus.displayLabel, + lastReadAt?.formatLastRead(), date?.yearOrRaw(), language?.uppercase(), format?.uppercase(), sizeBytes?.formatBytes(), ).joinToString(" | ").ifBlank { "No metadata" } +private val BookReadingStatus.displayLabel: String + get() = when (this) { + BookReadingStatus.NEW -> "New" + BookReadingStatus.READING -> "Reading" + BookReadingStatus.READ -> "Read" + BookReadingStatus.NOT_INTERESTED -> "Not interested" + } + +private fun Long.formatLastRead(): String = "Last read ${formatLibraryLastReadTime(this)}" + private fun String.yearOrRaw(): String = Regex("""\d{4}""").find(this)?.value ?: this diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index c766c14..65d4052 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -2,6 +2,7 @@ package net.sergeych.toread import net.sergeych.toread.fb2.Fb2Binary import net.sergeych.toread.fb2.Fb2Book +import net.sergeych.toread.storage.BookReadingStatus data class LibraryItem( val fileId: String, @@ -14,6 +15,8 @@ data class LibraryItem( val sizeBytes: Long?, val storageUri: String?, val lastSeenAt: Long?, + val readingStatus: BookReadingStatus = BookReadingStatus.NEW, + val lastReadAt: Long? = null, val coverImage: ByteArray? = null, val coverImageMimeType: String? = null, ) @@ -98,6 +101,8 @@ expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) +expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean + expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras expect suspend fun loadActiveReadingFileId(): String? @@ -114,6 +119,8 @@ expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit expect fun libraryLogPath(): String? +expect fun formatLibraryLastReadTime(millis: Long): String + internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? { val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull() return image?.let(::binaryFor) 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 e06c603..7f09864 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -3,13 +3,17 @@ package net.sergeych.toread import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import net.sergeych.toread.fb2.Fb2Binary -import net.sergeych.toread.storage.LibraryFileRecord +import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.ContentAnchor +import net.sergeych.toread.storage.LibraryFileRecord import net.sergeych.toread.storage.ReadingStateRecord import net.sergeych.toread.storage.jdbc.H2LibraryDatabase import net.sergeych.toread.storage.jdbc.LibraryScanner import org.jetbrains.skia.Image import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.TimeUnit import javax.swing.JFileChooser @@ -129,6 +133,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP openLibraryDatabase().useLibrary { db -> val file = db.files.get(fileId) ?: return@useLibrary val clusterId = file.bodyClusterId ?: return@useLibrary + val now = System.currentTimeMillis() db.readingStates.upsert( ReadingStateRecord( id = "state-$clusterId", @@ -138,9 +143,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP progress = position.itemIndex.toDouble(), formatHintsJson = position.toFormatHintsJson(), ), - updatedAt = System.currentTimeMillis(), + updatedAt = now, ), ) + db.files.touchLastReadAt(fileId, now) + } +} + +actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + db.files.updateReadingStatus(fileId, status) } } @@ -249,6 +261,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit { actual fun libraryLogPath(): String? = libraryLogFile().absolutePath +actual fun formatLibraryLastReadTime(millis: Long): String = + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis)) + private fun openLibraryDatabase(): H2LibraryDatabase { val libraryDir = File(System.getProperty("user.home"), ".toread/library") val dbDir = File(libraryDir, "db") @@ -290,6 +305,8 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem = sizeBytes = sizeBytes, storageUri = storageUri, lastSeenAt = lastSeenAt, + readingStatus = readingStatus, + lastReadAt = lastReadAt, ) private fun libraryLogFile(): File = 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 1d1fef2..aef713b 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -3,6 +3,7 @@ package net.sergeych.toread import androidx.compose.ui.graphics.ImageBitmap import kotlinx.browser.window import net.sergeych.toread.fb2.Fb2Binary +import net.sergeych.toread.storage.BookReadingStatus import org.w3c.dom.events.Event actual fun loadDefaultBookBytes(): ByteArray? = null @@ -39,6 +40,8 @@ actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit +actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false + actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras() actual suspend fun loadActiveReadingFileId(): String? = null @@ -62,3 +65,29 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit { } actual fun libraryLogPath(): String? = null + +actual fun formatLibraryLastReadTime(millis: Long): String { + val totalMinutes = millis / 60_000L + val minute = (totalMinutes % 60).toString().padStart(2, '0') + val totalHours = totalMinutes / 60 + val hour = (totalHours % 24).toString().padStart(2, '0') + val (yearValue, monthValue, dayValue) = civilDateFromEpochDay(totalHours / 24) + val year = yearValue.toString() + val month = monthValue.toString().padStart(2, '0') + val day = dayValue.toString().padStart(2, '0') + return "$year-$month-$day $hour:$minute" +} + +private fun civilDateFromEpochDay(epochDay: Long): Triple { + val shiftedDay = epochDay + 719_468 + val era = shiftedDay / 146_097 + val dayOfEra = shiftedDay - era * 146_097 + val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + var year = (yearOfEra + era * 400).toInt() + val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + val monthPart = (5 * dayOfYear + 2) / 153 + val day = (dayOfYear - (153 * monthPart + 2) / 5 + 1).toInt() + val month = (monthPart + if (monthPart < 10) 3 else -9).toInt() + if (month <= 2) year += 1 + return Triple(year, month, day) +} 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 c5d5f88..39fac47 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt @@ -15,6 +15,13 @@ enum class BookImportPolicy { STORE_BLOB, } +enum class BookReadingStatus { + NEW, + READING, + READ, + NOT_INTERESTED, +} + data class BookRecord( val id: String, val title: String? = null, @@ -97,6 +104,8 @@ data class BookFileRecord( val contentObjectId: String? = null, val lastModifiedMillis: Long? = null, val lastSeenAt: Long? = null, + val readingStatus: BookReadingStatus = BookReadingStatus.NEW, + val lastReadAt: Long? = null, val createdAt: Long, val updatedAt: Long, ) @@ -113,6 +122,8 @@ data class LibraryFileRecord( val originalFilename: String? = null, val storageUri: String? = null, val lastSeenAt: Long? = null, + val readingStatus: BookReadingStatus = BookReadingStatus.NEW, + val lastReadAt: Long? = null, ) data class ContentAnchor( @@ -186,6 +197,8 @@ interface BookFileRepository { fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List fun list(limit: Int = 500, offset: Int = 0): List fun listForBook(bookId: String): List + fun updateReadingStatus(id: String, status: BookReadingStatus): 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 741758c..fb31c22 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 @@ -8,6 +8,7 @@ import net.sergeych.toread.storage.BookCoverRecord import net.sergeych.toread.storage.BookFileRecord import net.sergeych.toread.storage.BookFileRepository import net.sergeych.toread.storage.BookFileStorageKind +import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.BookRecord import net.sergeych.toread.storage.BookRepository import net.sergeych.toread.storage.BookmarkRecord @@ -208,6 +209,8 @@ private fun migrate(connection: Connection) { 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, FOREIGN KEY (book_id) REFERENCES books(id), @@ -216,9 +219,12 @@ private fun migrate(connection: Connection) { ) """.trimIndent() ) + 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") 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_updated_at", "CREATE INDEX IF NOT EXISTS idx_book_files_updated_at ON book_files(updated_at)") + connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)") statement.execute( """ CREATE TABLE IF NOT EXISTS reading_states ( @@ -241,6 +247,20 @@ private fun migrate(connection: Connection) { """.trimIndent() ) connection.createIndexIfMissing("idx_reading_states_cluster", "CREATE INDEX IF NOT EXISTS idx_reading_states_cluster ON reading_states(body_cluster_id)") + statement.execute( + """ + UPDATE book_files + SET reading_status = 'READING', + last_read_at = ( + SELECT MAX(updated_at) + FROM reading_states + WHERE reading_states.body_cluster_id = book_files.body_cluster_id + ) + WHERE reading_status = 'NEW' + AND last_read_at IS NULL + AND body_cluster_id IN (SELECT body_cluster_id FROM reading_states) + """.trimIndent() + ) statement.execute( """ CREATE TABLE IF NOT EXISTS bookmarks ( @@ -452,9 +472,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF MERGE INTO book_files( id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis, - last_seen_at, created_at, updated_at + last_seen_at, reading_status, last_read_at, created_at, updated_at ) - KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent() ).use { statement -> statement.setString(1, file.id) @@ -471,8 +491,10 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF statement.setStringOrNull(12, file.contentObjectId) statement.setLongOrNull(13, file.lastModifiedMillis) statement.setLongOrNull(14, file.lastSeenAt) - statement.setLong(15, file.createdAt) - statement.setLong(16, file.updatedAt) + statement.setString(15, file.readingStatus.name) + statement.setLongOrNull(16, file.lastReadAt) + statement.setLong(17, file.createdAt) + statement.setLong(18, file.updatedAt) statement.executeUpdate() } } @@ -495,7 +517,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF 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.last_seen_at AS last_seen_at, + f.reading_status AS reading_status, + f.last_read_at AS last_read_at FROM book_files f LEFT JOIN books b ON b.id = f.book_id WHERE f.id = ? @@ -513,6 +537,24 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF override fun listLibraryFiles(limit: Int, offset: Int): List { return connection.prepareStatement( """ + WITH visible_files AS ( + SELECT + f.*, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(f.body_cluster_id, f.body_id, f.book_id, f.raw_sha256, f.id) + ORDER BY + CASE + WHEN LOWER(COALESCE(f.format, '')) = 'fb2.zip' + OR LOWER(COALESCE(f.original_filename, '')) LIKE '%.fb2.zip' + THEN 0 ELSE 1 + END, + CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END, + f.last_read_at DESC NULLS LAST, + f.updated_at DESC, + f.id + ) AS duplicate_rank + FROM book_files f + ) SELECT f.id AS file_id, f.book_id AS book_id, @@ -524,10 +566,17 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF 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 - FROM book_files f + f.last_seen_at AS last_seen_at, + f.reading_status AS reading_status, + f.last_read_at AS last_read_at + FROM visible_files f LEFT JOIN books b ON b.id = f.book_id - ORDER BY f.updated_at DESC + WHERE f.duplicate_rank = 1 + ORDER BY + CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END, + CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST, + LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)), + f.id LIMIT ? OFFSET ? """.trimIndent() ).use { statement -> @@ -551,6 +600,50 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF } } + override fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean { + val now = System.currentTimeMillis() + return connection.prepareStatement( + """ + UPDATE book_files + SET reading_status = ?, + last_read_at = CASE + WHEN ? IN ('READING', 'READ') THEN ? + WHEN ? = 'NEW' THEN NULL + ELSE last_read_at + END, + 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.setString(1, status.name) + statement.setString(2, status.name) + statement.setLong(3, now) + statement.setString(4, status.name) + statement.setLong(5, now) + statement.setString(6, id) + statement.setString(7, id) + statement.executeUpdate() > 0 + } + } + + override fun touchLastReadAt(id: String, lastReadAt: Long): Boolean { + return connection.prepareStatement( + """ + UPDATE book_files + SET last_read_at = ?, 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.setLong(1, lastReadAt) + statement.setLong(2, lastReadAt) + statement.setString(3, id) + statement.setString(4, id) + statement.executeUpdate() > 0 + } + } + override fun delete(id: String): Boolean = connection.deleteById("book_files", id) } @@ -718,6 +811,8 @@ private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord( originalFilename = getString("original_filename"), storageUri = getString("storage_uri"), lastSeenAt = getLongOrNull("last_seen_at"), + readingStatus = getReadingStatus("reading_status"), + lastReadAt = getLongOrNull("last_read_at"), ) private fun ResultSet.toBookBodyRecord() = BookBodyRecord( @@ -751,6 +846,8 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord( contentObjectId = getString("content_object_id"), lastModifiedMillis = getLongOrNull("last_modified_millis"), lastSeenAt = getLongOrNull("last_seen_at"), + readingStatus = getReadingStatus("reading_status"), + lastReadAt = getLongOrNull("last_read_at"), createdAt = getLong("created_at"), updatedAt = getLong("updated_at"), ) @@ -857,3 +954,6 @@ private fun ResultSet.getDoubleOrNull(column: String): Double? { val value = getDouble(column) return if (wasNull()) null else value } + +private fun ResultSet.getReadingStatus(column: String): BookReadingStatus = + runCatching { BookReadingStatus.valueOf(getString(column)) }.getOrDefault(BookReadingStatus.NEW) 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 117b0db..409907a 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 @@ -4,6 +4,7 @@ import net.sergeych.toread.storage.BodyClusterRecord import net.sergeych.toread.storage.BookBodyRecord import net.sergeych.toread.storage.BookFileRecord import net.sergeych.toread.storage.BookFileStorageKind +import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.BookRecord import net.sergeych.toread.storage.BookmarkRecord import net.sergeych.toread.storage.ContentAnchor @@ -119,6 +120,9 @@ class H2LibraryDatabaseTest { assertEquals("Jane Example", db.files.listLibraryFiles().single().authors.single()) 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(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) assertEquals("cluster-1", db.clusters.get("cluster-1")?.id) assertEquals(BookFileStorageKind.EXTERNAL_URI, db.files.get("file-1")?.storageKind) @@ -152,6 +156,109 @@ class H2LibraryDatabaseTest { db.close() } + @Test + fun listsReadingBooksFirstThenSortsByTitle() { + val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstThenSortsByTitle") + val now = 1_700_000_000_000L + + db.transaction { + listOf( + Triple("book-beta", "Beta", BookReadingStatus.NEW), + Triple("book-alpha", "Alpha", BookReadingStatus.READ), + Triple("book-gamma", "Gamma", BookReadingStatus.READING), + Triple("book-aardvark", "Aardvark", BookReadingStatus.READING), + ).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, + lastReadAt = if (title == "Aardvark") now + 500 else now + 100, + createdAt = now + index, + updatedAt = now + index, + ) + ) + } + } + + assertEquals( + listOf("Aardvark", "Gamma", "Alpha", "Beta"), + db.files.listLibraryFiles().map { it.title }, + ) + db.close() + } + + @Test + fun hidesDuplicateLibraryFilesAndPrefersZip() { + val db = H2LibraryDatabase.openMemory("hidesDuplicateLibraryFilesAndPrefersZip") + val now = 1_700_000_000_000L + + db.transaction { + bodies.upsert( + BookBodyRecord( + id = "body-dupe", + exactTextHash = "same-text", + canonicalizationVersion = 1, + createdAt = now, + ) + ) + clusters.upsert( + BodyClusterRecord( + id = "cluster-dupe", + representativeBodyId = "body-dupe", + createdAt = now, + ) + ) + books.upsert(BookRecord(id = "book-fb2", title = "Same Book", createdAt = now, updatedAt = now)) + books.upsert(BookRecord(id = "book-zip", title = "Same Book", createdAt = now, updatedAt = now + 1)) + files.upsert( + BookFileRecord( + id = "file-fb2", + bookId = "book-fb2", + bodyId = "body-dupe", + bodyClusterId = "cluster-dupe", + rawSha256 = "raw-fb2", + format = "fb2", + originalFilename = "same.fb2", + storageKind = BookFileStorageKind.EXTERNAL_URI, + createdAt = now, + updatedAt = now + 10, + ) + ) + files.upsert( + BookFileRecord( + id = "file-zip", + bookId = "book-zip", + bodyId = "body-dupe", + bodyClusterId = "cluster-dupe", + rawSha256 = "raw-zip", + format = "fb2.zip", + originalFilename = "same.fb2.zip", + storageKind = BookFileStorageKind.EXTERNAL_URI, + createdAt = now, + updatedAt = now, + ) + ) + } + + assertEquals(listOf("file-zip"), db.files.listLibraryFiles().map { it.fileId }) + 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) + db.close() + } + @Test fun opensDatabaseWithExistingUppercaseIndex() { val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()