diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index db8b232..e2975bf 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -43,6 +43,15 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index 666ecda..e94125f 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -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(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) { diff --git a/composeApp/src/androidMain/res/xml/image_clipboard_paths.xml b/composeApp/src/androidMain/res/xml/image_clipboard_paths.xml new file mode 100644 index 0000000..9a88aa2 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/image_clipboard_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index dc808f0..0f6272d 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -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.LoadingLibrary) } + var activeScan by remember { mutableStateOf(null) } + var scanJob by remember { mutableStateOf(null) } + var imageViewer by remember { mutableStateOf(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, +) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt index 4a99cc4..f2dd2f4 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt @@ -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") diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ImageViewer.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ImageViewer.kt new file mode 100644 index 0000000..32f10a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ImageViewer.kt @@ -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, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 65d4052..953389f 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 6cf3f48..af56de0 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -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() } 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 diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/PlatformImages.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/PlatformImages.kt index a3c87de..0f4d8ff 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/PlatformImages.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/PlatformImages.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index 38cfb1c..3f25f02 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index cf0ea08..2e16401 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt index 6454e13..491d681 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ScanScreen.kt @@ -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(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) diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index 7f09864..f9d3c37 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -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 = 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(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) { diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index aef713b..174609f 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt index 39fac47..488d2e8 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/storage/LibraryStorage.kt @@ -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 + fun findByOriginalFilenameSizeAndModified(originalFilename: String, sizeBytes: Long, lastModifiedMillis: Long): List + fun findByOriginalFilenameSizeAndRawSha256(originalFilename: String, sizeBytes: Long, rawSha256: String): List + fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord? + fun markDuplicateFiles(): Int fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List fun list(limit: Int = 500, offset: Int = 0): List fun listForBook(bookId: String): List diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt index fb31c22..4c43f7e 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt @@ -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 { + override fun findByOriginalFilenameSizeAndModified( + originalFilename: String, + sizeBytes: Long, + lastModifiedMillis: Long, + ): List { 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 { + 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 { + 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"), diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt index 543f816..028d705 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt @@ -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 diff --git a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt index 058caa2..2db168d 100644 --- a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt +++ b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/LibraryScannerTest.kt @@ -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) {