Compare commits

..

No commits in common. "d8b39057d525d298d51ad37241203f361e09e194" and "8f8b466e893c5ab865053858e9d64425f91ab885" have entirely different histories.

10 changed files with 110 additions and 837 deletions

View File

@ -13,17 +13,13 @@ 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.storage.BookReadingStatus
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
import net.sergeych.toread.storage.jdbc.LibraryScanSummary
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -79,26 +75,32 @@ actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = wit
actual suspend fun chooseLibraryScanDirectory(): String? = directoryChooser?.chooseDirectory()
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
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")
appendLibraryLog("load library items")
openLibraryDatabase().useLibrary { db ->
db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
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()
}
}
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.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 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) }
appendLibraryLog("loaded library items count=${items.size}")
items
}
}
@ -158,7 +160,6 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
val now = System.currentTimeMillis()
db.readingStates.upsert(
ReadingStateRecord(
id = "state-$clusterId",
@ -168,16 +169,9 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(),
),
updatedAt = now,
updatedAt = System.currentTimeMillis(),
),
)
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,9 +249,6 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
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(
val uri: Uri,
val name: String,
@ -435,22 +426,6 @@ 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,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
)
private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/toread.log")

View File

@ -1,7 +1,6 @@
package net.sergeych.toread
import android.Manifest
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
@ -23,9 +22,7 @@ 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?>
@ -67,51 +64,14 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
override suspend fun chooseDirectory(): String? {
val result = CompletableDeferred<String?>()
runOnUiThread {
if (pendingDirectoryChoice != null || pendingExternalFileAccess != null) {
if (pendingDirectoryChoice != null) {
result.complete(null)
} else {
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()
return result.await()
}
override fun onNewIntent(intent: Intent) {
@ -130,7 +90,16 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
}
pendingExternalFileAccess = result
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
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)
} else {
readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
@ -152,23 +121,6 @@ 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

View File

@ -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.VolumeUp
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.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Scanner
@ -42,12 +42,9 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -61,7 +58,6 @@ 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
@ -102,7 +98,6 @@ import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.fb2.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan
import net.sergeych.toread.fb2.Fb2TextStyle
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.text.HyphenationRegistry
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@ -214,7 +209,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
)
},
onBack = {
state = AppState.Library(emptyList(), current.scanPath, current.message)
state = AppState.Library(current.libraryItems, current.scanPath, current.message)
},
)
is AppState.BookInfo -> BookInfoScreen(
@ -244,42 +239,16 @@ 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) {
message = nextMessage
scope.launch { loadPage(reset = true) }
scope.launch {
busy = true
try {
onStateChange(loadLibraryState(nextMessage, state.scanPath))
} finally {
busy = false
}
}
LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) loadPage(reset = true)
}
Scaffold(
@ -290,7 +259,7 @@ private fun LibraryScreen(
containerColor = MaterialTheme.colorScheme.surface,
),
actions = {
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
IconButton(onClick = { refresh() }, enabled = !busy) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
}
},
@ -309,11 +278,7 @@ private fun LibraryScreen(
.background(readerBackground()),
) {
val wide = maxWidth >= 800.dp
if (items.isEmpty() && loadingPage) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (items.isEmpty()) {
if (state.items.isEmpty()) {
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else {
LazyColumn(
@ -321,23 +286,9 @@ private fun LibraryScreen(
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
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")
}
items(state.items, key = { it.fileId }) { item ->
LibraryRow(
item = item,
coverCache = coverCache,
enabled = !busy,
onOpen = {
scope.launch {
@ -346,17 +297,16 @@ private fun LibraryScreen(
val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
saveActiveReadingFileId(item.fileId)
AppState.Reader(
fileId = item.fileId,
book = book,
libraryItems = items,
libraryItems = state.items,
scanPath = state.scanPath,
message = message,
)
}.getOrElse {
AppState.Library(items, state.scanPath, it.message ?: "Could not open book.")
AppState.Library(state.items, state.scanPath, it.message ?: "Could not open book.")
}
onStateChange(next)
} finally {
@ -364,62 +314,17 @@ 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 = {
scope.launch {
busy = true
try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
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)
}
onStateChange(
loadLibraryState(
if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}.",
state.scanPath,
),
)
} finally {
busy = false
}
@ -427,19 +332,6 @@ 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)
}
}
}
}
}
}
@ -588,30 +480,13 @@ 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
private fun LibraryRow(
item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean,
onOpen: () -> Unit,
onMarkAsRead: () -> Unit,
onMarkAsUnread: () -> Unit,
onNotInterested: () -> Unit,
onDelete: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
@ -621,7 +496,7 @@ private fun LibraryRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
LibraryCover(item, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
item.title,
@ -642,72 +517,17 @@ private fun LibraryRow(
maxLines = 1,
)
}
Box {
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()
},
)
}
IconButton(onClick = onDelete, enabled = enabled) {
Icon(Icons.Filled.Delete, contentDescription = "Remove ${item.title}")
}
}
}
}
@Composable
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)
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)
}
Box(
modifier = modifier
@ -745,11 +565,6 @@ private fun BookView(
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
LaunchedEffect(fileId) {
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
}
LaunchedEffect(fileId) {
loadLibraryReadingPosition(fileId)?.let { position ->
@ -768,19 +583,6 @@ private fun BookView(
.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(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
@ -1481,7 +1283,7 @@ private suspend fun loadStartupState(): AppState {
}
}
val activeFileId = loadActiveReadingFileId() ?: return library
val item = loadLibraryItem(activeFileId)
val item = library.items.firstOrNull { it.fileId == activeFileId }
if (item == null) {
saveActiveReadingFileId(null)
return library
@ -1504,7 +1306,7 @@ private suspend fun loadStartupState(): AppState {
private suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState =
runCatching {
AppState.Library(
items = emptyList(),
items = loadLibraryItems(),
scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(),
message = message,
)
@ -1541,24 +1343,12 @@ private val ThemeMode.displayName: String
private fun LibraryItem.libraryMetadataLine(): String =
listOfNotNull(
readingStatus.displayLabel,
lastReadAt?.formatLastRead(),
date?.yearOrRaw(),
language?.uppercase(),
format?.uppercase(),
sizeBytes?.formatBytes(),
).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 =
Regex("""\d{4}""").find(this)?.value ?: this
@ -1597,8 +1387,6 @@ 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?

View File

@ -2,7 +2,6 @@ package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.storage.BookReadingStatus
data class LibraryItem(
val fileId: String,
@ -15,17 +14,10 @@ data class LibraryItem(
val sizeBytes: Long?,
val storageUri: String?,
val lastSeenAt: Long?,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
)
data class LibraryCover(
val image: ByteArray,
val mimeType: String?,
)
data class LibraryScanReport(
val scannedFiles: Int,
val importedFiles: Int,
@ -85,12 +77,6 @@ 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?
@ -101,8 +87,6 @@ expect suspend fun loadLibraryReadingPosition(fileId: String): 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 loadActiveReadingFileId(): String?
@ -119,8 +103,6 @@ expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit
expect fun libraryLogPath(): String?
expect fun formatLibraryLastReadTime(millis: Long): String
internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
return image?.let(::binaryFor)

View File

@ -3,17 +3,13 @@ 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.storage.BookReadingStatus
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
import org.jetbrains.skia.Image
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.TimeUnit
import javax.swing.JFileChooser
@ -53,26 +49,32 @@ actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatche
}
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
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")
appendLibraryLog("load library items")
openLibraryDatabase().useLibrary { db ->
db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
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()
}
}
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.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 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) }
appendLibraryLog("loaded library items count=${items.size}")
items
}
}
@ -133,7 +135,6 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
val now = System.currentTimeMillis()
db.readingStates.upsert(
ReadingStateRecord(
id = "state-$clusterId",
@ -143,16 +144,9 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(),
),
updatedAt = now,
updatedAt = System.currentTimeMillis(),
),
)
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)
}
}
@ -261,9 +255,6 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
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 {
val libraryDir = File(System.getProperty("user.home"), ".toread/library")
val dbDir = File(libraryDir, "db")
@ -293,22 +284,6 @@ 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,
readingStatus = readingStatus,
lastReadAt = lastReadAt,
)
private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log")

