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.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import net.sergeych.toread.fb2.Fb2Binary import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ReadingStateRecord 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.LibraryScanner
import net.sergeych.toread.storage.jdbc.LibraryScanSummary import net.sergeych.toread.storage.jdbc.LibraryScanSummary
import java.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -154,6 +158,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary val clusterId = file.bodyClusterId ?: return@useLibrary
val now = System.currentTimeMillis()
db.readingStates.upsert( db.readingStates.upsert(
ReadingStateRecord( ReadingStateRecord(
id = "state-$clusterId", id = "state-$clusterId",
@ -163,9 +168,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
progress = position.itemIndex.toDouble(), progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(), 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 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( private data class AndroidLibraryDocument(
val uri: Uri, val uri: Uri,
val name: String, val name: String,
@ -432,6 +447,8 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
sizeBytes = sizeBytes, sizeBytes = sizeBytes,
storageUri = storageUri, storageUri = storageUri,
lastSeenAt = lastSeenAt, lastSeenAt = lastSeenAt,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
) )
private fun libraryLogFile(): File = 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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Add 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.FolderOpen
import androidx.compose.material.icons.filled.Info 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.Palette
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Scanner 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.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan import net.sergeych.toread.fb2.Fb2TextSpan
import net.sergeych.toread.fb2.Fb2TextStyle import net.sergeych.toread.fb2.Fb2TextStyle
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.text.HyphenationRegistry import net.sergeych.toread.text.HyphenationRegistry
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ -210,7 +214,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
) )
}, },
onBack = { onBack = {
state = AppState.Library(current.libraryItems, current.scanPath, current.message) state = AppState.Library(emptyList(), current.scanPath, current.message)
}, },
) )
is AppState.BookInfo -> BookInfoScreen( is AppState.BookInfo -> BookInfoScreen(
@ -317,7 +321,20 @@ private fun LibraryScreen(
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.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( LibraryRow(
item = item, item = item,
coverCache = coverCache, coverCache = coverCache,
@ -329,6 +346,7 @@ private fun LibraryScreen(
val next = runCatching { val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.") val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title) val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
saveActiveReadingFileId(item.fileId) saveActiveReadingFileId(item.fileId)
AppState.Reader( AppState.Reader(
fileId = item.fileId, 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 = { onDelete = {
scope.launch { scope.launch {
busy = true 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 @Composable
private fun LibraryRow( private fun LibraryRow(
item: LibraryItem, item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>, coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean, enabled: Boolean,
onOpen: () -> Unit, onOpen: () -> Unit,
onMarkAsRead: () -> Unit,
onMarkAsUnread: () -> Unit,
onNotInterested: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
var menuOpen by remember { mutableStateOf(false) }
Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) { Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -563,8 +642,53 @@ private fun LibraryRow(
maxLines = 1, maxLines = 1,
) )
} }
IconButton(onClick = onDelete, enabled = enabled) { Box {
Icon(Icons.Filled.Delete, contentDescription = "Remove ${item.title}") 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 listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var restored by remember(fileId) { mutableStateOf(false) } var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
LaunchedEffect(fileId) {
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
}
LaunchedEffect(fileId) { LaunchedEffect(fileId) {
loadLibraryReadingPosition(fileId)?.let { position -> loadLibraryReadingPosition(fileId)?.let { position ->
@ -639,6 +768,19 @@ private fun BookView(
.collect { saveLibraryReadingPosition(fileId, it) } .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( Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = { topBar = {
@ -1399,12 +1541,24 @@ private val ThemeMode.displayName: String
private fun LibraryItem.libraryMetadataLine(): String = private fun LibraryItem.libraryMetadataLine(): String =
listOfNotNull( listOfNotNull(
readingStatus.displayLabel,
lastReadAt?.formatLastRead(),
date?.yearOrRaw(), date?.yearOrRaw(),
language?.uppercase(), language?.uppercase(),
format?.uppercase(), format?.uppercase(),
sizeBytes?.formatBytes(), sizeBytes?.formatBytes(),
).joinToString(" | ").ifBlank { "No metadata" } ).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 = private fun String.yearOrRaw(): String =
Regex("""\d{4}""").find(this)?.value ?: this 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.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.storage.BookReadingStatus
data class LibraryItem( data class LibraryItem(
val fileId: String, val fileId: String,
@ -14,6 +15,8 @@ data class LibraryItem(
val sizeBytes: Long?, val sizeBytes: Long?,
val storageUri: String?, val storageUri: String?,
val lastSeenAt: Long?, val lastSeenAt: Long?,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
val coverImage: ByteArray? = null, val coverImage: ByteArray? = null,
val coverImageMimeType: String? = 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 saveLibraryReadingPosition(fileId: String, position: ReadingPosition)
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String? expect suspend fun loadActiveReadingFileId(): String?
@ -114,6 +119,8 @@ expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit
expect fun libraryLogPath(): String? expect fun libraryLogPath(): String?
expect fun formatLibraryLastReadTime(millis: Long): String
internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? { internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull() val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
return image?.let(::binaryFor) 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.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import net.sergeych.toread.fb2.Fb2Binary 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.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ReadingStateRecord import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.io.File 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.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser import javax.swing.JFileChooser
@ -129,6 +133,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary val clusterId = file.bodyClusterId ?: return@useLibrary
val now = System.currentTimeMillis()
db.readingStates.upsert( db.readingStates.upsert(
ReadingStateRecord( ReadingStateRecord(
id = "state-$clusterId", id = "state-$clusterId",
@ -138,9 +143,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
progress = position.itemIndex.toDouble(), progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(), 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 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 { private fun openLibraryDatabase(): H2LibraryDatabase {
val libraryDir = File(System.getProperty("user.home"), ".toread/library") val libraryDir = File(System.getProperty("user.home"), ".toread/library")
val dbDir = File(libraryDir, "db") val dbDir = File(libraryDir, "db")
@ -290,6 +305,8 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
sizeBytes = sizeBytes, sizeBytes = sizeBytes,
storageUri = storageUri, storageUri = storageUri,
lastSeenAt = lastSeenAt, lastSeenAt = lastSeenAt,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
) )
private fun libraryLogFile(): File = private fun libraryLogFile(): File =

View File

@ -3,6 +3,7 @@ package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.browser.window import kotlinx.browser.window
import net.sergeych.toread.fb2.Fb2Binary import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus
import org.w3c.dom.events.Event import org.w3c.dom.events.Event
actual fun loadDefaultBookBytes(): ByteArray? = null 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 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 loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null actual suspend fun loadActiveReadingFileId(): String? = null
@ -62,3 +65,29 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
} }
actual fun libraryLogPath(): String? = null 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, STORE_BLOB,
} }
enum class BookReadingStatus {
NEW,
READING,
READ,
NOT_INTERESTED,
}
data class BookRecord( data class BookRecord(
val id: String, val id: String,
val title: String? = null, val title: String? = null,
@ -97,6 +104,8 @@ data class BookFileRecord(
val contentObjectId: String? = null, val contentObjectId: String? = null,
val lastModifiedMillis: Long? = null, val lastModifiedMillis: Long? = null,
val lastSeenAt: Long? = null, val lastSeenAt: Long? = null,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
val createdAt: Long, val createdAt: Long,
val updatedAt: Long, val updatedAt: Long,
) )
@ -113,6 +122,8 @@ data class LibraryFileRecord(
val originalFilename: String? = null, val originalFilename: String? = null,
val storageUri: String? = null, val storageUri: String? = null,
val lastSeenAt: Long? = null, val lastSeenAt: Long? = null,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
) )
data class ContentAnchor( data class ContentAnchor(
@ -186,6 +197,8 @@ interface BookFileRepository {
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord> fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord> fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): 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 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.BookFileRecord
import net.sergeych.toread.storage.BookFileRepository import net.sergeych.toread.storage.BookFileRepository
import net.sergeych.toread.storage.BookFileStorageKind import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.BookRecord import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookRepository import net.sergeych.toread.storage.BookRepository
import net.sergeych.toread.storage.BookmarkRecord import net.sergeych.toread.storage.BookmarkRecord
@ -208,6 +209,8 @@ private fun migrate(connection: Connection) {
content_object_id VARCHAR, content_object_id VARCHAR,
last_modified_millis BIGINT, last_modified_millis BIGINT,
last_seen_at BIGINT, last_seen_at BIGINT,
reading_status VARCHAR NOT NULL DEFAULT 'NEW',
last_read_at BIGINT,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL, updated_at BIGINT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books(id), FOREIGN KEY (book_id) REFERENCES books(id),
@ -216,9 +219,12 @@ private fun migrate(connection: Connection) {
) )
""".trimIndent() """.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_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_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_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( statement.execute(
""" """
CREATE TABLE IF NOT EXISTS reading_states ( CREATE TABLE IF NOT EXISTS reading_states (
@ -241,6 +247,20 @@ private fun migrate(connection: Connection) {
""".trimIndent() """.trimIndent()
) )
connection.createIndexIfMissing("idx_reading_states_cluster", "CREATE INDEX IF NOT EXISTS idx_reading_states_cluster ON reading_states(body_cluster_id)") 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( statement.execute(
""" """
CREATE TABLE IF NOT EXISTS bookmarks ( CREATE TABLE IF NOT EXISTS bookmarks (
@ -452,9 +472,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
MERGE INTO book_files( MERGE INTO book_files(
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes, 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, 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() """.trimIndent()
).use { statement -> ).use { statement ->
statement.setString(1, file.id) statement.setString(1, file.id)
@ -471,8 +491,10 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
statement.setStringOrNull(12, file.contentObjectId) statement.setStringOrNull(12, file.contentObjectId)
statement.setLongOrNull(13, file.lastModifiedMillis) statement.setLongOrNull(13, file.lastModifiedMillis)
statement.setLongOrNull(14, file.lastSeenAt) statement.setLongOrNull(14, file.lastSeenAt)
statement.setLong(15, file.createdAt) statement.setString(15, file.readingStatus.name)
statement.setLong(16, file.updatedAt) statement.setLongOrNull(16, file.lastReadAt)
statement.setLong(17, file.createdAt)
statement.setLong(18, file.updatedAt)
statement.executeUpdate() statement.executeUpdate()
} }
} }
@ -495,7 +517,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.size_bytes AS size_bytes, f.size_bytes AS size_bytes,
f.original_filename AS original_filename, f.original_filename AS original_filename,
f.storage_uri AS storage_uri, 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 FROM book_files f
LEFT JOIN books b ON b.id = f.book_id LEFT JOIN books b ON b.id = f.book_id
WHERE f.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> { override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
return connection.prepareStatement( 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 SELECT
f.id AS file_id, f.id AS file_id,
f.book_id AS book_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.size_bytes AS size_bytes,
f.original_filename AS original_filename, f.original_filename AS original_filename,
f.storage_uri AS storage_uri, f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at f.last_seen_at AS last_seen_at,
FROM book_files f 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 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 ? LIMIT ? OFFSET ?
""".trimIndent() """.trimIndent()
).use { statement -> ).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) override fun delete(id: String): Boolean = connection.deleteById("book_files", id)
} }
@ -718,6 +811,8 @@ private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
originalFilename = getString("original_filename"), originalFilename = getString("original_filename"),
storageUri = getString("storage_uri"), storageUri = getString("storage_uri"),
lastSeenAt = getLongOrNull("last_seen_at"), lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"),
) )
private fun ResultSet.toBookBodyRecord() = BookBodyRecord( private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
@ -751,6 +846,8 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
contentObjectId = getString("content_object_id"), contentObjectId = getString("content_object_id"),
lastModifiedMillis = getLongOrNull("last_modified_millis"), lastModifiedMillis = getLongOrNull("last_modified_millis"),
lastSeenAt = getLongOrNull("last_seen_at"), lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"),
createdAt = getLong("created_at"), createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"), updatedAt = getLong("updated_at"),
) )
@ -857,3 +954,6 @@ private fun ResultSet.getDoubleOrNull(column: String): Double? {
val value = getDouble(column) val value = getDouble(column)
return if (wasNull()) null else value 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.BookBodyRecord
import net.sergeych.toread.storage.BookFileRecord import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileStorageKind import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.BookRecord import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookmarkRecord import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.ContentAnchor
@ -119,6 +120,9 @@ class H2LibraryDatabaseTest {
assertEquals("Jane Example", db.files.listLibraryFiles().single().authors.single()) assertEquals("Jane Example", db.files.listLibraryFiles().single().authors.single())
assertEquals("2024", db.files.getLibraryFile("file-1")?.date) assertEquals("2024", db.files.getLibraryFile("file-1")?.date)
assertEquals("image/jpeg", db.books.getCover("book-1")?.mimeType) 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("body-1", db.bodies.findByExactTextHash("text-sha", 1)?.id)
assertEquals("cluster-1", db.clusters.get("cluster-1")?.id) assertEquals("cluster-1", db.clusters.get("cluster-1")?.id)
assertEquals(BookFileStorageKind.EXTERNAL_URI, db.files.get("file-1")?.storageKind) assertEquals(BookFileStorageKind.EXTERNAL_URI, db.files.get("file-1")?.storageKind)
@ -152,6 +156,109 @@ class H2LibraryDatabaseTest {
db.close() 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 @Test
fun opensDatabaseWithExistingUppercaseIndex() { fun opensDatabaseWithExistingUppercaseIndex() {
val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString() val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()