added library database

This commit is contained in:
Sergey Chernov 2026-05-17 01:26:14 +03:00
parent 8f8b466e89
commit 02a40c2589
10 changed files with 383 additions and 100 deletions

View File

@ -13,8 +13,8 @@ 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.fb2.Fb2Format
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
@ -75,32 +75,26 @@ actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = wit
actual suspend fun chooseLibraryScanDirectory(): String? = directoryChooser?.chooseDirectory() actual suspend fun chooseLibraryScanDirectory(): String? = directoryChooser?.chooseDirectory()
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) { actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items") loadLibraryItemsPage(Int.MAX_VALUE, 0)
}
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items page limit=$limit offset=$offset")
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val items = db.files.list().map { file -> db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
val book = file.bookId?.let(db.books::get)
val parsed = file.storageUri?.let { uri ->
runCatching {
readStorageUriBytes(uri)?.let { Fb2Format.parse(it, uri) }
}.getOrNull()
} }
LibraryItem( }
fileId = file.id,
bookId = file.bookId, actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id, openLibraryDatabase().useLibrary { db ->
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(), db.files.getLibraryFile(fileId)?.toLibraryItem()
language = parsed?.language ?: book?.language,
date = parsed?.date,
format = file.format,
sizeBytes = file.sizeBytes,
storageUri = file.storageUri,
lastSeenAt = file.lastSeenAt,
coverImage = book?.coverImage ?: parsed?.libraryCoverBinary()?.imageBytes(),
coverImageMimeType = book?.coverImageMimeType ?: parsed?.libraryCoverBinary()?.contentType,
)
} }
appendLibraryLog("loaded library items count=${items.size}") }
items
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val bookId = db.files.get(fileId)?.bookId ?: return@useLibrary null
db.books.getCover(bookId)?.let { LibraryCover(image = it.image, mimeType = it.mimeType) }
} }
} }
@ -426,6 +420,20 @@ private fun appendLibraryLog(message: String) {
file.appendText("${System.currentTimeMillis()} $message\n") file.appendText("${System.currentTimeMillis()} $message\n")
} }
private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
LibraryItem(
fileId = fileId,
bookId = bookId,
title = title ?: originalFilename ?: fileId,
authors = authors,
language = language,
date = date,
format = format,
sizeBytes = sizeBytes,
storageUri = storageUri,
lastSeenAt = lastSeenAt,
)
private fun libraryLogFile(): File = private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/toread.log") File(appContext.filesDir, "logs/toread.log")

View File

@ -1,6 +1,7 @@
package net.sergeych.toread package net.sergeych.toread
import android.Manifest import android.Manifest
import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
@ -22,7 +23,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
private lateinit var directoryLauncher: ActivityResultLauncher<Uri?> private lateinit var directoryLauncher: ActivityResultLauncher<Uri?>
@ -64,16 +67,53 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
override suspend fun chooseDirectory(): String? { override suspend fun chooseDirectory(): String? {
val result = CompletableDeferred<String?>() val result = CompletableDeferred<String?>()
runOnUiThread { runOnUiThread {
if (pendingDirectoryChoice != null) { if (pendingDirectoryChoice != null || pendingExternalFileAccess != null) {
result.complete(null) result.complete(null)
} else { } else {
pendingDirectoryChoice = result showDirectoryChoice(result)
directoryLauncher.launch(null)
} }
} }
return result.await() return result.await()
} }
private fun showDirectoryChoice(result: CompletableDeferred<String?>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
AlertDialog.Builder(this)
.setTitle("Choose library folder")
.setItems(arrayOf("Downloads", "Other folder...")) { _, which ->
when (which) {
0 -> chooseDownloadsDirectory(result)
else -> launchSystemDirectoryPicker(result)
}
}
.setNegativeButton(android.R.string.cancel) { _, _ -> result.complete(null) }
.setOnCancelListener { result.complete(null) }
.show()
} else {
launchSystemDirectoryPicker(result)
}
}
private fun launchSystemDirectoryPicker(result: CompletableDeferred<String?>) {
pendingDirectoryChoice = result
directoryLauncher.launch(null)
}
private fun chooseDownloadsDirectory(result: CompletableDeferred<String?>) {
if (hasBroadFileAccess()) {
result.complete(downloadsPath())
return
}
val accessResult = CompletableDeferred<Boolean>()
pendingExternalFileAccess = accessResult
lifecycleScope.launch {
val granted = accessResult.await()
result.complete(if (granted) downloadsPath() else null)
}
launchAllFilesAccessSettings()
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
@ -90,16 +130,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
} }
pendingExternalFileAccess = result pendingExternalFileAccess = result
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val appSettings = Intent( launchAllFilesAccessSettings()
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName"),
)
val settingsIntent = if (appSettings.resolveActivity(packageManager) != null) {
appSettings
} else {
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
}
allFilesAccessLauncher.launch(settingsIntent)
} else { } else {
readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
} }
@ -121,6 +152,23 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
} else { } else {
checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
} }
private fun launchAllFilesAccessSettings() {
val appSettings = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName"),
)
val settingsIntent = if (appSettings.resolveActivity(packageManager) != null) {
appSettings
} else {
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
}
allFilesAccessLauncher.launch(settingsIntent)
}
private fun downloadsPath(): String =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: Environment.getExternalStorageDirectory().absolutePath
} }
@Composable @Composable

