added library database
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user