diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index 93db8ce..8a54ee0 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -13,8 +13,8 @@ import android.provider.OpenableColumns import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import net.sergeych.toread.fb2.Fb2Binary -import net.sergeych.toread.fb2.Fb2Format import net.sergeych.toread.storage.ContentAnchor +import net.sergeych.toread.storage.LibraryFileRecord import net.sergeych.toread.storage.ReadingStateRecord import net.sergeych.toread.storage.jdbc.H2LibraryDatabase 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 loadLibraryItems(): List = withContext(Dispatchers.IO) { - appendLibraryLog("load library items") + loadLibraryItemsPage(Int.MAX_VALUE, 0) +} + +actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = withContext(Dispatchers.IO) { + appendLibraryLog("load library items page limit=$limit offset=$offset") openLibraryDatabase().useLibrary { db -> - val items = db.files.list().map { file -> - 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, - title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id, - authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(), - 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 + db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() } + } +} + +actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + db.files.getLibraryFile(fileId)?.toLibraryItem() + } +} + +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") } +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 = File(appContext.filesDir, "logs/toread.log") diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt index e7bae80..e2a6137 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/MainActivity.kt @@ -1,6 +1,7 @@ package net.sergeych.toread import android.Manifest +import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -22,7 +23,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.Modifier import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { private lateinit var directoryLauncher: ActivityResultLauncher @@ -64,16 +67,53 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { override suspend fun chooseDirectory(): String? { val result = CompletableDeferred() runOnUiThread { - if (pendingDirectoryChoice != null) { + if (pendingDirectoryChoice != null || pendingExternalFileAccess != null) { result.complete(null) } else { - pendingDirectoryChoice = result - directoryLauncher.launch(null) + showDirectoryChoice(result) } } return result.await() } + private fun showDirectoryChoice(result: CompletableDeferred) { + 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) { + pendingDirectoryChoice = result + directoryLauncher.launch(null) + } + + private fun chooseDownloadsDirectory(result: CompletableDeferred) { + if (hasBroadFileAccess()) { + result.complete(downloadsPath()) + return + } + + val accessResult = CompletableDeferred() + pendingExternalFileAccess = accessResult + lifecycleScope.launch { + val granted = accessResult.await() + result.complete(if (granted) downloadsPath() else null) + } + launchAllFilesAccessSettings() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -90,16 +130,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { } pendingExternalFileAccess = result if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - 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) + launchAllFilesAccessSettings() } else { readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) } @@ -121,6 +152,23 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser { } else { 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 diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index cae89db..17b6da0 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -58,6 +58,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -239,16 +240,42 @@ private fun LibraryScreen( val scope = rememberCoroutineScope() var busy by remember { mutableStateOf(false) } 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() } + + 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) { - scope.launch { - busy = true - try { - onStateChange(loadLibraryState(nextMessage, state.scanPath)) - } finally { - busy = false - } - } + message = nextMessage + scope.launch { loadPage(reset = true) } + } + + LaunchedEffect(state.scanPath, state.message) { + if (items.isEmpty() && !endReached) loadPage(reset = true) } Scaffold( @@ -259,7 +286,7 @@ private fun LibraryScreen( containerColor = MaterialTheme.colorScheme.surface, ), actions = { - IconButton(onClick = { refresh() }, enabled = !busy) { + IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) { Icon(Icons.Filled.Refresh, contentDescription = "Refresh library") } }, @@ -278,7 +305,11 @@ private fun LibraryScreen( .background(readerBackground()), ) { 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)) } else { LazyColumn( @@ -286,9 +317,10 @@ private fun LibraryScreen( contentPadding = PaddingValues(0.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - items(state.items, key = { it.fileId }) { item -> + items(items, key = { it.fileId }) { item -> LibraryRow( item = item, + coverCache = coverCache, enabled = !busy, onOpen = { scope.launch { @@ -301,12 +333,12 @@ private fun LibraryScreen( AppState.Reader( fileId = item.fileId, book = book, - libraryItems = state.items, + libraryItems = items, scanPath = state.scanPath, message = message, ) }.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) } finally { @@ -319,12 +351,12 @@ private fun LibraryScreen( busy = true try { val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false) - onStateChange( - loadLibraryState( - if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}.", - state.scanPath, - ), - ) + message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}." + if (deleted) { + items = items.filterNot { it.fileId == item.fileId } + coverCache.remove(item.fileId) + nextOffset = (nextOffset - 1).coerceAtLeast(items.size) + } } finally { 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 private fun LibraryRow( item: LibraryItem, + coverCache: MutableMap, enabled: Boolean, onOpen: () -> Unit, onDelete: () -> Unit, @@ -496,7 +542,7 @@ private fun LibraryRow( horizontalArrangement = Arrangement.spacedBy(10.dp), 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)) { Text( item.title, @@ -525,9 +571,19 @@ private fun LibraryRow( } @Composable -private fun LibraryCover(item: LibraryItem, modifier: Modifier = Modifier.width(54.dp).aspectRatio(0.68f)) { - val bitmap = remember(item.fileId, item.coverImage) { - item.coverImage?.let(::decodeImageBytes) +private fun LibraryCover( + item: LibraryItem, + coverCache: MutableMap, + 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( modifier = modifier @@ -1283,7 +1339,7 @@ private suspend fun loadStartupState(): AppState { } } val activeFileId = loadActiveReadingFileId() ?: return library - val item = library.items.firstOrNull { it.fileId == activeFileId } + val item = loadLibraryItem(activeFileId) if (item == null) { saveActiveReadingFileId(null) return library @@ -1306,7 +1362,7 @@ private suspend fun loadStartupState(): AppState { private suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState = runCatching { AppState.Library( - items = loadLibraryItems(), + items = emptyList(), scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(), 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" +private const val LibraryPageSize: Int = 50 + expect fun loadDefaultBookBytes(): ByteArray? expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap? diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index fa3f5f8..c766c14 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -18,6 +18,11 @@ data class LibraryItem( val coverImageMimeType: String? = null, ) +data class LibraryCover( + val image: ByteArray, + val mimeType: String?, +) + data class LibraryScanReport( val scannedFiles: Int, val importedFiles: Int, @@ -77,6 +82,12 @@ expect suspend fun chooseLibraryScanDirectory(): String? expect suspend fun loadLibraryItems(): List +expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List + +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 openLibraryBook(fileId: String): ByteArray? diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index efbfd32..e06c603 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -3,7 +3,7 @@ package net.sergeych.toread import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap 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.ReadingStateRecord import net.sergeych.toread.storage.jdbc.H2LibraryDatabase @@ -49,32 +49,26 @@ actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatche } actual suspend fun loadLibraryItems(): List = withContext(Dispatchers.IO) { - appendLibraryLog("load library items") + loadLibraryItemsPage(Int.MAX_VALUE, 0) +} + +actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = withContext(Dispatchers.IO) { + appendLibraryLog("load library items page limit=$limit offset=$offset") openLibraryDatabase().useLibrary { db -> - val items = db.files.list().map { file -> - 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, - title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id, - authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(), - 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 + db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() } + } +} + +actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) { + openLibraryDatabase().useLibrary { db -> + db.files.getLibraryFile(fileId)?.toLibraryItem() + } +} + +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") } +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 = File(System.getProperty("user.home"), ".toread/toread.log") diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index 36a3ac0..1d1fef2 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -19,6 +19,12 @@ actual suspend fun chooseLibraryScanDirectory(): String? = null actual suspend fun loadLibraryItems(): List = emptyList() +actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List = emptyList() + +actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = null + +actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = null + actual suspend fun scanLibrarySubtree( path: String, onProgress: (LibraryScanProgress) -> Unit, diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt index ddf920b..c5d5f88 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt @@ -19,7 +19,9 @@ data class BookRecord( val id: String, val title: String? = null, val subtitle: String? = null, + val authors: List = 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 = 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 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 + fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List fun list(limit: Int = 500, offset: Int = 0): List fun listForBook(bookId: String): List fun delete(id: String): Boolean diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt index 76ef9e2..741758c 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt @@ -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 transaction(block: LibraryDatabase.() -> T): T { @@ -88,6 +95,9 @@ class H2LibraryDatabase private constructor( } companion object { + private val openLocks = ConcurrentHashMap() + private val migratedUrls = ConcurrentHashMap.newKeySet() + 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 { 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 { 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 { + 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 { 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.toDbList(): String? = + map { it.trim() }.filter { it.isNotBlank() }.joinToString("\n").takeIf { it.isNotBlank() } + +private fun String?.fromDbList(): List = + 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 diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt index 4ee101d..543f816 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt @@ -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, diff --git a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt index 0c60d03..117b0db 100644 --- a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt +++ b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt @@ -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)