Compare commits
2 Commits
8f8b466e89
...
d8b39057d5
| Author | SHA1 | Date | |
|---|---|---|---|
| d8b39057d5 | |||
| 02a40c2589 |
@ -13,13 +13,17 @@ 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.BookReadingStatus
|
||||||
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
|
||||||
import net.sergeych.toread.storage.jdbc.LibraryScanSummary
|
import net.sergeych.toread.storage.jdbc.LibraryScanSummary
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@ -75,32 +79,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) }
|
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
||||||
}.getOrNull()
|
openLibraryDatabase().useLibrary { db ->
|
||||||
}
|
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
||||||
LibraryItem(
|
}
|
||||||
fileId = file.id,
|
}
|
||||||
bookId = file.bookId,
|
|
||||||
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id,
|
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withContext(Dispatchers.IO) {
|
||||||
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(),
|
openLibraryDatabase().useLibrary { db ->
|
||||||
language = parsed?.language ?: book?.language,
|
val bookId = db.files.get(fileId)?.bookId ?: return@useLibrary null
|
||||||
date = parsed?.date,
|
db.books.getCover(bookId)?.let { LibraryCover(image = it.image, mimeType = it.mimeType) }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,6 +158,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
val file = db.files.get(fileId) ?: return@useLibrary
|
val file = db.files.get(fileId) ?: return@useLibrary
|
||||||
val clusterId = file.bodyClusterId ?: return@useLibrary
|
val clusterId = file.bodyClusterId ?: return@useLibrary
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
db.readingStates.upsert(
|
db.readingStates.upsert(
|
||||||
ReadingStateRecord(
|
ReadingStateRecord(
|
||||||
id = "state-$clusterId",
|
id = "state-$clusterId",
|
||||||
@ -169,9 +168,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
progress = position.itemIndex.toDouble(),
|
progress = position.itemIndex.toDouble(),
|
||||||
formatHintsJson = position.toFormatHintsJson(),
|
formatHintsJson = position.toFormatHintsJson(),
|
||||||
),
|
),
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
db.files.touchLastReadAt(fileId, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
openLibraryDatabase().useLibrary { db ->
|
||||||
|
db.files.updateReadingStatus(fileId, status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,6 +255,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
|
|||||||
|
|
||||||
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
|
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
|
||||||
|
|
||||||
|
actual fun formatLibraryLastReadTime(millis: Long): String =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis))
|
||||||
|
|
||||||
private data class AndroidLibraryDocument(
|
private data class AndroidLibraryDocument(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
val name: String,
|
val name: String,
|
||||||
@ -426,6 +435,22 @@ 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,
|
||||||
|
readingStatus = readingStatus,
|
||||||
|
lastReadAt = lastReadAt,
|
||||||
|
)
|
||||||
|
|
||||||
private fun libraryLogFile(): File =
|
private fun libraryLogFile(): File =
|
||||||
File(appContext.filesDir, "logs/toread.log")
|
File(appContext.filesDir, "logs/toread.log")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -31,9 +31,9 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Scanner
|
import androidx.compose.material.icons.filled.Scanner
|
||||||
@ -42,9 +42,12 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -58,6 +61,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
|
||||||
@ -98,6 +102,7 @@ import net.sergeych.toread.fb2.Fb2Section
|
|||||||
import net.sergeych.toread.fb2.Fb2Text
|
import net.sergeych.toread.fb2.Fb2Text
|
||||||
import net.sergeych.toread.fb2.Fb2TextSpan
|
import net.sergeych.toread.fb2.Fb2TextSpan
|
||||||
import net.sergeych.toread.fb2.Fb2TextStyle
|
import net.sergeych.toread.fb2.Fb2TextStyle
|
||||||
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import net.sergeych.toread.text.HyphenationRegistry
|
import net.sergeych.toread.text.HyphenationRegistry
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
@ -209,7 +214,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onBack = {
|
onBack = {
|
||||||
state = AppState.Library(current.libraryItems, current.scanPath, current.message)
|
state = AppState.Library(emptyList(), current.scanPath, current.message)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
is AppState.BookInfo -> BookInfoScreen(
|
is AppState.BookInfo -> BookInfoScreen(
|
||||||
@ -239,16 +244,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 {
|
LaunchedEffect(state.scanPath, state.message) {
|
||||||
busy = false
|
if (items.isEmpty() && !endReached) loadPage(reset = true)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -259,7 +290,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 +309,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 +321,23 @@ 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 ->
|
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
||||||
|
if (hasReadingNow) {
|
||||||
|
item(key = "section-reading") {
|
||||||
|
LibrarySectionHeader("reading now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(items, key = { _, item -> item.fileId }) { index, item ->
|
||||||
|
if (
|
||||||
|
hasReadingNow &&
|
||||||
|
item.readingStatus != BookReadingStatus.READING &&
|
||||||
|
(index == 0 || items[index - 1].readingStatus == BookReadingStatus.READING)
|
||||||
|
) {
|
||||||
|
LibrarySectionHeader("my library")
|
||||||
|
}
|
||||||
LibraryRow(
|
LibraryRow(
|
||||||
item = item,
|
item = item,
|
||||||
|
coverCache = coverCache,
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
onOpen = {
|
onOpen = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -297,16 +346,17 @@ private fun LibraryScreen(
|
|||||||
val next = runCatching {
|
val next = runCatching {
|
||||||
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
|
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
|
||||||
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
|
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
|
||||||
|
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
|
||||||
saveActiveReadingFileId(item.fileId)
|
saveActiveReadingFileId(item.fileId)
|
||||||
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 {
|
||||||
@ -314,17 +364,62 @@ private fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onMarkAsRead = {
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
|
||||||
|
message = "Marked ${item.title} as read."
|
||||||
|
loadPage(reset = true)
|
||||||
|
} else {
|
||||||
|
message = "Could not update ${item.title}."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMarkAsUnread = {
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
|
||||||
|
message = "Marked ${item.title} as unread."
|
||||||
|
loadPage(reset = true)
|
||||||
|
} else {
|
||||||
|
message = "Could not update ${item.title}."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNotInterested = {
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
|
||||||
|
message = "Marked ${item.title} as not interesting."
|
||||||
|
loadPage(reset = true)
|
||||||
|
} else {
|
||||||
|
message = "Could not update ${item.title}."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onDelete = {
|
onDelete = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
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 +427,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -480,13 +588,30 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibrarySectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibraryRow(
|
private fun LibraryRow(
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
|
coverCache: MutableMap<String, LibraryCover?>,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
|
onMarkAsRead: () -> Unit,
|
||||||
|
onMarkAsUnread: () -> Unit,
|
||||||
|
onNotInterested: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
|
Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -496,7 +621,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,
|
||||||
@ -517,17 +642,72 @@ private fun LibraryRow(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDelete, enabled = enabled) {
|
Box {
|
||||||
Icon(Icons.Filled.Delete, contentDescription = "Remove ${item.title}")
|
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
|
||||||
|
Icon(Icons.Filled.MoreVert, contentDescription = "Book menu for ${item.title}")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Open") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onOpen()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
if (item.readingStatus != BookReadingStatus.READ) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Mark as read") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onMarkAsRead()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.readingStatus == BookReadingStatus.READ) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Mark as unread") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onMarkAsUnread()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Not interesting") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onNotInterested()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
@ -565,6 +745,11 @@ private fun BookView(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var restored by remember(fileId) { mutableStateOf(false) }
|
var restored by remember(fileId) { mutableStateOf(false) }
|
||||||
|
var markedRead by remember(fileId) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(fileId) {
|
||||||
|
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(fileId) {
|
LaunchedEffect(fileId) {
|
||||||
loadLibraryReadingPosition(fileId)?.let { position ->
|
loadLibraryReadingPosition(fileId)?.let { position ->
|
||||||
@ -583,6 +768,19 @@ private fun BookView(
|
|||||||
.collect { saveLibraryReadingPosition(fileId, it) }
|
.collect { saveLibraryReadingPosition(fileId, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(fileId, listState) {
|
||||||
|
snapshotFlow {
|
||||||
|
val layoutInfo = listState.layoutInfo
|
||||||
|
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
layoutInfo.totalItemsCount > 0 && lastVisible >= layoutInfo.totalItemsCount - 1
|
||||||
|
}
|
||||||
|
.filter { restored && it && !markedRead }
|
||||||
|
.collect {
|
||||||
|
markedRead = true
|
||||||
|
markLibraryReadingStatus(fileId, BookReadingStatus.READ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -1283,7 +1481,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 +1504,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,
|
||||||
)
|
)
|
||||||
@ -1343,12 +1541,24 @@ private val ThemeMode.displayName: String
|
|||||||
|
|
||||||
private fun LibraryItem.libraryMetadataLine(): String =
|
private fun LibraryItem.libraryMetadataLine(): String =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
|
readingStatus.displayLabel,
|
||||||
|
lastReadAt?.formatLastRead(),
|
||||||
date?.yearOrRaw(),
|
date?.yearOrRaw(),
|
||||||
language?.uppercase(),
|
language?.uppercase(),
|
||||||
format?.uppercase(),
|
format?.uppercase(),
|
||||||
sizeBytes?.formatBytes(),
|
sizeBytes?.formatBytes(),
|
||||||
).joinToString(" | ").ifBlank { "No metadata" }
|
).joinToString(" | ").ifBlank { "No metadata" }
|
||||||
|
|
||||||
|
private val BookReadingStatus.displayLabel: String
|
||||||
|
get() = when (this) {
|
||||||
|
BookReadingStatus.NEW -> "New"
|
||||||
|
BookReadingStatus.READING -> "Reading"
|
||||||
|
BookReadingStatus.READ -> "Read"
|
||||||
|
BookReadingStatus.NOT_INTERESTED -> "Not interested"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.formatLastRead(): String = "Last read ${formatLibraryLastReadTime(this)}"
|
||||||
|
|
||||||
private fun String.yearOrRaw(): String =
|
private fun String.yearOrRaw(): String =
|
||||||
Regex("""\d{4}""").find(this)?.value ?: this
|
Regex("""\d{4}""").find(this)?.value ?: this
|
||||||
|
|
||||||
@ -1387,6 +1597,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?
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package net.sergeych.toread
|
|||||||
|
|
||||||
import net.sergeych.toread.fb2.Fb2Binary
|
import net.sergeych.toread.fb2.Fb2Binary
|
||||||
import net.sergeych.toread.fb2.Fb2Book
|
import net.sergeych.toread.fb2.Fb2Book
|
||||||
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
|
|
||||||
data class LibraryItem(
|
data class LibraryItem(
|
||||||
val fileId: String,
|
val fileId: String,
|
||||||
@ -14,10 +15,17 @@ data class LibraryItem(
|
|||||||
val sizeBytes: Long?,
|
val sizeBytes: Long?,
|
||||||
val storageUri: String?,
|
val storageUri: String?,
|
||||||
val lastSeenAt: Long?,
|
val lastSeenAt: Long?,
|
||||||
|
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
|
||||||
|
val lastReadAt: Long? = null,
|
||||||
val coverImage: ByteArray? = null,
|
val coverImage: ByteArray? = null,
|
||||||
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 +85,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?
|
||||||
@ -87,6 +101,8 @@ expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
|
|||||||
|
|
||||||
expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition)
|
expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition)
|
||||||
|
|
||||||
|
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
|
||||||
|
|
||||||
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
|
||||||
|
|
||||||
expect suspend fun loadActiveReadingFileId(): String?
|
expect suspend fun loadActiveReadingFileId(): String?
|
||||||
@ -103,6 +119,8 @@ expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit
|
|||||||
|
|
||||||
expect fun libraryLogPath(): String?
|
expect fun libraryLogPath(): String?
|
||||||
|
|
||||||
|
expect fun formatLibraryLastReadTime(millis: Long): String
|
||||||
|
|
||||||
internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
|
internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
|
||||||
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
|
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
|
||||||
return image?.let(::binaryFor)
|
return image?.let(::binaryFor)
|
||||||
|
|||||||
@ -3,13 +3,17 @@ 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.BookReadingStatus
|
||||||
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
|
||||||
import org.jetbrains.skia.Image
|
import org.jetbrains.skia.Image
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.swing.JFileChooser
|
import javax.swing.JFileChooser
|
||||||
@ -49,32 +53,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) }
|
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
||||||
}.getOrNull()
|
openLibraryDatabase().useLibrary { db ->
|
||||||
}
|
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
||||||
LibraryItem(
|
}
|
||||||
fileId = file.id,
|
}
|
||||||
bookId = file.bookId,
|
|
||||||
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id,
|
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withContext(Dispatchers.IO) {
|
||||||
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(),
|
openLibraryDatabase().useLibrary { db ->
|
||||||
language = parsed?.language ?: book?.language,
|
val bookId = db.files.get(fileId)?.bookId ?: return@useLibrary null
|
||||||
date = parsed?.date,
|
db.books.getCover(bookId)?.let { LibraryCover(image = it.image, mimeType = it.mimeType) }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,6 +133,7 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
val file = db.files.get(fileId) ?: return@useLibrary
|
val file = db.files.get(fileId) ?: return@useLibrary
|
||||||
val clusterId = file.bodyClusterId ?: return@useLibrary
|
val clusterId = file.bodyClusterId ?: return@useLibrary
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
db.readingStates.upsert(
|
db.readingStates.upsert(
|
||||||
ReadingStateRecord(
|
ReadingStateRecord(
|
||||||
id = "state-$clusterId",
|
id = "state-$clusterId",
|
||||||
@ -144,9 +143,16 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
|
|||||||
progress = position.itemIndex.toDouble(),
|
progress = position.itemIndex.toDouble(),
|
||||||
formatHintsJson = position.toFormatHintsJson(),
|
formatHintsJson = position.toFormatHintsJson(),
|
||||||
),
|
),
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
db.files.touchLastReadAt(fileId, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
openLibraryDatabase().useLibrary { db ->
|
||||||
|
db.files.updateReadingStatus(fileId, status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +261,9 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
|
|||||||
|
|
||||||
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
|
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
|
||||||
|
|
||||||
|
actual fun formatLibraryLastReadTime(millis: Long): String =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(millis))
|
||||||
|
|
||||||
private fun openLibraryDatabase(): H2LibraryDatabase {
|
private fun openLibraryDatabase(): H2LibraryDatabase {
|
||||||
val libraryDir = File(System.getProperty("user.home"), ".toread/library")
|
val libraryDir = File(System.getProperty("user.home"), ".toread/library")
|
||||||
val dbDir = File(libraryDir, "db")
|
val dbDir = File(libraryDir, "db")
|
||||||
@ -284,6 +293,22 @@ 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,
|
||||||
|
readingStatus = readingStatus,
|
||||||
|
lastReadAt = lastReadAt,
|
||||||
|
)
|
||||||
|
|
||||||
private fun libraryLogFile(): File =
|
private fun libraryLogFile(): File =
|
||||||
File(System.getProperty("user.home"), ".toread/toread.log")
|
File(System.getProperty("user.home"), ".toread/toread.log")
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package net.sergeych.toread
|
|||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import kotlinx.browser.window
|
import kotlinx.browser.window
|
||||||
import net.sergeych.toread.fb2.Fb2Binary
|
import net.sergeych.toread.fb2.Fb2Binary
|
||||||
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import org.w3c.dom.events.Event
|
import org.w3c.dom.events.Event
|
||||||
|
|
||||||
actual fun loadDefaultBookBytes(): ByteArray? = null
|
actual fun loadDefaultBookBytes(): ByteArray? = null
|
||||||
@ -19,6 +20,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,
|
||||||
@ -33,6 +40,8 @@ actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
|
|||||||
|
|
||||||
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit
|
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit
|
||||||
|
|
||||||
|
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
|
||||||
|
|
||||||
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
|
||||||
|
|
||||||
actual suspend fun loadActiveReadingFileId(): String? = null
|
actual suspend fun loadActiveReadingFileId(): String? = null
|
||||||
@ -56,3 +65,29 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual fun libraryLogPath(): String? = null
|
actual fun libraryLogPath(): String? = null
|
||||||
|
|
||||||
|
actual fun formatLibraryLastReadTime(millis: Long): String {
|
||||||
|
val totalMinutes = millis / 60_000L
|
||||||
|
val minute = (totalMinutes % 60).toString().padStart(2, '0')
|
||||||
|
val totalHours = totalMinutes / 60
|
||||||
|
val hour = (totalHours % 24).toString().padStart(2, '0')
|
||||||
|
val (yearValue, monthValue, dayValue) = civilDateFromEpochDay(totalHours / 24)
|
||||||
|
val year = yearValue.toString()
|
||||||
|
val month = monthValue.toString().padStart(2, '0')
|
||||||
|
val day = dayValue.toString().padStart(2, '0')
|
||||||
|
return "$year-$month-$day $hour:$minute"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun civilDateFromEpochDay(epochDay: Long): Triple<Int, Int, Int> {
|
||||||
|
val shiftedDay = epochDay + 719_468
|
||||||
|
val era = shiftedDay / 146_097
|
||||||
|
val dayOfEra = shiftedDay - era * 146_097
|
||||||
|
val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
|
||||||
|
var year = (yearOfEra + era * 400).toInt()
|
||||||
|
val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
|
||||||
|
val monthPart = (5 * dayOfYear + 2) / 153
|
||||||
|
val day = (dayOfYear - (153 * monthPart + 2) / 5 + 1).toInt()
|
||||||
|
val month = (monthPart + if (monthPart < 10) 3 else -9).toInt()
|
||||||
|
if (month <= 2) year += 1
|
||||||
|
return Triple(year, month, day)
|
||||||
|
}
|
||||||
|
|||||||
@ -15,11 +15,20 @@ enum class BookImportPolicy {
|
|||||||
STORE_BLOB,
|
STORE_BLOB,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class BookReadingStatus {
|
||||||
|
NEW,
|
||||||
|
READING,
|
||||||
|
READ,
|
||||||
|
NOT_INTERESTED,
|
||||||
|
}
|
||||||
|
|
||||||
data class BookRecord(
|
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 +42,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 +56,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 +68,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,
|
||||||
@ -86,10 +104,28 @@ data class BookFileRecord(
|
|||||||
val contentObjectId: String? = null,
|
val contentObjectId: String? = null,
|
||||||
val lastModifiedMillis: Long? = null,
|
val lastModifiedMillis: Long? = null,
|
||||||
val lastSeenAt: Long? = null,
|
val lastSeenAt: Long? = null,
|
||||||
|
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
|
||||||
|
val lastReadAt: Long? = null,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
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,
|
||||||
|
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
|
||||||
|
val lastReadAt: 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 +172,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,9 +192,13 @@ 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 updateReadingStatus(id: String, status: BookReadingStatus): Boolean
|
||||||
|
fun touchLastReadAt(id: String, lastReadAt: Long): Boolean
|
||||||
fun delete(id: String): Boolean
|
fun delete(id: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,15 +4,18 @@ 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
|
||||||
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import net.sergeych.toread.storage.BookRecord
|
import net.sergeych.toread.storage.BookRecord
|
||||||
import net.sergeych.toread.storage.BookRepository
|
import net.sergeych.toread.storage.BookRepository
|
||||||
import net.sergeych.toread.storage.BookmarkRecord
|
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 +25,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 +40,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 +96,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 +109,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 +147,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 +158,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(
|
||||||
@ -188,6 +209,8 @@ private fun migrate(connection: Connection) {
|
|||||||
content_object_id VARCHAR,
|
content_object_id VARCHAR,
|
||||||
last_modified_millis BIGINT,
|
last_modified_millis BIGINT,
|
||||||
last_seen_at BIGINT,
|
last_seen_at BIGINT,
|
||||||
|
reading_status VARCHAR NOT NULL DEFAULT 'NEW',
|
||||||
|
last_read_at BIGINT,
|
||||||
created_at BIGINT NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
updated_at BIGINT NOT NULL,
|
updated_at BIGINT NOT NULL,
|
||||||
FOREIGN KEY (book_id) REFERENCES books(id),
|
FOREIGN KEY (book_id) REFERENCES books(id),
|
||||||
@ -196,8 +219,12 @@ private fun migrate(connection: Connection) {
|
|||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
|
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
|
||||||
|
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
|
||||||
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)")
|
||||||
|
connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)")
|
||||||
statement.execute(
|
statement.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS reading_states (
|
CREATE TABLE IF NOT EXISTS reading_states (
|
||||||
@ -220,6 +247,20 @@ private fun migrate(connection: Connection) {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
connection.createIndexIfMissing("idx_reading_states_cluster", "CREATE INDEX IF NOT EXISTS idx_reading_states_cluster ON reading_states(body_cluster_id)")
|
connection.createIndexIfMissing("idx_reading_states_cluster", "CREATE INDEX IF NOT EXISTS idx_reading_states_cluster ON reading_states(body_cluster_id)")
|
||||||
|
statement.execute(
|
||||||
|
"""
|
||||||
|
UPDATE book_files
|
||||||
|
SET reading_status = 'READING',
|
||||||
|
last_read_at = (
|
||||||
|
SELECT MAX(updated_at)
|
||||||
|
FROM reading_states
|
||||||
|
WHERE reading_states.body_cluster_id = book_files.body_cluster_id
|
||||||
|
)
|
||||||
|
WHERE reading_status = 'NEW'
|
||||||
|
AND last_read_at IS NULL
|
||||||
|
AND body_cluster_id IN (SELECT body_cluster_id FROM reading_states)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
statement.execute(
|
statement.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
@ -312,20 +353,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 +378,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)
|
||||||
@ -417,9 +472,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
MERGE INTO book_files(
|
MERGE INTO book_files(
|
||||||
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes,
|
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes,
|
||||||
original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis,
|
original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis,
|
||||||
last_seen_at, created_at, updated_at
|
last_seen_at, reading_status, last_read_at, created_at, updated_at
|
||||||
)
|
)
|
||||||
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
).use { statement ->
|
).use { statement ->
|
||||||
statement.setString(1, file.id)
|
statement.setString(1, file.id)
|
||||||
@ -436,8 +491,10 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
statement.setStringOrNull(12, file.contentObjectId)
|
statement.setStringOrNull(12, file.contentObjectId)
|
||||||
statement.setLongOrNull(13, file.lastModifiedMillis)
|
statement.setLongOrNull(13, file.lastModifiedMillis)
|
||||||
statement.setLongOrNull(14, file.lastSeenAt)
|
statement.setLongOrNull(14, file.lastSeenAt)
|
||||||
statement.setLong(15, file.createdAt)
|
statement.setString(15, file.readingStatus.name)
|
||||||
statement.setLong(16, file.updatedAt)
|
statement.setLongOrNull(16, file.lastReadAt)
|
||||||
|
statement.setLong(17, file.createdAt)
|
||||||
|
statement.setLong(18, file.updatedAt)
|
||||||
statement.executeUpdate()
|
statement.executeUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -446,12 +503,89 @@ 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,
|
||||||
|
f.reading_status AS reading_status,
|
||||||
|
f.last_read_at AS last_read_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(
|
||||||
|
"""
|
||||||
|
WITH visible_files AS (
|
||||||
|
SELECT
|
||||||
|
f.*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY COALESCE(f.body_cluster_id, f.body_id, f.book_id, f.raw_sha256, f.id)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(COALESCE(f.format, '')) = 'fb2.zip'
|
||||||
|
OR LOWER(COALESCE(f.original_filename, '')) LIKE '%.fb2.zip'
|
||||||
|
THEN 0 ELSE 1
|
||||||
|
END,
|
||||||
|
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||||
|
f.last_read_at DESC NULLS LAST,
|
||||||
|
f.updated_at DESC,
|
||||||
|
f.id
|
||||||
|
) AS duplicate_rank
|
||||||
|
FROM book_files f
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
f.reading_status AS reading_status,
|
||||||
|
f.last_read_at AS last_read_at
|
||||||
|
FROM visible_files f
|
||||||
|
LEFT JOIN books b ON b.id = f.book_id
|
||||||
|
WHERE f.duplicate_rank = 1
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||||
|
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
||||||
|
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
|
||||||
|
f.id
|
||||||
|
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)
|
||||||
@ -466,6 +600,50 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
UPDATE book_files
|
||||||
|
SET reading_status = ?,
|
||||||
|
last_read_at = CASE
|
||||||
|
WHEN ? IN ('READING', 'READ') THEN ?
|
||||||
|
WHEN ? = 'NEW' THEN NULL
|
||||||
|
ELSE last_read_at
|
||||||
|
END,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
OR body_cluster_id = (SELECT body_cluster_id FROM book_files WHERE id = ? AND body_cluster_id IS NOT NULL)
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, status.name)
|
||||||
|
statement.setString(2, status.name)
|
||||||
|
statement.setLong(3, now)
|
||||||
|
statement.setString(4, status.name)
|
||||||
|
statement.setLong(5, now)
|
||||||
|
statement.setString(6, id)
|
||||||
|
statement.setString(7, id)
|
||||||
|
statement.executeUpdate() > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun touchLastReadAt(id: String, lastReadAt: Long): Boolean {
|
||||||
|
return connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
UPDATE book_files
|
||||||
|
SET last_read_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
OR body_cluster_id = (SELECT body_cluster_id FROM book_files WHERE id = ? AND body_cluster_id IS NOT NULL)
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setLong(1, lastReadAt)
|
||||||
|
statement.setLong(2, lastReadAt)
|
||||||
|
statement.setString(3, id)
|
||||||
|
statement.setString(4, id)
|
||||||
|
statement.executeUpdate() > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun delete(id: String): Boolean = connection.deleteById("book_files", id)
|
override fun delete(id: String): Boolean = connection.deleteById("book_files", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -611,7 +789,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 +799,22 @@ 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"),
|
||||||
|
readingStatus = getReadingStatus("reading_status"),
|
||||||
|
lastReadAt = getLongOrNull("last_read_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"),
|
||||||
@ -650,6 +846,8 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
|
|||||||
contentObjectId = getString("content_object_id"),
|
contentObjectId = getString("content_object_id"),
|
||||||
lastModifiedMillis = getLongOrNull("last_modified_millis"),
|
lastModifiedMillis = getLongOrNull("last_modified_millis"),
|
||||||
lastSeenAt = getLongOrNull("last_seen_at"),
|
lastSeenAt = getLongOrNull("last_seen_at"),
|
||||||
|
readingStatus = getReadingStatus("reading_status"),
|
||||||
|
lastReadAt = getLongOrNull("last_read_at"),
|
||||||
createdAt = getLong("created_at"),
|
createdAt = getLong("created_at"),
|
||||||
updatedAt = getLong("updated_at"),
|
updatedAt = getLong("updated_at"),
|
||||||
)
|
)
|
||||||
@ -736,6 +934,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
|
||||||
@ -750,3 +954,6 @@ private fun ResultSet.getDoubleOrNull(column: String): Double? {
|
|||||||
val value = getDouble(column)
|
val value = getDouble(column)
|
||||||
return if (wasNull()) null else value
|
return if (wasNull()) null else value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ResultSet.getReadingStatus(column: String): BookReadingStatus =
|
||||||
|
runCatching { BookReadingStatus.valueOf(getString(column)) }.getOrDefault(BookReadingStatus.NEW)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import net.sergeych.toread.storage.BodyClusterRecord
|
|||||||
import net.sergeych.toread.storage.BookBodyRecord
|
import net.sergeych.toread.storage.BookBodyRecord
|
||||||
import net.sergeych.toread.storage.BookFileRecord
|
import net.sergeych.toread.storage.BookFileRecord
|
||||||
import net.sergeych.toread.storage.BookFileStorageKind
|
import net.sergeych.toread.storage.BookFileStorageKind
|
||||||
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import net.sergeych.toread.storage.BookRecord
|
import net.sergeych.toread.storage.BookRecord
|
||||||
import net.sergeych.toread.storage.BookmarkRecord
|
import net.sergeych.toread.storage.BookmarkRecord
|
||||||
import net.sergeych.toread.storage.ContentAnchor
|
import net.sergeych.toread.storage.ContentAnchor
|
||||||
@ -27,7 +28,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 +117,12 @@ 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(BookReadingStatus.NEW, db.files.getLibraryFile("file-1")?.readingStatus)
|
||||||
|
assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.READING))
|
||||||
|
assertEquals(BookReadingStatus.READING, db.files.getLibraryFile("file-1")?.readingStatus)
|
||||||
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)
|
||||||
@ -145,6 +156,109 @@ class H2LibraryDatabaseTest {
|
|||||||
db.close()
|
db.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsReadingBooksFirstThenSortsByTitle() {
|
||||||
|
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstThenSortsByTitle")
|
||||||
|
val now = 1_700_000_000_000L
|
||||||
|
|
||||||
|
db.transaction {
|
||||||
|
listOf(
|
||||||
|
Triple("book-beta", "Beta", BookReadingStatus.NEW),
|
||||||
|
Triple("book-alpha", "Alpha", BookReadingStatus.READ),
|
||||||
|
Triple("book-gamma", "Gamma", BookReadingStatus.READING),
|
||||||
|
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING),
|
||||||
|
).forEachIndexed { index, (bookId, title, status) ->
|
||||||
|
books.upsert(
|
||||||
|
BookRecord(
|
||||||
|
id = bookId,
|
||||||
|
title = title,
|
||||||
|
createdAt = now + index,
|
||||||
|
updatedAt = now + index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
files.upsert(
|
||||||
|
BookFileRecord(
|
||||||
|
id = "file-$title",
|
||||||
|
bookId = bookId,
|
||||||
|
rawSha256 = "sha-$title",
|
||||||
|
originalFilename = "$title.fb2",
|
||||||
|
storageKind = BookFileStorageKind.EXTERNAL_URI,
|
||||||
|
readingStatus = status,
|
||||||
|
lastReadAt = if (title == "Aardvark") now + 500 else now + 100,
|
||||||
|
createdAt = now + index,
|
||||||
|
updatedAt = now + index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("Aardvark", "Gamma", "Alpha", "Beta"),
|
||||||
|
db.files.listLibraryFiles().map { it.title },
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hidesDuplicateLibraryFilesAndPrefersZip() {
|
||||||
|
val db = H2LibraryDatabase.openMemory("hidesDuplicateLibraryFilesAndPrefersZip")
|
||||||
|
val now = 1_700_000_000_000L
|
||||||
|
|
||||||
|
db.transaction {
|
||||||
|
bodies.upsert(
|
||||||
|
BookBodyRecord(
|
||||||
|
id = "body-dupe",
|
||||||
|
exactTextHash = "same-text",
|
||||||
|
canonicalizationVersion = 1,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
clusters.upsert(
|
||||||
|
BodyClusterRecord(
|
||||||
|
id = "cluster-dupe",
|
||||||
|
representativeBodyId = "body-dupe",
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
books.upsert(BookRecord(id = "book-fb2", title = "Same Book", createdAt = now, updatedAt = now))
|
||||||
|
books.upsert(BookRecord(id = "book-zip", title = "Same Book", createdAt = now, updatedAt = now + 1))
|
||||||
|
files.upsert(
|
||||||
|
BookFileRecord(
|
||||||
|
id = "file-fb2",
|
||||||
|
bookId = "book-fb2",
|
||||||
|
bodyId = "body-dupe",
|
||||||
|
bodyClusterId = "cluster-dupe",
|
||||||
|
rawSha256 = "raw-fb2",
|
||||||
|
format = "fb2",
|
||||||
|
originalFilename = "same.fb2",
|
||||||
|
storageKind = BookFileStorageKind.EXTERNAL_URI,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now + 10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
files.upsert(
|
||||||
|
BookFileRecord(
|
||||||
|
id = "file-zip",
|
||||||
|
bookId = "book-zip",
|
||||||
|
bodyId = "body-dupe",
|
||||||
|
bodyClusterId = "cluster-dupe",
|
||||||
|
rawSha256 = "raw-zip",
|
||||||
|
format = "fb2.zip",
|
||||||
|
originalFilename = "same.fb2.zip",
|
||||||
|
storageKind = BookFileStorageKind.EXTERNAL_URI,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(listOf("file-zip"), db.files.listLibraryFiles().map { it.fileId })
|
||||||
|
assertEquals(true, db.files.updateReadingStatus("file-zip", BookReadingStatus.READING))
|
||||||
|
assertEquals(BookReadingStatus.READING, db.files.get("file-fb2")?.readingStatus)
|
||||||
|
assertEquals(BookReadingStatus.READING, db.files.get("file-zip")?.readingStatus)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun opensDatabaseWithExistingUppercaseIndex() {
|
fun opensDatabaseWithExistingUppercaseIndex() {
|
||||||
val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()
|
val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user