improved UI

This commit is contained in:
sergeych committed 2026-05-17 01:56:33 +03:00
1 parent 02a40c2589
commit d8b39057d5
8 files changed
+460 -16

No files matched your search

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