View File

@ -58,6 +58,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -239,16 +240,42 @@ private fun LibraryScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) } var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) }
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return
loadingPage = true
if (reset) {
items = emptyList()
nextOffset = 0
endReached = false
coverCache.clear()
}
val offset = if (reset) 0 else nextOffset
try {
val page = loadLibraryItemsPage(LibraryPageSize, offset)
items = if (reset) page else items + page
nextOffset = offset + page.size
endReached = page.size < LibraryPageSize
} catch (t: Throwable) {
message = t.message ?: "Could not load library."
endReached = true
} finally {
loadingPage = false
}
}
fun refresh(nextMessage: String? = message) { fun refresh(nextMessage: String? = message) {
scope.launch { message = nextMessage
busy = true scope.launch { loadPage(reset = true) }
try {
onStateChange(loadLibraryState(nextMessage, state.scanPath))
} finally {
busy = false
}
} }
LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) loadPage(reset = true)
} }
Scaffold( Scaffold(
@ -259,7 +286,7 @@ private fun LibraryScreen(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
actions = { actions = {
IconButton(onClick = { refresh() }, enabled = !busy) { IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library") Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
} }
}, },
@ -278,7 +305,11 @@ private fun LibraryScreen(
.background(readerBackground()), .background(readerBackground()),
) { ) {
val wide = maxWidth >= 800.dp val wide = maxWidth >= 800.dp
if (state.items.isEmpty()) { if (items.isEmpty() && loadingPage) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (items.isEmpty()) {
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp)) EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else { } else {
LazyColumn( LazyColumn(
@ -286,9 +317,10 @@ private fun LibraryScreen(
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(state.items, key = { it.fileId }) { item -> items(items, key = { it.fileId }) { item ->
LibraryRow( LibraryRow(
item = item, item = item,
coverCache = coverCache,
enabled = !busy, enabled = !busy,
onOpen = { onOpen = {
scope.launch { scope.launch {
@ -301,12 +333,12 @@ private fun LibraryScreen(
AppState.Reader( AppState.Reader(
fileId = item.fileId, fileId = item.fileId,
book = book, book = book,
libraryItems = state.items, libraryItems = items,
scanPath = state.scanPath, scanPath = state.scanPath,
message = message, message = message,
) )
}.getOrElse { }.getOrElse {
AppState.Library(state.items, state.scanPath, it.message ?: "Could not open book.") AppState.Library(items, state.scanPath, it.message ?: "Could not open book.")
} }
onStateChange(next) onStateChange(next)
} finally { } finally {
@ -319,12 +351,12 @@ private fun LibraryScreen(
busy = true busy = true
try { try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false) val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
onStateChange( message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
loadLibraryState( if (deleted) {
if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}.", items = items.filterNot { it.fileId == item.fileId }
state.scanPath, coverCache.remove(item.fileId)
), nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
) }
} finally { } finally {
busy = false busy = false
} }
@ -332,6 +364,19 @@ private fun LibraryScreen(
}, },
) )
} }
if (!endReached) {
item(key = "load-more") {
LaunchedEffect(nextOffset, items.size) {
if (!loadingPage) loadPage()
}
Box(
modifier = Modifier.fillMaxWidth().padding(18.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
}
}
}
} }
} }
} }
@ -483,6 +528,7 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
@Composable @Composable
private fun LibraryRow( private fun LibraryRow(
item: LibraryItem, item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean, enabled: Boolean,
onOpen: () -> Unit, onOpen: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
@ -496,7 +542,7 @@ private fun LibraryRow(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
LibraryCover(item, modifier = Modifier.width(46.dp).aspectRatio(0.68f)) LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
item.title, item.title,
@ -525,9 +571,19 @@ private fun LibraryRow(
} }
@Composable @Composable
private fun LibraryCover(item: LibraryItem, modifier: Modifier = Modifier.width(54.dp).aspectRatio(0.68f)) { private fun LibraryCover(
val bitmap = remember(item.fileId, item.coverImage) { item: LibraryItem,
item.coverImage?.let(::decodeImageBytes) coverCache: MutableMap<String, LibraryCover?>,
modifier: Modifier = Modifier.width(54.dp).aspectRatio(0.68f),
) {
LaunchedEffect(item.fileId) {
if (!coverCache.containsKey(item.fileId)) {
coverCache[item.fileId] = loadLibraryItemCover(item.fileId)
}
}
val cover = coverCache[item.fileId]
val bitmap = remember(item.fileId, cover?.image) {
cover?.image?.let(::decodeImageBytes)
} }
Box( Box(
modifier = modifier modifier = modifier
@ -1283,7 +1339,7 @@ private suspend fun loadStartupState(): AppState {
} }
} }
val activeFileId = loadActiveReadingFileId() ?: return library val activeFileId = loadActiveReadingFileId() ?: return library
val item = library.items.firstOrNull { it.fileId == activeFileId } val item = loadLibraryItem(activeFileId)
if (item == null) { if (item == null) {
saveActiveReadingFileId(null) saveActiveReadingFileId(null)
return library return library
@ -1306,7 +1362,7 @@ private suspend fun loadStartupState(): AppState {
private suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState = private suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState =
runCatching { runCatching {
AppState.Library( AppState.Library(
items = loadLibraryItems(), items = emptyList(),
scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(), scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(),
message = message, message = message,
) )
@ -1387,6 +1443,8 @@ fun Fb2Binary.imageBytes(): ByteArray = Base64.Default.decode(base64)
const val DefaultBookFileName: String = "Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip" const val DefaultBookFileName: String = "Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip"
private const val LibraryPageSize: Int = 50
expect fun loadDefaultBookBytes(): ByteArray? expect fun loadDefaultBookBytes(): ByteArray?
expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap? expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap?

View File

@ -18,6 +18,11 @@ data class LibraryItem(
val coverImageMimeType: String? = null, val coverImageMimeType: String? = null,
) )
data class LibraryCover(
val image: ByteArray,
val mimeType: String?,
)
data class LibraryScanReport( data class LibraryScanReport(
val scannedFiles: Int, val scannedFiles: Int,
val importedFiles: Int, val importedFiles: Int,
@ -77,6 +82,12 @@ expect suspend fun chooseLibraryScanDirectory(): String?
expect suspend fun loadLibraryItems(): List<LibraryItem> expect suspend fun loadLibraryItems(): List<LibraryItem>
expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem>
expect suspend fun loadLibraryItem(fileId: String): LibraryItem?
expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover?
expect suspend fun scanLibrarySubtree(path: String, onProgress: (LibraryScanProgress) -> Unit): LibraryScanReport expect suspend fun scanLibrarySubtree(path: String, onProgress: (LibraryScanProgress) -> Unit): LibraryScanReport
expect suspend fun openLibraryBook(fileId: String): ByteArray? expect suspend fun openLibraryBook(fileId: String): ByteArray?

View File

@ -3,7 +3,7 @@ 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.fb2.Fb2Format import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.ContentAnchor
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
@ -49,32 +49,26 @@ actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatche
} }
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) { actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items") loadLibraryItemsPage(Int.MAX_VALUE, 0)
}
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items page limit=$limit offset=$offset")
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val items = db.files.list().map { file -> db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
val book = file.bookId?.let(db.books::get)
val parsed = file.storageUri?.let { uri ->
runCatching {
File(uri).takeIf { it.isFile }?.readBytes()?.let { Fb2Format.parse(it, uri) }
}.getOrNull()
} }
LibraryItem( }
fileId = file.id,
bookId = file.bookId, actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id, openLibraryDatabase().useLibrary { db ->
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(), db.files.getLibraryFile(fileId)?.toLibraryItem()
language = parsed?.language ?: book?.language,
date = parsed?.date,
format = file.format,
sizeBytes = file.sizeBytes,
storageUri = file.storageUri,
lastSeenAt = file.lastSeenAt,
coverImage = book?.coverImage ?: parsed?.libraryCoverBinary()?.imageBytes(),
coverImageMimeType = book?.coverImageMimeType ?: parsed?.libraryCoverBinary()?.contentType,
)
} }
appendLibraryLog("loaded library items count=${items.size}") }
items
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val bookId = db.files.get(fileId)?.bookId ?: return@useLibrary null
db.books.getCover(bookId)?.let { LibraryCover(image = it.image, mimeType = it.mimeType) }
} }
} }
@ -284,6 +278,20 @@ private fun appendLibraryLog(message: String) {
file.appendText("${System.currentTimeMillis()} $message\n") file.appendText("${System.currentTimeMillis()} $message\n")
} }
private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
LibraryItem(
fileId = fileId,
bookId = bookId,
title = title ?: originalFilename ?: fileId,
authors = authors,
language = language,
date = date,
format = format,
sizeBytes = sizeBytes,
storageUri = storageUri,
lastSeenAt = lastSeenAt,
)
private fun libraryLogFile(): File = private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log") File(System.getProperty("user.home"), ".toread/toread.log")

