added library database

This commit is contained in:
sergeych committed 2026-05-17 01:26:14 +03:00
1 parent 8f8b466e89
commit 02a40c2589
10 files changed
+383 -100

No files matched your search

@@ -19,7 +19,9 @@ data class BookRecord(
val id: String,
val title: String? = null,
val subtitle: String? = null,
val authors: List<String> = emptyList(),
val language: String? = null,
val date: String? = null,
val description: String? = null,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
@@ -33,7 +35,9 @@ data class BookRecord(
return id == other.id &&
title == other.title &&
subtitle == other.subtitle &&
authors == other.authors &&
language == other.language &&
date == other.date &&
description == other.description &&
coverImage.contentEquals(other.coverImage) &&
coverImageMimeType == other.coverImageMimeType &&
@@ -45,7 +49,9 @@ data class BookRecord(
var result = id.hashCode()
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + (subtitle?.hashCode() ?: 0)
result = 31 * result + authors.hashCode()
result = 31 * result + (language?.hashCode() ?: 0)
result = 31 * result + (date?.hashCode() ?: 0)
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + (coverImage?.contentHashCode() ?: 0)
result = 31 * result + (coverImageMimeType?.hashCode() ?: 0)
@@ -55,6 +61,11 @@ data class BookRecord(
}
}
data class BookCoverRecord(
val image: ByteArray,
val mimeType: String?,
)
data class BookBodyRecord(
val id: String,
val exactTextHash: String,
@@ -90,6 +101,20 @@ data class BookFileRecord(
val updatedAt: Long,
)
data class LibraryFileRecord(
val fileId: String,
val bookId: String? = null,
val title: String? = null,
val authors: List<String> = emptyList(),
val language: String? = null,
val date: String? = null,
val format: String? = null,
val sizeBytes: Long? = null,
val originalFilename: String? = null,
val storageUri: String? = null,
val lastSeenAt: Long? = null,
)
data class ContentAnchor(
val version: Int = 1,
val canonicalCharOffset: Long? = null,
@@ -136,6 +161,7 @@ data class NoteRecord(
interface BookRepository {
fun upsert(book: BookRecord)
fun get(id: String): BookRecord?
fun getCover(id: String): BookCoverRecord?
fun list(limit: Int = 100, offset: Int = 0): List<BookRecord>
fun delete(id: String): Boolean
}
@@ -155,7 +181,9 @@ interface BodyClusterRepository {
interface BookFileRepository {
fun upsert(file: BookFileRecord)
fun get(id: String): BookFileRecord?
fun getLibraryFile(id: String): LibraryFileRecord?
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
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 delete(id: String): Boolean
@@ -4,6 +4,7 @@ import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BodyClusterRepository
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookBodyRepository
import net.sergeych.toread.storage.BookCoverRecord
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileRepository
import net.sergeych.toread.storage.BookFileStorageKind
@@ -13,6 +14,7 @@ import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.BookmarkRepository
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryDatabase
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.NoteRecord
import net.sergeych.toread.storage.NoteRepository
import net.sergeych.toread.storage.ReadingStateRecord
@@ -22,9 +24,11 @@ import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.SQLException
import java.util.concurrent.ConcurrentHashMap
class H2LibraryDatabase private constructor(
private val connection: Connection,
migrate: Boolean,
) : LibraryDatabase {
override val books: BookRepository = JdbcBookRepository(connection)
override val bodies: BookBodyRepository = JdbcBookBodyRepository(connection)
@@ -35,7 +39,10 @@ class H2LibraryDatabase private constructor(
override val notes: NoteRepository = JdbcNoteRepository(connection)
init {
migrate(connection)
connection.createStatement().use { statement ->
statement.execute("SET LOCK_TIMEOUT 10000")
}
if (migrate) migrate(connection)
}
override fun <T> transaction(block: LibraryDatabase.() -> T): T {
@@ -88,6 +95,9 @@ class H2LibraryDatabase private constructor(
}
companion object {
private val openLocks = ConcurrentHashMap<String, Any>()
private val migratedUrls = ConcurrentHashMap.newKeySet<String>()
fun openFile(path: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
return openUrl("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", user, password)
}
@@ -98,7 +108,13 @@ class H2LibraryDatabase private constructor(
fun openUrl(url: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
Class.forName("org.h2.Driver")
return H2LibraryDatabase(DriverManager.getConnection(url, user, password))
val lock = openLocks.computeIfAbsent(url) { Any() }
synchronized(lock) {
val shouldMigrate = !migratedUrls.contains(url)
val database = H2LibraryDatabase(DriverManager.getConnection(url, user, password), shouldMigrate)
if (shouldMigrate) migratedUrls += url
return database
}
}
}
}
@@ -130,7 +146,9 @@ private fun migrate(connection: Connection) {
id VARCHAR PRIMARY KEY,
title VARCHAR,
subtitle VARCHAR,
authors CLOB,
language VARCHAR,
published_date VARCHAR,
description CLOB,
cover_image BLOB,
cover_image_mime_type VARCHAR,
@@ -139,6 +157,8 @@ private fun migrate(connection: Connection) {
)
""".trimIndent()
)
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS authors CLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS published_date VARCHAR")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image BLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR")
statement.execute(
@@ -198,6 +218,7 @@ private fun migrate(connection: Connection) {
)
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)")
statement.execute(
"""
CREATE TABLE IF NOT EXISTS reading_states (
@@ -312,20 +333,23 @@ private class JdbcBookRepository(private val connection: Connection) : BookRepos
connection.prepareStatement(
"""
MERGE INTO books(
id, title, subtitle, language, description, cover_image, cover_image_mime_type, created_at, updated_at
id, title, subtitle, authors, language, published_date, description,
cover_image, cover_image_mime_type, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, book.id)
statement.setStringOrNull(2, book.title)
statement.setStringOrNull(3, book.subtitle)
statement.setStringOrNull(4, book.language)
statement.setStringOrNull(5, book.description)
statement.setBytesOrNull(6, book.coverImage)
statement.setStringOrNull(7, book.coverImageMimeType)
statement.setLong(8, book.createdAt)
statement.setLong(9, book.updatedAt)
statement.setStringOrNull(4, book.authors.toDbList())
statement.setStringOrNull(5, book.language)
statement.setStringOrNull(6, book.date)
statement.setStringOrNull(7, book.description)
statement.setBytesOrNull(8, book.coverImage)
statement.setStringOrNull(9, book.coverImageMimeType)
statement.setLong(10, book.createdAt)
statement.setLong(11, book.updatedAt)
statement.executeUpdate()
}
}
@@ -334,6 +358,17 @@ private class JdbcBookRepository(private val connection: Connection) : BookRepos
return connection.selectOne("SELECT * FROM books WHERE id = ?", id) { it.toBookRecord() }
}
override fun getCover(id: String): BookCoverRecord? {
return connection.prepareStatement("SELECT cover_image, cover_image_mime_type FROM books WHERE id = ?").use { statement ->
statement.setString(1, id)
statement.executeQuery().use { resultSet ->
if (!resultSet.next()) return@use null
val image = resultSet.getBytes("cover_image") ?: return@use null
BookCoverRecord(image = image, mimeType = resultSet.getString("cover_image_mime_type"))
}
}
}
override fun list(limit: Int, offset: Int): List<BookRecord> {
return connection.prepareStatement("SELECT * FROM books ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit)
@@ -446,12 +481,62 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
return connection.selectOne("SELECT * FROM book_files WHERE id = ?", id) { it.toBookFileRecord() }
}
override fun getLibraryFile(id: String): LibraryFileRecord? {
return connection.selectOne(
"""
SELECT
f.id AS file_id,
f.book_id AS book_id,
b.title AS book_title,
b.authors AS book_authors,
b.language AS book_language,
b.published_date AS book_date,
f.format AS file_format,
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
LEFT JOIN books b ON b.id = f.book_id
WHERE f.id = ?
""".trimIndent(),
id,
) { it.toLibraryFileRecord() }
}
override fun findByRawSha256(rawSha256: String): List<BookFileRecord> {
return connection.selectMany("SELECT * FROM book_files WHERE raw_sha256 = ? ORDER BY created_at", rawSha256) {
it.toBookFileRecord()
}
}
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
return connection.prepareStatement(
"""
SELECT
f.id AS file_id,
f.book_id AS book_id,
b.title AS book_title,
b.authors AS book_authors,
b.language AS book_language,
b.published_date AS book_date,
f.format AS file_format,
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
LEFT JOIN books b ON b.id = f.book_id
ORDER BY f.updated_at DESC
LIMIT ? OFFSET ?
""".trimIndent()
).use { statement ->
statement.setInt(1, limit)
statement.setInt(2, offset)
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toLibraryFileRecord() } }
}
}
override fun list(limit: Int, offset: Int): List<BookFileRecord> {
return connection.prepareStatement("SELECT * FROM book_files ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit)
@@ -611,7 +696,9 @@ private fun ResultSet.toBookRecord() = BookRecord(
id = getString("id"),
title = getString("title"),
subtitle = getString("subtitle"),
authors = getString("authors").fromDbList(),
language = getString("language"),
date = getString("published_date"),
description = getString("description"),
coverImage = getBytes("cover_image"),
coverImageMimeType = getString("cover_image_mime_type"),
@@ -619,6 +706,20 @@ private fun ResultSet.toBookRecord() = BookRecord(
updatedAt = getLong("updated_at"),
)
private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
fileId = getString("file_id"),
bookId = getString("book_id"),
title = getString("book_title"),
authors = getString("book_authors").fromDbList(),
language = getString("book_language"),
date = getString("book_date"),
format = getString("file_format"),
sizeBytes = getLongOrNull("size_bytes"),
originalFilename = getString("original_filename"),
storageUri = getString("storage_uri"),
lastSeenAt = getLongOrNull("last_seen_at"),
)
private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
id = getString("id"),
exactTextHash = getString("exact_text_hash"),
@@ -736,6 +837,12 @@ private fun PreparedStatement.setBytesOrNull(index: Int, value: ByteArray?) {
if (value == null) setNull(index, java.sql.Types.BLOB) else setBytes(index, value)
}
private fun List<String>.toDbList(): String? =
map { it.trim() }.filter { it.isNotBlank() }.joinToString("\n").takeIf { it.isNotBlank() }
private fun String?.fromDbList(): List<String> =
orEmpty().lineSequence().map { it.trim() }.filter { it.isNotBlank() }.toList()
private fun ResultSet.getIntOrNull(column: String): Int? {
val value = getInt(column)
return if (wasNull()) null else value
@@ -100,7 +100,9 @@ class LibraryScanner(
BookRecord(
id = bookId,
title = book.title.ifBlank { displayName.substringBeforeLast('.') },
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
language = book.language,
date = book.date,
description = book.annotation,
coverImage = cover?.bytes,
coverImageMimeType = cover?.mimeType,
@@ -27,7 +27,11 @@ class H2LibraryDatabaseTest {
BookRecord(
id = "book-1",
title = "Example",
authors = listOf("Jane Example"),
language = "en",
date = "2024",
coverImage = byteArrayOf(1, 2, 3),
coverImageMimeType = "image/jpeg",
createdAt = now,
updatedAt = now,
)
@@ -112,6 +116,9 @@ class H2LibraryDatabaseTest {
}
assertEquals("Example", db.books.get("book-1")?.title)
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("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)