improved UI

This commit is contained in:
Sergey Chernov 2026-05-17 01:56:33 +03:00
parent 02a40c2589
commit d8b39057d5
8 changed files with 460 additions and 16 deletions

View File

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

View File

@ -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<String, LibraryCover?>,
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

View File

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

View File

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

View File

@ -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<Int, Int, Int> {
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)
}

View File

@ -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<LibraryFileRecord>
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord>
fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean
fun touchLastReadAt(id: String, lastReadAt: Long): Boolean
fun delete(id: String): Boolean
}

View File

@ -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<LibraryFileRecord> {
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)

View File

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