View File

@ -3,7 +3,6 @@ package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.browser.window
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus
import org.w3c.dom.events.Event
actual fun loadDefaultBookBytes(): ByteArray? = null
@ -20,12 +19,6 @@ 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,
@ -40,8 +33,6 @@ actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
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 loadActiveReadingFileId(): String? = null
@ -65,29 +56,3 @@ actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
}
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)
}

View File

@ -15,20 +15,11 @@ enum class BookImportPolicy {
STORE_BLOB,
}
enum class BookReadingStatus {
NEW,
READING,
READ,
NOT_INTERESTED,
}
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,
@ -42,9 +33,7 @@ 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 &&
@ -56,9 +45,7 @@ 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)
@ -68,11 +55,6 @@ data class BookRecord(
}
}
data class BookCoverRecord(
val image: ByteArray,
val mimeType: String?,
)
data class BookBodyRecord(
val id: String,
val exactTextHash: String,
@ -104,28 +86,10 @@ data class BookFileRecord(
val contentObjectId: String? = null,
val lastModifiedMillis: Long? = null,
val lastSeenAt: Long? = null,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val lastReadAt: Long? = null,
val createdAt: 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(
val version: Int = 1,
val canonicalCharOffset: Long? = null,
@ -172,7 +136,6 @@ 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
}
@ -192,13 +155,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 updateReadingStatus(id: String, status: BookReadingStatus): Boolean
fun touchLastReadAt(id: String, lastReadAt: Long): Boolean
fun delete(id: String): Boolean
}

View File

@ -4,18 +4,15 @@ 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
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookRepository
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
@ -25,11 +22,9 @@ 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)
@ -40,10 +35,7 @@ class H2LibraryDatabase private constructor(
override val notes: NoteRepository = JdbcNoteRepository(connection)
init {
connection.createStatement().use { statement ->
statement.execute("SET LOCK_TIMEOUT 10000")
}
if (migrate) migrate(connection)
migrate(connection)
}
override fun <T> transaction(block: LibraryDatabase.() -> T): T {
@ -96,9 +88,6 @@ 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)
}
@ -109,13 +98,7 @@ class H2LibraryDatabase private constructor(
fun openUrl(url: String, user: String = "sa", password: String = ""): H2LibraryDatabase {
Class.forName("org.h2.Driver")
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
}
return H2LibraryDatabase(DriverManager.getConnection(url, user, password))
}
}
}
@ -147,9 +130,7 @@ 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,
@ -158,8 +139,6 @@ 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(
@ -209,8 +188,6 @@ private fun migrate(connection: Connection) {
content_object_id VARCHAR,
last_modified_millis BIGINT,
last_seen_at BIGINT,
reading_status VARCHAR NOT NULL DEFAULT 'NEW',
last_read_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
FOREIGN KEY (book_id) REFERENCES books(id),
@ -219,12 +196,8 @@ private fun migrate(connection: Connection) {
)
""".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_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(
"""
CREATE TABLE IF NOT EXISTS reading_states (
@ -247,20 +220,6 @@ private fun migrate(connection: Connection) {
""".trimIndent()
)
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(
"""
CREATE TABLE IF NOT EXISTS bookmarks (
@ -353,23 +312,20 @@ private class JdbcBookRepository(private val connection: Connection) : BookRepos
connection.prepareStatement(
"""
MERGE INTO books(
id, title, subtitle, authors, language, published_date, description,
cover_image, cover_image_mime_type, created_at, updated_at
id, title, subtitle, language, 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.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.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.executeUpdate()
}
}
@ -378,17 +334,6 @@ 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)
@ -472,9 +417,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
MERGE INTO book_files(
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,
last_seen_at, reading_status, last_read_at, created_at, updated_at
last_seen_at, created_at, updated_at
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, file.id)
@ -491,10 +436,8 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
statement.setStringOrNull(12, file.contentObjectId)
statement.setLongOrNull(13, file.lastModifiedMillis)
statement.setLongOrNull(14, file.lastSeenAt)
statement.setString(15, file.readingStatus.name)
statement.setLongOrNull(16, file.lastReadAt)
statement.setLong(17, file.createdAt)
statement.setLong(18, file.updatedAt)
statement.setLong(15, file.createdAt)
statement.setLong(16, file.updatedAt)
statement.executeUpdate()
}
}
@ -503,89 +446,12 @@ 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,
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> {
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(
"""
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> {
return connection.prepareStatement("SELECT * FROM book_files ORDER BY updated_at DESC LIMIT ? OFFSET ?").use { statement ->
statement.setInt(1, limit)
@ -600,50 +466,6 @@ 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)
}
@ -789,9 +611,7 @@ 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"),
@ -799,22 +619,6 @@ 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"),
readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"),
)
private fun ResultSet.toBookBodyRecord() = BookBodyRecord(
id = getString("id"),
exactTextHash = getString("exact_text_hash"),
@ -846,8 +650,6 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
contentObjectId = getString("content_object_id"),
lastModifiedMillis = getLongOrNull("last_modified_millis"),
lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"),
lastReadAt = getLongOrNull("last_read_at"),
createdAt = getLong("created_at"),
updatedAt = getLong("updated_at"),
)
@ -934,12 +736,6 @@ 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
@ -954,6 +750,3 @@ private fun ResultSet.getDoubleOrNull(column: String): Double? {
val value = getDouble(column)
return if (wasNull()) null else value
}
private fun ResultSet.getReadingStatus(column: String): BookReadingStatus =
runCatching { BookReadingStatus.valueOf(getString(column)) }.getOrDefault(BookReadingStatus.NEW)

View File

@ -100,9 +100,7 @@ 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,

View File

@ -4,7 +4,6 @@ import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord
import net.sergeych.toread.storage.BookFileRecord
import net.sergeych.toread.storage.BookFileStorageKind
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.BookRecord
import net.sergeych.toread.storage.BookmarkRecord
import net.sergeych.toread.storage.ContentAnchor
@ -28,11 +27,7 @@ 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,
)
@ -117,12 +112,6 @@ 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(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("cluster-1", db.clusters.get("cluster-1")?.id)
assertEquals(BookFileStorageKind.EXTERNAL_URI, db.files.get("file-1")?.storageKind)
@ -156,109 +145,6 @@ class H2LibraryDatabaseTest {
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
fun opensDatabaseWithExistingUppercaseIndex() {
val path = Files.createTempDirectory("toread-h2-index-").resolve("library").toString()