added library database
This commit is contained in:
parent
8f8b466e89
commit
02a40c2589
@ -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<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 ->
|
||||
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()
|
||||
db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@ -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<Uri?>
|
||||
@ -64,16 +67,53 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
|
||||
override suspend fun chooseDirectory(): String? {
|
||||
val result = CompletableDeferred<String?>()
|
||||
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<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) {
|
||||
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
|
||||
|
||||
@ -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<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) {
|
||||
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<String, LibraryCover?>,
|
||||
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<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(
|
||||
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?
|
||||
|
||||
@ -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<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 openLibraryBook(fileId: String): ByteArray?
|
||||
|
||||
@ -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<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 ->
|
||||
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()
|
||||
db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@ -19,6 +19,12 @@ actual suspend fun chooseLibraryScanDirectory(): String? = null
|
||||
|
||||
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(
|
||||
path: String,
|
||||
onProgress: (LibraryScanProgress) -> Unit,
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user