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) {