+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"/> <data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>

View File

@ -1,5 +1,7 @@
package net.sergeych.toread package net.sergeych.toread
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ComponentCallbacks import android.content.ComponentCallbacks
import android.content.Context import android.content.Context
@ -12,6 +14,7 @@ import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import net.sergeych.toread.fb2.Fb2Binary import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.BookReadingStatus
import net.sergeych.toread.storage.ContentAnchor import net.sergeych.toread.storage.ContentAnchor
@ -24,7 +27,12 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale 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.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private lateinit var appContext: Context private lateinit var appContext: Context
@ -54,6 +62,34 @@ actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull() }.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? = actual fun defaultLibraryScanPath(): String? =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: appContext.getExternalFilesDir(null)?.absolutePath ?: appContext.getExternalFilesDir(null)?.absolutePath
@ -105,26 +141,68 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
actual suspend fun scanLibrarySubtree( actual suspend fun scanLibrarySubtree(
path: String, path: String,
onProgress: (LibraryScanProgress) -> Unit, onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) { ): LibraryScanReport = coroutineScope {
withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path") 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()) { if (path.isContentUri()) {
scanLibraryContentTree(Uri.parse(path), onProgress) 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 { } else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) { if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.") error("All files access is required to scan $path.")
} }
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) { val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(it.toLibraryScanProgress()) emitProgress(it.toLibraryScanProgress())
} }
LibraryScanReport( LibraryScanReport(
scannedFiles = summary.scannedFiles, scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles, importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles, skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles, failedFiles = summary.failedFiles,
totalFiles = totalFiles.get().takeIf { it >= 0 },
) )
} }
} }
}
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) { 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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -92,25 +94,64 @@ private fun AppToast(message: String?, modifier: Modifier = Modifier) {
@Composable @Composable
private fun BookReaderApp(onThemeToggle: () -> Unit) { private fun BookReaderApp(onThemeToggle: () -> Unit) {
var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) } 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) { LaunchedEffect(Unit) {
state = loadStartupState() 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) { when (val current = state) {
AppState.LoadingLibrary -> LoadingScreen("Opening library") AppState.LoadingLibrary -> LoadingScreen("Opening library")
is AppState.Library -> LibraryScreen( is AppState.Library -> LibraryScreen(
state = current, state = current,
activeScan = activeScan,
onStateChange = { state = it }, onStateChange = { state = it },
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) }, onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
) )
is AppState.Scan -> ScanScreen( is AppState.Scan -> ScanScreen(
state = current, state = current,
activeScan = activeScan,
onStateChange = { state = it }, onStateChange = { state = it },
onStartScan = { path ->
startScan(path)
state = AppState.Library(current.items, path, "Scanning...")
},
) )
is AppState.Reader -> BookView( is AppState.Reader -> BookView(
fileId = current.fileId, fileId = current.fileId,
book = current.book, book = current.book,
onImageOpen = { imageViewer = it },
onThemeToggle = onThemeToggle, onThemeToggle = onThemeToggle,
onBookInfo = { onBookInfo = {
state = AppState.BookInfo( state = AppState.BookInfo(
@ -128,6 +169,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
is AppState.BookInfo -> BookInfoScreen( is AppState.BookInfo -> BookInfoScreen(
fileId = current.fileId, fileId = current.fileId,
book = current.book, book = current.book,
onImageOpen = { imageViewer = it },
onBack = { onBack = {
state = AppState.Reader( state = AppState.Reader(
fileId = current.fileId, fileId = current.fileId,
@ -140,4 +182,18 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
) )
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary }) 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( internal fun BookInfoScreen(
fileId: String, fileId: String,
book: Fb2Book, book: Fb2Book,
onImageOpen: (ViewedBookImage) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val stats = remember(book) { BookStats.from(book) } val stats = remember(book) { BookStats.from(book) }
@ -73,7 +74,7 @@ internal fun BookInfoScreen(
) { ) {
item { item {
InfoSection("Title Info") { InfoSection("Title Info") {
CoverAndTitle(book) CoverAndTitle(book, onImageOpen = onImageOpen)
DetailLine("Title", book.title) DetailLine("Title", book.title)
DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" }) DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" })
DetailLine("Language", book.language?.uppercase() ?: "Not specified") 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 importedFiles: Int,
val skippedFiles: Int, val skippedFiles: Int,
val failedFiles: Int, val failedFiles: Int,
val totalFiles: Int? = null,
) )
data class LibraryScanProgress( data class LibraryScanProgress(
@ -39,6 +40,7 @@ data class LibraryScanProgress(
val skippedFiles: Int, val skippedFiles: Int,
val failedFiles: Int, val failedFiles: Int,
val currentFile: String? = null, val currentFile: String? = null,
val totalFiles: Int? = null,
) )
data class PlatformOpenBookRequest( 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.fb2.Fb2Format
import net.sergeych.toread.storage.BookReadingStatus import net.sergeych.toread.storage.BookReadingStatus
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
internal fun LibraryScreen( internal fun LibraryScreen(
state: AppState.Library, state: AppState.Library,
activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit, onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit, onNavigateToScan: () -> Unit,
) { ) {
@ -70,6 +72,7 @@ internal fun LibraryScreen(
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) } var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
var loadingPage by remember(state.items) { mutableStateOf(false) } var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached 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?>() } val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
suspend fun loadPage(reset: Boolean = false) { suspend fun loadPage(reset: Boolean = false) {
@ -104,6 +107,19 @@ internal fun LibraryScreen(
if (items.isEmpty() && !endReached) loadPage(reset = true) 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( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
@ -140,7 +156,7 @@ internal fun LibraryScreen(
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING 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 @Composable
private fun LibraryRow( private fun LibraryRow(
item: LibraryItem, item: LibraryItem,
@ -454,4 +508,14 @@ private fun Long.formatBytes(): String =
else -> "$this B" 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 private const val LibraryPageSize: Int = 50

View File

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

View File

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

View File

@ -44,13 +44,14 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
internal fun ScanScreen( internal fun ScanScreen(
state: AppState.Scan, state: AppState.Scan,
activeScan: LibraryScanProgress?,
onStateChange: (AppState) -> Unit, onStateChange: (AppState) -> Unit,
onStartScan: (String) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var scanPath by remember(state.scanPath) { mutableStateOf(state.scanPath) } 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 message by remember(state.message) { mutableStateOf(state.message) }
var scanProgress by remember { mutableStateOf<LibraryScanProgress?>(null) } val busy = activeScan != null
Scaffold( Scaffold(
topBar = { topBar = {
@ -92,28 +93,8 @@ internal fun ScanScreen(
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Button( Button(
onClick = { onClick = {
scope.launch {
busy = true
scanProgress = null
try {
message = "Scanning..." message = "Scanning..."
val report = runCatching { onStartScan(scanPath)
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
}
}
}, },
enabled = !busy && scanPath.isNotBlank(), enabled = !busy && scanPath.isNotBlank(),
) { ) {
@ -137,15 +118,13 @@ internal fun ScanScreen(
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
} }
} }
if (busy) { if (activeScan != null) {
scanProgress?.let { progress ->
Text( Text(
progress.toScanMessage(), activeScan.toScanMessage(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} }
}
message?.let { message?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) 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.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.io.File import java.io.File
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale 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.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser import javax.swing.JFileChooser
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
actual fun loadDefaultBookBytes(): ByteArray? { actual fun loadDefaultBookBytes(): ByteArray? {
@ -37,6 +48,22 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull() 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 fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
@ -79,11 +106,33 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
actual suspend fun scanLibrarySubtree( actual suspend fun scanLibrarySubtree(
path: String, path: String,
onProgress: (LibraryScanProgress) -> Unit, onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) { ): LibraryScanReport = coroutineScope {
withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path") 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 -> openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) { val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(root) {
onProgress( emitProgress(
LibraryScanProgress( LibraryScanProgress(
scannedFiles = it.scannedFiles, scannedFiles = it.scannedFiles,
importedFiles = it.importedFiles, importedFiles = it.importedFiles,
@ -98,8 +147,12 @@ actual suspend fun scanLibrarySubtree(
importedFiles = summary.importedFiles, importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles, skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles, failedFiles = summary.failedFiles,
totalFiles = totalFiles.get().takeIf { it >= 0 },
) )
} }
}
totalPublisher.join()
report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
} }
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) { 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 fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = null
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = false
actual fun defaultLibraryScanPath(): String? = null actual fun defaultLibraryScanPath(): String? = null
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null

View File

@ -94,6 +94,7 @@ data class BookFileRecord(
val bookId: String? = null, val bookId: String? = null,
val bodyId: String? = null, val bodyId: String? = null,
val bodyClusterId: String? = null, val bodyClusterId: String? = null,
val duplicateOfFileId: String? = null,
val rawSha256: String, val rawSha256: String,
val format: String? = null, val format: String? = null,
val mimeType: String? = null, val mimeType: String? = null,
@ -194,6 +195,10 @@ interface BookFileRepository {
fun get(id: String): BookFileRecord? fun get(id: String): BookFileRecord?
fun getLibraryFile(id: String): LibraryFileRecord? fun getLibraryFile(id: String): LibraryFileRecord?
fun findByRawSha256(rawSha256: String): List<BookFileRecord> 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 listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord> fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord> fun listForBook(bookId: String): List<BookFileRecord>

View File

@ -199,6 +199,7 @@ private fun migrate(connection: Connection) {
book_id VARCHAR, book_id VARCHAR,
body_id VARCHAR, body_id VARCHAR,
body_cluster_id VARCHAR, body_cluster_id VARCHAR,
duplicate_of_file_id VARCHAR,
raw_sha256 VARCHAR NOT NULL, raw_sha256 VARCHAR NOT NULL,
format VARCHAR, format VARCHAR,
mime_type VARCHAR, mime_type VARCHAR,
@ -219,10 +220,14 @@ private fun migrate(connection: Connection) {
) )
""".trimIndent() """.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 reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT") statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)") connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)") connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
connection.createIndexIfMissing("idx_book_files_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_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)") connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)")
statement.execute( statement.execute(
@ -470,31 +475,32 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
connection.prepareStatement( connection.prepareStatement(
""" """
MERGE INTO book_files( MERGE INTO book_files(
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes, id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format,
original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis, mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id,
last_seen_at, reading_status, last_read_at, created_at, updated_at last_modified_millis, last_seen_at, reading_status, last_read_at, created_at, updated_at
) )
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent() """.trimIndent()
).use { statement -> ).use { statement ->
statement.setString(1, file.id) statement.setString(1, file.id)
statement.setStringOrNull(2, file.bookId) statement.setStringOrNull(2, file.bookId)
statement.setStringOrNull(3, file.bodyId) statement.setStringOrNull(3, file.bodyId)
statement.setStringOrNull(4, file.bodyClusterId) statement.setStringOrNull(4, file.bodyClusterId)
statement.setString(5, file.rawSha256) statement.setStringOrNull(5, file.duplicateOfFileId)
statement.setStringOrNull(6, file.format) statement.setString(6, file.rawSha256)
statement.setStringOrNull(7, file.mimeType) statement.setStringOrNull(7, file.format)
statement.setLongOrNull(8, file.sizeBytes) statement.setStringOrNull(8, file.mimeType)
statement.setStringOrNull(9, file.originalFilename) statement.setLongOrNull(9, file.sizeBytes)
statement.setString(10, file.storageKind.name) statement.setStringOrNull(10, file.originalFilename)
statement.setStringOrNull(11, file.storageUri) statement.setString(11, file.storageKind.name)
statement.setStringOrNull(12, file.contentObjectId) statement.setStringOrNull(12, file.storageUri)
statement.setLongOrNull(13, file.lastModifiedMillis) statement.setStringOrNull(13, file.contentObjectId)
statement.setLongOrNull(14, file.lastSeenAt) statement.setLongOrNull(14, file.lastModifiedMillis)
statement.setString(15, file.readingStatus.name) statement.setLongOrNull(15, file.lastSeenAt)
statement.setLongOrNull(16, file.lastReadAt) statement.setString(16, file.readingStatus.name)
statement.setLong(17, file.createdAt) statement.setLongOrNull(17, file.lastReadAt)
statement.setLong(18, file.updatedAt) statement.setLong(18, file.createdAt)
statement.setLong(19, file.updatedAt)
statement.executeUpdate() 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( return connection.prepareStatement(
""" """
WITH visible_files AS ( SELECT * FROM book_files
SELECT WHERE original_filename = ?
f.*, AND size_bytes = ?
ROW_NUMBER() OVER ( AND last_modified_millis = ?
PARTITION BY COALESCE(f.body_cluster_id, f.body_id, f.book_id, f.raw_sha256, f.id) 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 ORDER BY
CASE CASE
WHEN LOWER(COALESCE(f.format, '')) = 'fb2.zip' WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
OR LOWER(COALESCE(f.original_filename, '')) LIKE '%.fb2.zip' OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
THEN 0 ELSE 1 THEN 0 ELSE 1
END, END,
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END, CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
f.last_read_at DESC NULLS LAST, last_read_at DESC NULLS LAST,
f.updated_at DESC, updated_at DESC,
f.id 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
id,
FIRST_VALUE(id) 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 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 ) 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 SELECT
f.id AS file_id, f.id AS file_id,
f.book_id AS book_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.last_seen_at AS last_seen_at,
f.reading_status AS reading_status, f.reading_status AS reading_status,
f.last_read_at AS last_read_at 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 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 ORDER BY
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END, 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, 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"), bookId = getString("book_id"),
bodyId = getString("body_id"), bodyId = getString("body_id"),
bodyClusterId = getString("body_cluster_id"), bodyClusterId = getString("body_cluster_id"),
duplicateOfFileId = getString("duplicate_of_file_id"),
rawSha256 = getString("raw_sha256"), rawSha256 = getString("raw_sha256"),
format = getString("format"), format = getString("format"),
mimeType = getString("mime_type"), mimeType = getString("mime_type"),

View File

@ -82,20 +82,50 @@ class LibraryScanner(
lastModifiedMillis: Long?, lastModifiedMillis: Long?,
): Boolean { ): Boolean {
val rawSha256 = bytes.sha256Hex() 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 book = Fb2Format.parse(bytes, displayName)
val cover = book.coverImage() val cover = book.coverImage()
val canonicalText = book.canonicalText() val canonicalText = book.canonicalText()
val bodyHash = canonicalText.encodeToByteArray().sha256Hex() val bodyHash = canonicalText.encodeToByteArray().sha256Hex()
val now = System.currentTimeMillis()
val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion) val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion)
val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}" val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}"
val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) } val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) }
val clusterId = knownCluster?.id ?: "cluster-${UUID.randomUUID()}" 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 { database.transaction {
if (duplicateTarget == null || duplicateTarget.bookId == null) {
books.upsert( books.upsert(
BookRecord( BookRecord(
id = bookId, id = bookId,
@ -110,6 +140,7 @@ class LibraryScanner(
updatedAt = now, updatedAt = now,
) )
) )
}
if (knownBody == null) { if (knownBody == null) {
bodies.upsert( bodies.upsert(
BookBodyRecord( BookBodyRecord(
@ -143,6 +174,7 @@ class LibraryScanner(
bookId = bookId, bookId = bookId,
bodyId = bodyId, bodyId = bodyId,
bodyClusterId = clusterId, bodyClusterId = clusterId,
duplicateOfFileId = duplicateTarget?.id,
rawSha256 = rawSha256, rawSha256 = rawSha256,
format = displayName.bookFormat(), format = displayName.bookFormat(),
mimeType = displayName.bookMimeType(), mimeType = displayName.bookMimeType(),
@ -160,14 +192,27 @@ class LibraryScanner(
return true return true
} }
private fun importLinkedFile(file: File): Boolean = private fun importLinkedFile(file: File): Boolean {
importExternalFile( val sizeBytes = file.length()
val lastModifiedMillis = file.lastModified()
if (database.files.findByOriginalFilenameSizeAndModified(file.name, sizeBytes, lastModifiedMillis).isNotEmpty()) {
return false
}
return importExternalFile(
bytes = file.readBytes(), bytes = file.readBytes(),
displayName = file.name, displayName = file.name,
storageUri = file.absolutePath, storageUri = file.absolutePath,
sizeBytes = file.length(), sizeBytes = sizeBytes,
lastModifiedMillis = file.lastModified(), 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 private const val CanonicalizationVersion = 1

View File

@ -1,9 +1,11 @@
package net.sergeych.toread.storage.jdbc package net.sergeych.toread.storage.jdbc
import java.io.File import java.io.File
import java.nio.file.Files
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class LibraryScannerTest { 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 { private fun findProjectRoot(): File {
var current = File(System.getProperty("user.dir")).absoluteFile var current = File(System.getProperty("user.dir")).absoluteFile
while (true) { while (true) {