+image viewer, better dupes protection and background processing

This commit is contained in:
Sergey Chernov 2026-05-17 12:22:46 +03:00
parent 14c9863a83
commit ade4fb1896
18 changed files with 817 additions and 127 deletions

View File

@ -43,6 +43,15 @@
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.imageviewer.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/image_clipboard_paths"/>
</provider>
</application>
</manifest>

View File

@ -1,5 +1,7 @@
package net.sergeych.toread
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.ComponentCallbacks
import android.content.Context
@ -12,6 +14,7 @@ import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor
@ -24,7 +27,12 @@ import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private lateinit var appContext: Context
@ -54,6 +62,34 @@ actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val imageDir = File(appContext.cacheDir, "clipboard-images").also { it.mkdirs() }
val extension = when (mimeType.lowercase()) {
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
else -> "jpg"
}
val imageFile = File(imageDir, "book-image.$extension")
imageFile.writeBytes(bytes)
val uri = FileProvider.getUriForFile(
appContext,
"${appContext.packageName}.imageviewer.fileprovider",
imageFile,
)
val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newUri(appContext.contentResolver, label, uri).apply {
description.extras = android.os.PersistableBundle().apply {
putString("mime_type", mimeType)
}
}
clipboard.setPrimaryClip(clip)
true
}.getOrDefault(false)
}
actual fun defaultLibraryScanPath(): String? =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: appContext.getExternalFilesDir(null)?.absolutePath
@ -105,26 +141,68 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
if (path.isContentUri()) {
scanLibraryContentTree(Uri.parse(path), onProgress)
} else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.")
}
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(it.toLibraryScanProgress())
): LibraryScanReport = coroutineScope {
withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
}
val totalFiles = AtomicInteger(-1)
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
fun emitProgress(progress: LibraryScanProgress) {
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
latestProgress.set(enriched)
onProgress(enriched)
}
val totalJob = async(Dispatchers.IO) {
runCatching {
if (path.isContentUri()) {
countContentTree(Uri.parse(path))
} else {
LibraryScanner.countSupportedBookFiles(File(path))
}
}.getOrNull()
}
val totalPublisher = launch(Dispatchers.IO) {
val total = totalJob.await() ?: return@launch
totalFiles.set(total)
emitProgress(latestProgress.get()?.copy(totalFiles = total) ?: LibraryScanProgress(0, 0, 0, 0, totalFiles = total))
}
val report = withContext(Dispatchers.IO) {
if (path.isContentUri()) {
scanLibraryContentTree(Uri.parse(path), ::emitProgress)
} else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.")
}
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
emitProgress(it.toLibraryScanProgress())
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
totalFiles = totalFiles.get().takeIf { it >= 0 },
)
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
)
}
}
totalPublisher.join()
report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
}
private fun countContentTree(rootUri: Uri): Int {
var count = 0
walkContentTree(
rootUri = rootUri,
onVisited = {},
) {
count += 1
}
return count
}
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="clipboard_images" path="clipboard-images/"/>
</paths>

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -92,25 +94,64 @@ private fun AppToast(message: String?, modifier: Modifier = Modifier) {
@Composable
private fun BookReaderApp(onThemeToggle: () -> Unit) {
var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) }
var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) }
var scanJob by remember { mutableStateOf<Job?>(null) }
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
state = loadStartupState()
}
fun startScan(path: String) {
if (scanJob?.isActive == true) return
activeScan = LibraryScanProgress(0, 0, 0, 0)
scanJob = scope.launch {
val report = runCatching {
scanLibrarySubtree(path) { progress ->
scope.launch {
if (scanJob?.isActive == true) activeScan = progress
}
}
}
val message = report.fold(
onSuccess = {
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
},
onFailure = { it.message ?: "Scan failed." },
)
scanJob = null
activeScan = null
state = when (val current = state) {
is AppState.Library, is AppState.Scan, AppState.LoadingLibrary -> loadLibraryState(message, path)
is AppState.Reader -> current.copy(message = message)
is AppState.BookInfo -> current.copy(message = message)
is AppState.Error -> current
}
}
}
when (val current = state) {
AppState.LoadingLibrary -> LoadingScreen("Opening library")
is AppState.Library -> LibraryScreen(
state = current,
activeScan = activeScan,
onStateChange = { state = it },
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
)
is AppState.Scan -> ScanScreen(
state = current,
activeScan = activeScan,
onStateChange = { state = it },
onStartScan = { path ->
startScan(path)
state = AppState.Library(current.items, path, "Scanning...")
},
)
is AppState.Reader -> BookView(
fileId = current.fileId,
book = current.book,
onImageOpen = { imageViewer = it },
onThemeToggle = onThemeToggle,
onBookInfo = {
state = AppState.BookInfo(
@ -128,6 +169,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
is AppState.BookInfo -> BookInfoScreen(
fileId = current.fileId,
book = current.book,
onImageOpen = { imageViewer = it },
onBack = {
state = AppState.Reader(
fileId = current.fileId,
@ -140,4 +182,18 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
)
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
}
imageViewer?.let { image ->
ImageViewer(
image = image,
onBack = { imageViewer = null },
)
}
}
internal data class ViewedBookImage(
val bitmap: ImageBitmap,
val bytes: ByteArray,
val mimeType: String,
val title: String,
)

View File

@ -39,6 +39,7 @@ import net.sergeych.toread.fb2.Fb2Book
internal fun BookInfoScreen(
fileId: String,
book: Fb2Book,
onImageOpen: (ViewedBookImage) -> Unit,
onBack: () -> Unit,
) {
val stats = remember(book) { BookStats.from(book) }
@ -73,7 +74,7 @@ internal fun BookInfoScreen(
) {
item {
InfoSection("Title Info") {
CoverAndTitle(book)
CoverAndTitle(book, onImageOpen = onImageOpen)
DetailLine("Title", book.title)
DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" })
DetailLine("Language", book.language?.uppercase() ?: "Not specified")

View File

@ -0,0 +1,190 @@
package net.sergeych.toread
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ZoomIn
import androidx.compose.material.icons.filled.ZoomOut
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.focusable
import kotlinx.coroutines.launch
private const val MinImageScale = 1f
private const val MaxImageScale = 8f
@Composable
internal fun ImageViewer(
image: ViewedBookImage,
onBack: () -> Unit,
) {
var scale by remember(image) { mutableStateOf(MinImageScale) }
var offset by remember(image) { mutableStateOf(Offset.Zero) }
val focusRequester = remember { FocusRequester() }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun setScale(next: Float) {
scale = next.coerceIn(MinImageScale, MaxImageScale)
if (scale == MinImageScale) offset = Offset.Zero
}
fun panBy(delta: Offset) {
if (scale > MinImageScale) {
offset += delta
} else {
offset = Offset.Zero
}
}
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
setScale(scale * zoomChange)
panBy(panChange)
}
fun copyImage() {
scope.launch {
val message = if (copyImageToClipboard(image.bytes, image.mimeType, image.title)) {
"Image copied"
} else {
"Copy failed"
}
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
}
}
LaunchedEffect(image) {
focusRequester.requestFocus()
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
modifier = Modifier.fillMaxWidth().height(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
image.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { setScale(scale * 1.25f) }) {
Icon(Icons.Filled.ZoomIn, contentDescription = "Zoom in")
}
IconButton(onClick = { setScale(scale / 1.25f) }, enabled = scale > MinImageScale) {
Icon(Icons.Filled.ZoomOut, contentDescription = "Zoom out")
}
IconButton(
onClick = ::copyImage,
) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy image")
}
}
}
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color.Black)
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when {
event.key == Key.Escape -> {
onBack()
true
}
event.key == Key.Plus || event.key == Key.Equals -> {
setScale(scale * 1.25f)
true
}
event.key == Key.Minus -> {
setScale(scale / 1.25f)
true
}
event.key == Key.C && (event.isCtrlPressed || event.isMetaPressed) -> {
copyImage()
true
}
else -> false
}
}
.focusable()
.transformable(transformState)
.pointerInput(image, scale) {
detectDragGestures { change, dragAmount ->
if (change.positionChange() != Offset.Zero) change.consume()
panBy(dragAmount)
}
},
contentAlignment = Alignment.Center,
) {
Image(
bitmap = image.bitmap,
contentDescription = image.title,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
contentScale = ContentScale.Fit,
)
}
}
}

View File

@ -31,6 +31,7 @@ data class LibraryScanReport(
val importedFiles: Int,
val skippedFiles: Int,
val failedFiles: Int,
val totalFiles: Int? = null,
)
data class LibraryScanProgress(
@ -39,6 +40,7 @@ data class LibraryScanProgress(
val skippedFiles: Int,
val failedFiles: Int,
val currentFile: String? = null,
val totalFiles: Int? = null,
)
data class PlatformOpenBookRequest(

View File

@ -54,12 +54,14 @@ import androidx.compose.ui.unit.dp
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun LibraryScreen(
state: AppState.Library,
activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit,
) {
@ -70,6 +72,7 @@ internal fun LibraryScreen(
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) }
var wasScanning by remember { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
suspend fun loadPage(reset: Boolean = false) {
@ -104,6 +107,19 @@ internal fun LibraryScreen(
if (items.isEmpty() && !endReached) loadPage(reset = true)
}
LaunchedEffect(activeScan != null) {
if (activeScan != null) {
wasScanning = true
while (true) {
delay(5_000)
loadPage(reset = true)
}
} else if (wasScanning) {
wasScanning = false
loadPage(reset = true)
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
@ -140,7 +156,7 @@ internal fun LibraryScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(0.dp),
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
@ -264,6 +280,14 @@ internal fun LibraryScreen(
}
}
}
activeScan?.let { progress ->
LibraryScanStatusPanel(
progress = progress,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp),
)
}
}
}
}
@ -291,6 +315,36 @@ private fun LibrarySectionHeader(text: String) {
)
}
@Composable
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
Card(
shape = RoundedCornerShape(8.dp),
colors = quietCardColors(),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.width(22.dp).height(22.dp), strokeWidth = 2.dp)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
progress.toCatalogScanMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
"Imported ${progress.importedFiles}, skipped ${progress.skippedFiles}, failed ${progress.failedFiles}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
@Composable
private fun LibraryRow(
item: LibraryItem,
@ -454,4 +508,14 @@ private fun Long.formatBytes(): String =
else -> "$this B"
}
private fun LibraryScanProgress.toCatalogScanMessage(): String {
val total = totalFiles ?: return "Scanned $scannedFiles books"
val percent = if (total <= 0) {
100
} else {
((scannedFiles.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100)
}
return "Scanned $scannedFiles of $total, $percent% done"
}
private const val LibraryPageSize: Int = 50

View File

@ -15,3 +15,5 @@ expect fun loadDefaultBookBytes(): ByteArray?
expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap?
expect fun decodeImageBytes(bytes: ByteArray): ImageBitmap?
expect suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean

View File

@ -2,6 +2,7 @@ package net.sergeych.toread
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -75,6 +76,7 @@ internal fun ContinuousBookReader(
stats: BookStats,
listState: LazyListState,
modifier: Modifier = Modifier,
onImageOpen: (ViewedBookImage) -> Unit = {},
) {
val hyphenation = remember { HyphenationRegistry() }
val contentPadding = if (isAndroidPlatform()) {
@ -92,7 +94,7 @@ internal fun ContinuousBookReader(
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
CoverAndTitle(book)
CoverAndTitle(book, onImageOpen = onImageOpen)
MetadataCard(book)
StatsCard(stats)
}
@ -107,6 +109,7 @@ internal fun ContinuousBookReader(
depth = 0,
keyPrefix = "section-$index",
hyphenation = hyphenation,
onImageOpen = onImageOpen,
)
}
item { Spacer(Modifier.height(22.dp)) }
@ -119,6 +122,7 @@ private fun LazyListScope.sectionItems(
depth: Int,
keyPrefix: String,
hyphenation: HyphenationRegistry,
onImageOpen: (ViewedBookImage) -> Unit,
) {
if( section.title.isNullOrBlank() ) {
item {
@ -153,6 +157,7 @@ private fun LazyListScope.sectionItems(
image = block.image,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
contentScale = ContentScale.Fit,
onOpen = onImageOpen,
)
is Fb2Block.Paragraph -> ReaderText(
text = block.content,
@ -181,6 +186,7 @@ private fun LazyListScope.sectionItems(
depth = depth + 1,
keyPrefix = "$keyPrefix-$index",
hyphenation = hyphenation,
onImageOpen = onImageOpen,
)
}
}
@ -232,7 +238,7 @@ private fun DetailsPane(
}
@Composable
internal fun CoverAndTitle(book: Fb2Book) {
internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit = {}) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
@ -243,6 +249,7 @@ internal fun CoverAndTitle(book: Fb2Book) {
image = book.coverImages.firstOrNull() ?: book.bodyImages.firstOrNull(),
modifier = Modifier.width(112.dp).aspectRatio(0.68f),
contentScale = ContentScale.Crop,
onOpen = onImageOpen,
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
@ -428,20 +435,41 @@ private fun BookImage(
image: Fb2ImageRef?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
onOpen: (ViewedBookImage) -> Unit = {},
) {
val bitmap = remember(book, image) {
image?.let(book::binaryFor)?.let { decodeBookImage(it) }
val binary = remember(book, image) {
image?.let(book::binaryFor)
}
val bitmap = remember(binary) {
binary?.let { decodeBookImage(it) }
}
val imageTitle = image?.alt?.ifBlank { null } ?: book.title
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
.background(MaterialTheme.colorScheme.surfaceVariant)
.then(
if (bitmap != null && binary != null) {
Modifier.clickable {
onOpen(
ViewedBookImage(
bitmap = bitmap,
bytes = binary.imageBytes(),
mimeType = binary.contentType,
title = imageTitle,
),
)
}
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = image?.alt ?: book.title,
contentDescription = imageTitle,
modifier = Modifier.fillMaxWidth(),
contentScale = contentScale,
)

View File

@ -45,6 +45,7 @@ import kotlinx.coroutines.launch
internal fun BookView(
fileId: String,
book: Fb2Book,
onImageOpen: (ViewedBookImage) -> Unit,
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onBack: () -> Unit,
@ -128,6 +129,7 @@ internal fun BookView(
stats = stats,
modifier = Modifier.fillMaxSize(),
listState = listState,
onImageOpen = onImageOpen,
)
}
}

View File

@ -44,13 +44,14 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
internal fun ScanScreen(
state: AppState.Scan,
activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit,
onStartScan: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
var scanPath by remember(state.scanPath) { mutableStateOf(state.scanPath) }
var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var scanProgress by remember { mutableStateOf<LibraryScanProgress?>(null) }
val busy = activeScan != null
Scaffold(
topBar = {
@ -92,28 +93,8 @@ internal fun ScanScreen(
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = {
scope.launch {
busy = true
scanProgress = null
try {
message = "Scanning..."
val report = runCatching {
scanLibrarySubtree(scanPath) { progress ->
scope.launch { scanProgress = progress }
}
}
val nextMessage = report.fold(
onSuccess = {
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
},
onFailure = { it.message ?: "Scan failed." },
)
onStateChange(loadLibraryState(nextMessage, scanPath))
} finally {
busy = false
scanProgress = null
}
}
message = "Scanning..."
onStartScan(scanPath)
},
enabled = !busy && scanPath.isNotBlank(),
) {
@ -137,14 +118,12 @@ internal fun ScanScreen(
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
}
}
if (busy) {
scanProgress?.let { progress ->
Text(
progress.toScanMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
if (activeScan != null) {
Text(
activeScan.toScanMessage(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
message?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)

View File

@ -10,15 +10,26 @@ 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.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.io.File
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser
import kotlin.concurrent.thread
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
actual fun loadDefaultBookBytes(): ByteArray? {
@ -37,6 +48,22 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext false
val transferable = object : Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.imageFlavor)
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor
override fun getTransferData(flavor: DataFlavor): Any {
if (!isDataFlavorSupported(flavor)) throw UnsupportedFlavorException(flavor)
return image
}
}
Toolkit.getDefaultToolkit().systemClipboard.setContents(transferable, null)
true
}.getOrDefault(false)
}
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
@ -79,27 +106,53 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(
LibraryScanProgress(
scannedFiles = it.scannedFiles,
importedFiles = it.importedFiles,
skippedFiles = it.skippedFiles,
failedFiles = it.failedFiles,
currentFile = it.currentFile,
): LibraryScanReport = coroutineScope {
withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
}
val root = File(path)
val totalFiles = AtomicInteger(-1)
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
fun emitProgress(progress: LibraryScanProgress) {
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
latestProgress.set(enriched)
onProgress(enriched)
}
val totalJob = async(Dispatchers.IO) {
runCatching { LibraryScanner.countSupportedBookFiles(root) }.getOrNull()
}
val totalPublisher = launch(Dispatchers.IO) {
val total = totalJob.await() ?: return@launch
totalFiles.set(total)
emitProgress(latestProgress.get()?.copy(totalFiles = total) ?: LibraryScanProgress(0, 0, 0, 0, totalFiles = total))
}
val report = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(root) {
emitProgress(
LibraryScanProgress(
scannedFiles = it.scannedFiles,
importedFiles = it.importedFiles,
skippedFiles = it.skippedFiles,
failedFiles = it.failedFiles,
currentFile = it.currentFile,
)
)
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
totalFiles = totalFiles.get().takeIf { it >= 0 },
)
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
)
}
totalPublisher.join()
report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
}
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {

View File

@ -12,6 +12,8 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? = null
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = null
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = false
actual fun defaultLibraryScanPath(): String? = null
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null

View File

@ -94,6 +94,7 @@ data class BookFileRecord(
val bookId: String? = null,
val bodyId: String? = null,
val bodyClusterId: String? = null,
val duplicateOfFileId: String? = null,
val rawSha256: String,
val format: String? = null,
val mimeType: String? = null,
@ -194,6 +195,10 @@ interface BookFileRepository {
fun get(id: String): BookFileRecord?
fun getLibraryFile(id: String): LibraryFileRecord?
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
fun findByOriginalFilenameSizeAndModified(originalFilename: String, sizeBytes: Long, lastModifiedMillis: Long): List<BookFileRecord>
fun findByOriginalFilenameSizeAndRawSha256(originalFilename: String, sizeBytes: Long, rawSha256: String): List<BookFileRecord>
fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord?
fun markDuplicateFiles(): Int
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>

View File

@ -199,6 +199,7 @@ private fun migrate(connection: Connection) {
book_id VARCHAR,
body_id VARCHAR,
body_cluster_id VARCHAR,
duplicate_of_file_id VARCHAR,
raw_sha256 VARCHAR NOT NULL,
format VARCHAR,
mime_type VARCHAR,
@ -219,10 +220,14 @@ private fun migrate(connection: Connection) {
)
""".trimIndent()
)
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR")
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_duplicate", "CREATE INDEX IF NOT EXISTS idx_book_files_duplicate ON book_files(duplicate_of_file_id)")
connection.createIndexIfMissing("idx_book_files_name_size_mtime", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_mtime ON book_files(original_filename, size_bytes, last_modified_millis)")
connection.createIndexIfMissing("idx_book_files_name_size_hash", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_hash ON book_files(original_filename, size_bytes, raw_sha256)")
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(
@ -470,31 +475,32 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
connection.prepareStatement(
"""
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
id, book_id, body_id, body_cluster_id, duplicate_of_file_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
)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setString(1, file.id)
statement.setStringOrNull(2, file.bookId)
statement.setStringOrNull(3, file.bodyId)
statement.setStringOrNull(4, file.bodyClusterId)
statement.setString(5, file.rawSha256)
statement.setStringOrNull(6, file.format)
statement.setStringOrNull(7, file.mimeType)
statement.setLongOrNull(8, file.sizeBytes)
statement.setStringOrNull(9, file.originalFilename)
statement.setString(10, file.storageKind.name)
statement.setStringOrNull(11, file.storageUri)
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.setStringOrNull(5, file.duplicateOfFileId)
statement.setString(6, file.rawSha256)
statement.setStringOrNull(7, file.format)
statement.setStringOrNull(8, file.mimeType)
statement.setLongOrNull(9, file.sizeBytes)
statement.setStringOrNull(10, file.originalFilename)
statement.setString(11, file.storageKind.name)
statement.setStringOrNull(12, file.storageUri)
statement.setStringOrNull(13, file.contentObjectId)
statement.setLongOrNull(14, file.lastModifiedMillis)
statement.setLongOrNull(15, file.lastSeenAt)
statement.setString(16, file.readingStatus.name)
statement.setLongOrNull(17, file.lastReadAt)
statement.setLong(18, file.createdAt)
statement.setLong(19, file.updatedAt)
statement.executeUpdate()
}
}
@ -534,27 +540,133 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
}
}
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
override fun findByOriginalFilenameSizeAndModified(
originalFilename: String,
sizeBytes: Long,
lastModifiedMillis: Long,
): List<BookFileRecord> {
return connection.prepareStatement(
"""
WITH visible_files AS (
SELECT * FROM book_files
WHERE original_filename = ?
AND size_bytes = ?
AND last_modified_millis = ?
ORDER BY created_at
""".trimIndent()
).use { statement ->
statement.setString(1, originalFilename)
statement.setLong(2, sizeBytes)
statement.setLong(3, lastModifiedMillis)
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
}
}
override fun findByOriginalFilenameSizeAndRawSha256(
originalFilename: String,
sizeBytes: Long,
rawSha256: String,
): List<BookFileRecord> {
return connection.prepareStatement(
"""
SELECT * FROM book_files
WHERE original_filename = ?
AND size_bytes = ?
AND raw_sha256 = ?
ORDER BY created_at
""".trimIndent()
).use { statement ->
statement.setString(1, originalFilename)
statement.setLong(2, sizeBytes)
statement.setString(3, rawSha256)
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
}
}
override fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord? {
return connection.prepareStatement(
"""
SELECT * FROM book_files
WHERE duplicate_of_file_id IS NULL
AND (
(? IS NOT NULL AND body_cluster_id = ?)
OR (? IS NOT NULL AND body_id = ?)
OR raw_sha256 = ?
)
ORDER BY
CASE
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
THEN 0 ELSE 1
END,
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
last_read_at DESC NULLS LAST,
updated_at DESC,
id
LIMIT 1
""".trimIndent()
).use { statement ->
statement.setStringOrNull(1, bodyClusterId)
statement.setStringOrNull(2, bodyClusterId)
statement.setStringOrNull(3, bodyId)
statement.setStringOrNull(4, bodyId)
statement.setString(5, rawSha256)
statement.executeQuery().use { resultSet ->
if (resultSet.next()) resultSet.toBookFileRecord() else null
}
}
}
override fun markDuplicateFiles(): Int {
val now = System.currentTimeMillis()
return connection.prepareStatement(
"""
MERGE INTO book_files(id, duplicate_of_file_id, updated_at)
KEY(id)
SELECT id, primary_file_id, ?
FROM (
SELECT
f.*,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(f.body_cluster_id, f.body_id, f.book_id, f.raw_sha256, f.id)
id,
FIRST_VALUE(id) OVER (
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
ORDER BY
CASE
WHEN LOWER(COALESCE(f.format, '')) = 'fb2.zip'
OR LOWER(COALESCE(f.original_filename, '')) LIKE '%.fb2.zip'
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
OR LOWER(COALESCE(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
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
last_read_at DESC NULLS LAST,
updated_at DESC,
id
) AS primary_file_id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
ORDER BY
CASE
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
THEN 0 ELSE 1
END,
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
last_read_at DESC NULLS LAST,
updated_at DESC,
id
) AS duplicate_rank
FROM book_files f
)
FROM book_files
WHERE duplicate_of_file_id IS NULL
) ranked
WHERE duplicate_rank > 1
""".trimIndent()
).use { statement ->
statement.setLong(1, now)
statement.executeUpdate()
}
}
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
markDuplicateFiles()
return connection.prepareStatement(
"""
SELECT
f.id AS file_id,
f.book_id AS book_id,
@ -569,9 +681,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
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
FROM book_files f
LEFT JOIN books b ON b.id = f.book_id
WHERE f.duplicate_rank = 1
WHERE f.duplicate_of_file_id IS NULL
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,
@ -836,6 +948,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
bookId = getString("book_id"),
bodyId = getString("body_id"),
bodyClusterId = getString("body_cluster_id"),
duplicateOfFileId = getString("duplicate_of_file_id"),
rawSha256 = getString("raw_sha256"),
format = getString("format"),
mimeType = getString("mime_type"),

View File

@ -82,34 +82,65 @@ class LibraryScanner(
lastModifiedMillis: Long?,
): Boolean {
val rawSha256 = bytes.sha256Hex()
if (database.files.findByRawSha256(rawSha256).isNotEmpty()) return false
if (sizeBytes != null && database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).isNotEmpty()) {
return false
}
val now = System.currentTimeMillis()
database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget ->
database.files.upsert(
BookFileRecord(
id = "file-${UUID.randomUUID()}",
bookId = duplicateTarget.bookId,
bodyId = duplicateTarget.bodyId,
bodyClusterId = duplicateTarget.bodyClusterId,
duplicateOfFileId = duplicateTarget.id,
rawSha256 = rawSha256,
format = displayName.bookFormat(),
mimeType = displayName.bookMimeType(),
sizeBytes = sizeBytes,
originalFilename = displayName,
storageKind = BookFileStorageKind.EXTERNAL_URI,
storageUri = storageUri,
lastModifiedMillis = lastModifiedMillis,
lastSeenAt = now,
createdAt = now,
updatedAt = now,
)
)
return true
}
val book = Fb2Format.parse(bytes, displayName)
val cover = book.coverImage()
val canonicalText = book.canonicalText()
val bodyHash = canonicalText.encodeToByteArray().sha256Hex()
val now = System.currentTimeMillis()
val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion)
val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}"
val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) }
val clusterId = knownCluster?.id ?: "cluster-${UUID.randomUUID()}"
val bookId = "book-${UUID.randomUUID()}"
val duplicateTarget = knownBody?.let {
database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256)
}
val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}"
database.transaction {
books.upsert(
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,
createdAt = now,
updatedAt = now,
if (duplicateTarget == null || duplicateTarget.bookId == null) {
books.upsert(
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,
createdAt = now,
updatedAt = now,
)
)
)
}
if (knownBody == null) {
bodies.upsert(
BookBodyRecord(
@ -143,6 +174,7 @@ class LibraryScanner(
bookId = bookId,
bodyId = bodyId,
bodyClusterId = clusterId,
duplicateOfFileId = duplicateTarget?.id,
rawSha256 = rawSha256,
format = displayName.bookFormat(),
mimeType = displayName.bookMimeType(),
@ -160,14 +192,27 @@ class LibraryScanner(
return true
}
private fun importLinkedFile(file: File): Boolean =
importExternalFile(
private fun importLinkedFile(file: File): Boolean {
val sizeBytes = file.length()
val lastModifiedMillis = file.lastModified()
if (database.files.findByOriginalFilenameSizeAndModified(file.name, sizeBytes, lastModifiedMillis).isNotEmpty()) {
return false
}
return importExternalFile(
bytes = file.readBytes(),
displayName = file.name,
storageUri = file.absolutePath,
sizeBytes = file.length(),
lastModifiedMillis = file.lastModified(),
sizeBytes = sizeBytes,
lastModifiedMillis = lastModifiedMillis,
)
}
companion object {
fun countSupportedBookFiles(root: File): Int {
require(root.isDirectory) { "Scan root is not a directory: ${root.path}" }
return root.walkTopDown().count { it.isFile && it.isSupportedBookFile() }
}
}
}
private const val CanonicalizationVersion = 1

View File

@ -1,9 +1,11 @@
package net.sergeych.toread.storage.jdbc
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class LibraryScannerTest {
@ -35,6 +37,61 @@ class LibraryScannerTest {
}
}
@Test
fun scanMarksImportedDuplicateFilesImmediately() {
val root = findProjectRoot()
val source = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip")
val tempDir = Files.createTempDirectory("yereed-dupe-scan").toFile()
try {
source.copyTo(File(tempDir, source.name))
source.copyTo(File(tempDir, "same-book-different-name.fb2.zip"))
val db = H2LibraryDatabase.openMemory("scanMarksImportedDuplicateFilesImmediately")
try {
val summary = LibraryScanner(db).scanSubtree(tempDir)
assertEquals(2, summary.scannedFiles)
assertEquals(2, summary.importedFiles)
assertEquals(0, summary.skippedFiles)
val files = db.files.list().sortedBy { it.duplicateOfFileId != null }
assertEquals(2, files.size)
assertNull(files.first().duplicateOfFileId)
assertEquals(files.first().id, files.last().duplicateOfFileId)
assertEquals(1, db.files.listLibraryFiles().size)
val second = LibraryScanner(db).scanSubtree(tempDir)
assertEquals(0, second.importedFiles)
assertEquals(2, second.skippedFiles)
} finally {
db.close()
}
} finally {
tempDir.deleteRecursively()
}
}
@Test
fun listingLibraryFilesPersistsLegacyDuplicateMarkers() {
val root = findProjectRoot()
val bytes = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip").readBytes()
val db = H2LibraryDatabase.openMemory("listingLibraryFilesPersistsLegacyDuplicateMarkers")
try {
val scanner = LibraryScanner(db)
assertTrue(scanner.importExternalFile(bytes, "primary.fb2.zip", "/tmp/primary.fb2.zip", bytes.size.toLong(), 1L))
assertTrue(scanner.importExternalFile(bytes, "secondary.fb2.zip", "/tmp/secondary.fb2.zip", bytes.size.toLong(), 2L))
val duplicate = db.files.list().single { it.duplicateOfFileId != null }
db.files.upsert(duplicate.copy(duplicateOfFileId = null))
assertEquals(2, db.files.list().count { it.duplicateOfFileId == null })
assertEquals(1, db.files.listLibraryFiles().size)
assertEquals(1, db.files.list().count { it.duplicateOfFileId != null })
} finally {
db.close()
}
}
private fun findProjectRoot(): File {
var current = File(System.getProperty("user.dir")).absoluteFile
while (true) {