View File

@ -19,6 +19,12 @@ actual suspend fun chooseLibraryScanDirectory(): String? = null
actual suspend fun loadLibraryItems(): List<LibraryItem> = emptyList() actual suspend fun loadLibraryItems(): List<LibraryItem> = emptyList()
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = emptyList()
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = null
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = null
actual suspend fun scanLibrarySubtree( actual suspend fun scanLibrarySubtree(
path: String, path: String,
onProgress: (LibraryScanProgress) -> Unit, onProgress: (LibraryScanProgress) -> Unit,

View File

@ -19,7 +19,9 @@ data class BookRecord(
val id: String, val id: String,
val title: String? = null, val title: String? = null,
val subtitle: String? = null, val subtitle: String? = null,
val authors: List<String> = emptyList(),
val language: String? = null, val language: String? = null,
val date: String? = null,
val description: String? = null, val description: String? = null,
val coverImage: ByteArray? = null, val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null, val coverImageMimeType: String? = null,
@ -33,7 +35,9 @@ data class BookRecord(
return id == other.id && return id == other.id &&
title == other.title && title == other.title &&
subtitle == other.subtitle && subtitle == other.subtitle &&
authors == other.authors &&
language == other.language && language == other.language &&
date == other.date &&
description == other.description && description == other.description &&
coverImage.contentEquals(other.coverImage) && coverImage.contentEquals(other.coverImage) &&
coverImageMimeType == other.coverImageMimeType && coverImageMimeType == other.coverImageMimeType &&
@ -45,7 +49,9 @@ data class BookRecord(
var result = id.hashCode() var result = id.hashCode()
result = 31 * result + (title?.hashCode() ?: 0) result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + (subtitle?.hashCode() ?: 0) result = 31 * result + (subtitle?.hashCode() ?: 0)
result = 31 * result + authors.hashCode()
result = 31 * result + (language?.hashCode() ?: 0) result = 31 * result + (language?.hashCode() ?: 0)
result = 31 * result + (date?.hashCode() ?: 0)
result = 31 * result + (description?.hashCode() ?: 0) result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + (coverImage?.contentHashCode() ?: 0) result = 31 * result + (coverImage?.contentHashCode() ?: 0)
result = 31 * result + (coverImageMimeType?.hashCode() ?: 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( data class BookBodyRecord(
val id: String, val id: String,
val exactTextHash: String, val exactTextHash: String,
@ -90,6 +101,20 @@ data class BookFileRecord(
val updatedAt: Long, 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( data class ContentAnchor(
val version: Int = 1, val version: Int = 1,
val canonicalCharOffset: Long? = null, val canonicalCharOffset: Long? = null,
@ -136,6 +161,7 @@ data class NoteRecord(
interface BookRepository { interface BookRepository {
fun upsert(book: BookRecord) fun upsert(book: BookRecord)
fun get(id: String): BookRecord? fun get(id: String): BookRecord?
fun getCover(id: String): BookCoverRecord?
fun list(limit: Int = 100, offset: Int = 0): List<BookRecord> fun list(limit: Int = 100, offset: Int = 0): List<BookRecord>
fun delete(id: String): Boolean fun delete(id: String): Boolean
} }
@ -155,7 +181,9 @@ interface BodyClusterRepository {
interface BookFileRepository { interface BookFileRepository {
fun upsert(file: BookFileRecord) fun upsert(file: BookFileRecord)
fun get(id: String): BookFileRecord? fun get(id: String): BookFileRecord?
fun getLibraryFile(id: String): LibraryFileRecord?
fun findByRawSha256(rawSha256: String): List<BookFileRecord> 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 list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord> fun listForBook(bookId: String): List<BookFileRecord>
fun delete(id: String): Boolean fun delete(id: String): Boolean

View File

@ -4,6 +4,7 @@ import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BodyClusterRepository import net.sergeych.toread.storage.BodyClusterRepository
import net.sergeych.toread.storage.BookBodyRecord import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookBodyRepository import net.sergeych.toread.storage.BookBodyRepository
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
@ -13,6 +14,7 @@ import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.BookmarkRepository import net.sergeych.toread.storage.BookmarkRepository
import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryDatabase import net.sergeych.toread.storage.LibraryDatabase
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.NoteRecord import net.sergeych.toread.storage.NoteRecord
import net.sergeych.toread.storage.NoteRepository import net.sergeych.toread.storage.NoteRepository
import net.sergeych.toread.storage.ReadingStateRecord import net.sergeych.toread.storage.ReadingStateRecord
@ -22,9 +24,11 @@ import java.sql.DriverManager
import java.sql.PreparedStatement import java.sql.PreparedStatement
import java.sql.ResultSet import java.sql.ResultSet
import java.sql.SQLException import java.sql.SQLException
import java.util.concurrent.ConcurrentHashMap
class H2LibraryDatabase private constructor( class H2LibraryDatabase private constructor(
private val connection: Connection, private val connection: Connection,
migrate: Boolean,
) : LibraryDatabase { ) : LibraryDatabase {
override val books: BookRepository = JdbcBookRepository(connection) override val books: BookRepository = JdbcBookRepository(connection)
override val bodies: BookBodyRepository = JdbcBookBodyRepository(connection) override val bodies: BookBodyRepository = JdbcBookBodyRepository(connection)
@ -35,7 +39,10 @@ class H2LibraryDatabase private constructor(
override val notes: NoteRepository = JdbcNoteRepository(connection) override val notes: NoteRepository = JdbcNoteRepository(connection)
init { 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 { override fun <T> transaction(block: LibraryDatabase.() -> T): T {
@ -88,6 +95,9 @@ class H2LibraryDatabase private constructor(
} }
companion object { companion object {
private val openLocks = ConcurrentHashMap<String, Any>()
private val migratedUrls = ConcurrentHashMap.newKeySet<String>()
fun openFile(path: String, user: String = "sa", password: String = ""): H2LibraryDatabase { fun openFile(path: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
return openUrl("jdbc:h2:file:$path;DB_CLOSE_DELAY=0", user, password) 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 { fun openUrl(url: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
Class.forName("org.h2.Driver") 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, id VARCHAR PRIMARY KEY,
title VARCHAR, title VARCHAR,
subtitle VARCHAR, subtitle VARCHAR,
authors CLOB,
language VARCHAR, language VARCHAR,
published_date VARCHAR,
description CLOB, description CLOB,
cover_image BLOB, cover_image BLOB,
cover_image_mime_type VARCHAR, cover_image_mime_type VARCHAR,
@ -139,6 +157,8 @@ private fun migrate(connection: Connection) {
) )
""".trimIndent() """.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 BLOB")
statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR") statement.execute("ALTER TABLE books ADD COLUMN IF NOT EXISTS cover_image_mime_type VARCHAR")
statement.execute( 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_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)")
statement.execute( statement.execute(
""" """
CREATE TABLE IF NOT EXISTS reading_states ( CREATE TABLE IF NOT EXISTS reading_states (
@ -312,20 +333,23 @@ private class JdbcBookRepository(private val connection: Connection) : BookRepos
connection.prepareStatement( connection.prepareStatement(
""" """
MERGE INTO books( 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() """.trimIndent()
).use { statement -> ).use { statement ->
statement.setString(1, book.id) statement.setString(1, book.id)
statement.setStringOrNull(2, book.title) statement.setStringOrNull(2, book.title)
statement.setStringOrNull(3, book.subtitle) statement.setStringOrNull(3, book.subtitle)
statement.setStringOrNull(4, book.language) statement.setStringOrNull(4, book.authors.toDbList())
statement.setStringOrNull(5, book.description) statement.setStringOrNull(5, book.language)
statement.setBytesOrNull(6, book.coverImage) statement.setStringOrNull(6, book.date)
statement.setStringOrNull(7, book.coverImageMimeType) statement.setStringOrNull(7, book.description)
statement.setLong(8, book.createdAt) statement.setBytesOrNull(8, book.coverImage)
statement.setLong(9, book.updatedAt) statement.setStringOrNull(9, book.coverImageMimeType)
statement.setLong(10, book.createdAt)
statement.setLong(11, book.updatedAt)
statement.executeUpdate() 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() } 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> { override fun list(limit: Int, offset: Int): List<BookRecord> {
return connection.prepareStatement("SELECT * FROM books ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement -> return connection.prepareStatement("SELECT * FROM books ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit) 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() } 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> { override fun findByRawSha256(rawSha256: String): List<BookFileRecord> {
return connection.selectMany("SELECT * FROM book_files WHERE raw_sha256 = ? ORDER BY created_at", rawSha256) { return connection.selectMany("SELECT * FROM book_files WHERE raw_sha256 = ? ORDER BY created_at", rawSha256) {
it.toBookFileRecord() 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> { 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 -> return connection.prepareStatement("SELECT * FROM book_files ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit) statement.setInt(1, limit)
@ -611,7 +696,9 @@ private fun ResultSet.toBookRecord() = BookRecord(
id = getString("id"), id = getString("id"),
title = getString("title"), title = getString("title"),
subtitle = getString("subtitle"), subtitle = getString("subtitle"),
authors = getString("authors").fromDbList(),
language = getString("language"), language = getString("language"),
date = getString("published_date"),
description = getString("description"), description = getString("description"),
coverImage = getBytes("cover_image"), coverImage = getBytes("cover_image"),
coverImageMimeType = getString("cover_image_mime_type"), coverImageMimeType = getString("cover_image_mime_type"),
@ -619,6 +706,20 @@ private fun ResultSet.toBookRecord() = BookRecord(
updatedAt = getLong("updated_at"), 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( private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
id = getString("id"), id = getString("id"),
exactTextHash = getString("exact_text_hash"), 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) 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? { private fun ResultSet.getIntOrNull(column: String): Int? {
val value = getInt(column) val value = getInt(column)
return if (wasNull()) null else value return if (wasNull()) null else value

View File

@ -100,7 +100,9 @@ class LibraryScanner(
BookRecord( BookRecord(
id = bookId, id = bookId,
title = book.title.ifBlank { displayName.substringBeforeLast('.') }, title = book.title.ifBlank { displayName.substringBeforeLast('.') },
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
language = book.language, language = book.language,
date = book.date,
description = book.annotation, description = book.annotation,
coverImage = cover?.bytes, coverImage = cover?.bytes,
coverImageMimeType = cover?.mimeType, coverImageMimeType = cover?.mimeType,

View File

@ -27,7 +27,11 @@ class H2LibraryDatabaseTest {
BookRecord( BookRecord(
id = "book-1", id = "book-1",
title = "Example", title = "Example",
authors = listOf("Jane Example"),
language = "en", language = "en",
date = "2024",
coverImage = byteArrayOf(1, 2, 3),
coverImageMimeType = "image/jpeg",
createdAt = now, createdAt = now,
updatedAt = now, updatedAt = now,
) )
@ -112,6 +116,9 @@ class H2LibraryDatabaseTest {
} }
assertEquals("Example", db.books.get("book-1")?.title) 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("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)