+image viewer, better dupes protection and background processing
This commit is contained in:
parent
14c9863a83
commit
ade4fb1896
@ -43,6 +43,15 @@
|
|||||||
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
|
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.imageviewer.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/image_clipboard_paths"/>
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package net.sergeych.toread
|
package net.sergeych.toread
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ComponentCallbacks
|
import android.content.ComponentCallbacks
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@ -12,6 +14,7 @@ import android.provider.DocumentsContract
|
|||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import net.sergeych.toread.fb2.Fb2Binary
|
import net.sergeych.toread.fb2.Fb2Binary
|
||||||
import net.sergeych.toread.storage.BookReadingStatus
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import net.sergeych.toread.storage.ContentAnchor
|
import net.sergeych.toread.storage.ContentAnchor
|
||||||
@ -24,7 +27,12 @@ import java.io.File
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private lateinit var appContext: Context
|
private lateinit var appContext: Context
|
||||||
@ -54,6 +62,34 @@ actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
|
|||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val imageDir = File(appContext.cacheDir, "clipboard-images").also { it.mkdirs() }
|
||||||
|
val extension = when (mimeType.lowercase()) {
|
||||||
|
"image/png" -> "png"
|
||||||
|
"image/gif" -> "gif"
|
||||||
|
"image/webp" -> "webp"
|
||||||
|
else -> "jpg"
|
||||||
|
}
|
||||||
|
val imageFile = File(imageDir, "book-image.$extension")
|
||||||
|
imageFile.writeBytes(bytes)
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
appContext,
|
||||||
|
"${appContext.packageName}.imageviewer.fileprovider",
|
||||||
|
imageFile,
|
||||||
|
)
|
||||||
|
val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = ClipData.newUri(appContext.contentResolver, label, uri).apply {
|
||||||
|
description.extras = android.os.PersistableBundle().apply {
|
||||||
|
putString("mime_type", mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
actual fun defaultLibraryScanPath(): String? =
|
actual fun defaultLibraryScanPath(): String? =
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
|
||||||
?: appContext.getExternalFilesDir(null)?.absolutePath
|
?: appContext.getExternalFilesDir(null)?.absolutePath
|
||||||
@ -105,26 +141,68 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
|
|||||||
actual suspend fun scanLibrarySubtree(
|
actual suspend fun scanLibrarySubtree(
|
||||||
path: String,
|
path: String,
|
||||||
onProgress: (LibraryScanProgress) -> Unit,
|
onProgress: (LibraryScanProgress) -> Unit,
|
||||||
): LibraryScanReport = withContext(Dispatchers.IO) {
|
): LibraryScanReport = coroutineScope {
|
||||||
appendLibraryLog("scan requested path=$path")
|
withContext(Dispatchers.IO) {
|
||||||
if (path.isContentUri()) {
|
appendLibraryLog("scan requested path=$path")
|
||||||
scanLibraryContentTree(Uri.parse(path), onProgress)
|
}
|
||||||
} else {
|
val totalFiles = AtomicInteger(-1)
|
||||||
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
|
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
|
||||||
error("All files access is required to scan $path.")
|
|
||||||
}
|
fun emitProgress(progress: LibraryScanProgress) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
|
||||||
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
|
latestProgress.set(enriched)
|
||||||
onProgress(it.toLibraryScanProgress())
|
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) {
|
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="clipboard_images" path="clipboard-images/"/>
|
||||||
|
</paths>
|
||||||
@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@ -92,25 +94,64 @@ private fun AppToast(message: String?, modifier: Modifier = Modifier) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
||||||
var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) }
|
var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) }
|
||||||
|
var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) }
|
||||||
|
var scanJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
state = loadStartupState()
|
state = loadStartupState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startScan(path: String) {
|
||||||
|
if (scanJob?.isActive == true) return
|
||||||
|
activeScan = LibraryScanProgress(0, 0, 0, 0)
|
||||||
|
scanJob = scope.launch {
|
||||||
|
val report = runCatching {
|
||||||
|
scanLibrarySubtree(path) { progress ->
|
||||||
|
scope.launch {
|
||||||
|
if (scanJob?.isActive == true) activeScan = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val message = report.fold(
|
||||||
|
onSuccess = {
|
||||||
|
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
|
||||||
|
},
|
||||||
|
onFailure = { it.message ?: "Scan failed." },
|
||||||
|
)
|
||||||
|
scanJob = null
|
||||||
|
activeScan = null
|
||||||
|
state = when (val current = state) {
|
||||||
|
is AppState.Library, is AppState.Scan, AppState.LoadingLibrary -> loadLibraryState(message, path)
|
||||||
|
is AppState.Reader -> current.copy(message = message)
|
||||||
|
is AppState.BookInfo -> current.copy(message = message)
|
||||||
|
is AppState.Error -> current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (val current = state) {
|
when (val current = state) {
|
||||||
AppState.LoadingLibrary -> LoadingScreen("Opening library")
|
AppState.LoadingLibrary -> LoadingScreen("Opening library")
|
||||||
is AppState.Library -> LibraryScreen(
|
is AppState.Library -> LibraryScreen(
|
||||||
state = current,
|
state = current,
|
||||||
|
activeScan = activeScan,
|
||||||
onStateChange = { state = it },
|
onStateChange = { state = it },
|
||||||
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
|
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
|
||||||
)
|
)
|
||||||
is AppState.Scan -> ScanScreen(
|
is AppState.Scan -> ScanScreen(
|
||||||
state = current,
|
state = current,
|
||||||
|
activeScan = activeScan,
|
||||||
onStateChange = { state = it },
|
onStateChange = { state = it },
|
||||||
|
onStartScan = { path ->
|
||||||
|
startScan(path)
|
||||||
|
state = AppState.Library(current.items, path, "Scanning...")
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is AppState.Reader -> BookView(
|
is AppState.Reader -> BookView(
|
||||||
fileId = current.fileId,
|
fileId = current.fileId,
|
||||||
book = current.book,
|
book = current.book,
|
||||||
|
onImageOpen = { imageViewer = it },
|
||||||
onThemeToggle = onThemeToggle,
|
onThemeToggle = onThemeToggle,
|
||||||
onBookInfo = {
|
onBookInfo = {
|
||||||
state = AppState.BookInfo(
|
state = AppState.BookInfo(
|
||||||
@ -128,6 +169,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
is AppState.BookInfo -> BookInfoScreen(
|
is AppState.BookInfo -> BookInfoScreen(
|
||||||
fileId = current.fileId,
|
fileId = current.fileId,
|
||||||
book = current.book,
|
book = current.book,
|
||||||
|
onImageOpen = { imageViewer = it },
|
||||||
onBack = {
|
onBack = {
|
||||||
state = AppState.Reader(
|
state = AppState.Reader(
|
||||||
fileId = current.fileId,
|
fileId = current.fileId,
|
||||||
@ -140,4 +182,18 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
|||||||
)
|
)
|
||||||
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
|
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageViewer?.let { image ->
|
||||||
|
ImageViewer(
|
||||||
|
image = image,
|
||||||
|
onBack = { imageViewer = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class ViewedBookImage(
|
||||||
|
val bitmap: ImageBitmap,
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val mimeType: String,
|
||||||
|
val title: String,
|
||||||
|
)
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import net.sergeych.toread.fb2.Fb2Book
|
|||||||
internal fun BookInfoScreen(
|
internal fun BookInfoScreen(
|
||||||
fileId: String,
|
fileId: String,
|
||||||
book: Fb2Book,
|
book: Fb2Book,
|
||||||
|
onImageOpen: (ViewedBookImage) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val stats = remember(book) { BookStats.from(book) }
|
val stats = remember(book) { BookStats.from(book) }
|
||||||
@ -73,7 +74,7 @@ internal fun BookInfoScreen(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
InfoSection("Title Info") {
|
InfoSection("Title Info") {
|
||||||
CoverAndTitle(book)
|
CoverAndTitle(book, onImageOpen = onImageOpen)
|
||||||
DetailLine("Title", book.title)
|
DetailLine("Title", book.title)
|
||||||
DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" })
|
DetailLine("Authors", book.authors.joinToString { it.displayName }.ifBlank { "Unknown author" })
|
||||||
DetailLine("Language", book.language?.uppercase() ?: "Not specified")
|
DetailLine("Language", book.language?.uppercase() ?: "Not specified")
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,7 @@ data class LibraryScanReport(
|
|||||||
val importedFiles: Int,
|
val importedFiles: Int,
|
||||||
val skippedFiles: Int,
|
val skippedFiles: Int,
|
||||||
val failedFiles: Int,
|
val failedFiles: Int,
|
||||||
|
val totalFiles: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LibraryScanProgress(
|
data class LibraryScanProgress(
|
||||||
@ -39,6 +40,7 @@ data class LibraryScanProgress(
|
|||||||
val skippedFiles: Int,
|
val skippedFiles: Int,
|
||||||
val failedFiles: Int,
|
val failedFiles: Int,
|
||||||
val currentFile: String? = null,
|
val currentFile: String? = null,
|
||||||
|
val totalFiles: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlatformOpenBookRequest(
|
data class PlatformOpenBookRequest(
|
||||||
|
|||||||
@ -54,12 +54,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
import net.sergeych.toread.fb2.Fb2Format
|
import net.sergeych.toread.fb2.Fb2Format
|
||||||
import net.sergeych.toread.storage.BookReadingStatus
|
import net.sergeych.toread.storage.BookReadingStatus
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
internal fun LibraryScreen(
|
internal fun LibraryScreen(
|
||||||
state: AppState.Library,
|
state: AppState.Library,
|
||||||
|
activeScan: LibraryScanProgress?,
|
||||||
onStateChange: (AppState) -> Unit,
|
onStateChange: (AppState) -> Unit,
|
||||||
onNavigateToScan: () -> Unit,
|
onNavigateToScan: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -70,6 +72,7 @@ internal fun LibraryScreen(
|
|||||||
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
|
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
|
||||||
var loadingPage by remember(state.items) { mutableStateOf(false) }
|
var loadingPage by remember(state.items) { mutableStateOf(false) }
|
||||||
var endReached by remember(state.items) { mutableStateOf(false) }
|
var endReached by remember(state.items) { mutableStateOf(false) }
|
||||||
|
var wasScanning by remember { mutableStateOf(false) }
|
||||||
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
||||||
|
|
||||||
suspend fun loadPage(reset: Boolean = false) {
|
suspend fun loadPage(reset: Boolean = false) {
|
||||||
@ -104,6 +107,19 @@ internal fun LibraryScreen(
|
|||||||
if (items.isEmpty() && !endReached) loadPage(reset = true)
|
if (items.isEmpty() && !endReached) loadPage(reset = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activeScan != null) {
|
||||||
|
if (activeScan != null) {
|
||||||
|
wasScanning = true
|
||||||
|
while (true) {
|
||||||
|
delay(5_000)
|
||||||
|
loadPage(reset = true)
|
||||||
|
}
|
||||||
|
} else if (wasScanning) {
|
||||||
|
wasScanning = false
|
||||||
|
loadPage(reset = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
@ -140,7 +156,7 @@ internal fun LibraryScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(0.dp),
|
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
||||||
@ -264,6 +280,14 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activeScan?.let { progress ->
|
||||||
|
LibraryScanStatusPanel(
|
||||||
|
progress = progress,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -291,6 +315,36 @@ private fun LibrarySectionHeader(text: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = quietCardColors(),
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.width(22.dp).height(22.dp), strokeWidth = 2.dp)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
progress.toCatalogScanMessage(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Imported ${progress.importedFiles}, skipped ${progress.skippedFiles}, failed ${progress.failedFiles}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibraryRow(
|
private fun LibraryRow(
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
@ -454,4 +508,14 @@ private fun Long.formatBytes(): String =
|
|||||||
else -> "$this B"
|
else -> "$this B"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun LibraryScanProgress.toCatalogScanMessage(): String {
|
||||||
|
val total = totalFiles ?: return "Scanned $scannedFiles books"
|
||||||
|
val percent = if (total <= 0) {
|
||||||
|
100
|
||||||
|
} else {
|
||||||
|
((scannedFiles.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
return "Scanned $scannedFiles of $total, $percent% done"
|
||||||
|
}
|
||||||
|
|
||||||
private const val LibraryPageSize: Int = 50
|
private const val LibraryPageSize: Int = 50
|
||||||
|
|||||||
@ -15,3 +15,5 @@ expect fun loadDefaultBookBytes(): ByteArray?
|
|||||||
expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap?
|
expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap?
|
||||||
|
|
||||||
expect fun decodeImageBytes(bytes: ByteArray): ImageBitmap?
|
expect fun decodeImageBytes(bytes: ByteArray): ImageBitmap?
|
||||||
|
|
||||||
|
expect suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package net.sergeych.toread
|
|||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -75,6 +76,7 @@ internal fun ContinuousBookReader(
|
|||||||
stats: BookStats,
|
stats: BookStats,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onImageOpen: (ViewedBookImage) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val hyphenation = remember { HyphenationRegistry() }
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
val contentPadding = if (isAndroidPlatform()) {
|
val contentPadding = if (isAndroidPlatform()) {
|
||||||
@ -92,7 +94,7 @@ internal fun ContinuousBookReader(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
CoverAndTitle(book)
|
CoverAndTitle(book, onImageOpen = onImageOpen)
|
||||||
MetadataCard(book)
|
MetadataCard(book)
|
||||||
StatsCard(stats)
|
StatsCard(stats)
|
||||||
}
|
}
|
||||||
@ -107,6 +109,7 @@ internal fun ContinuousBookReader(
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
keyPrefix = "section-$index",
|
keyPrefix = "section-$index",
|
||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
|
onImageOpen = onImageOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(22.dp)) }
|
item { Spacer(Modifier.height(22.dp)) }
|
||||||
@ -119,6 +122,7 @@ private fun LazyListScope.sectionItems(
|
|||||||
depth: Int,
|
depth: Int,
|
||||||
keyPrefix: String,
|
keyPrefix: String,
|
||||||
hyphenation: HyphenationRegistry,
|
hyphenation: HyphenationRegistry,
|
||||||
|
onImageOpen: (ViewedBookImage) -> Unit,
|
||||||
) {
|
) {
|
||||||
if( section.title.isNullOrBlank() ) {
|
if( section.title.isNullOrBlank() ) {
|
||||||
item {
|
item {
|
||||||
@ -153,6 +157,7 @@ private fun LazyListScope.sectionItems(
|
|||||||
image = block.image,
|
image = block.image,
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
|
onOpen = onImageOpen,
|
||||||
)
|
)
|
||||||
is Fb2Block.Paragraph -> ReaderText(
|
is Fb2Block.Paragraph -> ReaderText(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
@ -181,6 +186,7 @@ private fun LazyListScope.sectionItems(
|
|||||||
depth = depth + 1,
|
depth = depth + 1,
|
||||||
keyPrefix = "$keyPrefix-$index",
|
keyPrefix = "$keyPrefix-$index",
|
||||||
hyphenation = hyphenation,
|
hyphenation = hyphenation,
|
||||||
|
onImageOpen = onImageOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -232,7 +238,7 @@ private fun DetailsPane(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CoverAndTitle(book: Fb2Book) {
|
internal fun CoverAndTitle(book: Fb2Book, onImageOpen: (ViewedBookImage) -> Unit = {}) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@ -243,6 +249,7 @@ internal fun CoverAndTitle(book: Fb2Book) {
|
|||||||
image = book.coverImages.firstOrNull() ?: book.bodyImages.firstOrNull(),
|
image = book.coverImages.firstOrNull() ?: book.bodyImages.firstOrNull(),
|
||||||
modifier = Modifier.width(112.dp).aspectRatio(0.68f),
|
modifier = Modifier.width(112.dp).aspectRatio(0.68f),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
onOpen = onImageOpen,
|
||||||
)
|
)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f)) {
|
||||||
Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
Text(book.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
@ -428,20 +435,41 @@ private fun BookImage(
|
|||||||
image: Fb2ImageRef?,
|
image: Fb2ImageRef?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
onOpen: (ViewedBookImage) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val bitmap = remember(book, image) {
|
val binary = remember(book, image) {
|
||||||
image?.let(book::binaryFor)?.let { decodeBookImage(it) }
|
image?.let(book::binaryFor)
|
||||||
}
|
}
|
||||||
|
val bitmap = remember(binary) {
|
||||||
|
binary?.let { decodeBookImage(it) }
|
||||||
|
}
|
||||||
|
val imageTitle = image?.alt?.ifBlank { null } ?: book.title
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.then(
|
||||||
|
if (bitmap != null && binary != null) {
|
||||||
|
Modifier.clickable {
|
||||||
|
onOpen(
|
||||||
|
ViewedBookImage(
|
||||||
|
bitmap = bitmap,
|
||||||
|
bytes = binary.imageBytes(),
|
||||||
|
mimeType = binary.contentType,
|
||||||
|
title = imageTitle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
contentDescription = image?.alt ?: book.title,
|
contentDescription = imageTitle,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import kotlinx.coroutines.launch
|
|||||||
internal fun BookView(
|
internal fun BookView(
|
||||||
fileId: String,
|
fileId: String,
|
||||||
book: Fb2Book,
|
book: Fb2Book,
|
||||||
|
onImageOpen: (ViewedBookImage) -> Unit,
|
||||||
onThemeToggle: () -> Unit,
|
onThemeToggle: () -> Unit,
|
||||||
onBookInfo: () -> Unit,
|
onBookInfo: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@ -128,6 +129,7 @@ internal fun BookView(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
listState = listState,
|
listState = listState,
|
||||||
|
onImageOpen = onImageOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,13 +44,14 @@ import kotlinx.coroutines.launch
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
internal fun ScanScreen(
|
internal fun ScanScreen(
|
||||||
state: AppState.Scan,
|
state: AppState.Scan,
|
||||||
|
activeScan: LibraryScanProgress?,
|
||||||
onStateChange: (AppState) -> Unit,
|
onStateChange: (AppState) -> Unit,
|
||||||
|
onStartScan: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var scanPath by remember(state.scanPath) { mutableStateOf(state.scanPath) }
|
var scanPath by remember(state.scanPath) { mutableStateOf(state.scanPath) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
|
||||||
var message by remember(state.message) { mutableStateOf(state.message) }
|
var message by remember(state.message) { mutableStateOf(state.message) }
|
||||||
var scanProgress by remember { mutableStateOf<LibraryScanProgress?>(null) }
|
val busy = activeScan != null
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -92,28 +93,8 @@ internal fun ScanScreen(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
message = "Scanning..."
|
||||||
busy = true
|
onStartScan(scanPath)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
enabled = !busy && scanPath.isNotBlank(),
|
enabled = !busy && scanPath.isNotBlank(),
|
||||||
) {
|
) {
|
||||||
@ -137,14 +118,12 @@ internal fun ScanScreen(
|
|||||||
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (busy) {
|
if (activeScan != null) {
|
||||||
scanProgress?.let { progress ->
|
Text(
|
||||||
Text(
|
activeScan.toScanMessage(),
|
||||||
progress.toScanMessage(),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
message?.let {
|
message?.let {
|
||||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
|
||||||
|
|||||||
@ -10,15 +10,26 @@ import net.sergeych.toread.storage.ReadingStateRecord
|
|||||||
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
||||||
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
||||||
import org.jetbrains.skia.Image
|
import org.jetbrains.skia.Image
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.datatransfer.DataFlavor
|
||||||
|
import java.awt.datatransfer.Transferable
|
||||||
|
import java.awt.datatransfer.UnsupportedFlavorException
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import javax.imageio.ImageIO
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.swing.JFileChooser
|
import javax.swing.JFileChooser
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
actual fun loadDefaultBookBytes(): ByteArray? {
|
actual fun loadDefaultBookBytes(): ByteArray? {
|
||||||
@ -37,6 +48,22 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
|
|||||||
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
|
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
|
||||||
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
|
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
|
||||||
|
|
||||||
|
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext false
|
||||||
|
val transferable = object : Transferable {
|
||||||
|
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.imageFlavor)
|
||||||
|
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor
|
||||||
|
override fun getTransferData(flavor: DataFlavor): Any {
|
||||||
|
if (!isDataFlavorSupported(flavor)) throw UnsupportedFlavorException(flavor)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toolkit.getDefaultToolkit().systemClipboard.setContents(transferable, null)
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
|
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
|
||||||
|
|
||||||
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
|
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
|
||||||
@ -79,27 +106,53 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
|
|||||||
actual suspend fun scanLibrarySubtree(
|
actual suspend fun scanLibrarySubtree(
|
||||||
path: String,
|
path: String,
|
||||||
onProgress: (LibraryScanProgress) -> Unit,
|
onProgress: (LibraryScanProgress) -> Unit,
|
||||||
): LibraryScanReport = withContext(Dispatchers.IO) {
|
): LibraryScanReport = coroutineScope {
|
||||||
appendLibraryLog("scan requested path=$path")
|
withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
appendLibraryLog("scan requested path=$path")
|
||||||
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
|
}
|
||||||
onProgress(
|
val root = File(path)
|
||||||
LibraryScanProgress(
|
val totalFiles = AtomicInteger(-1)
|
||||||
scannedFiles = it.scannedFiles,
|
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
|
||||||
importedFiles = it.importedFiles,
|
|
||||||
skippedFiles = it.skippedFiles,
|
fun emitProgress(progress: LibraryScanProgress) {
|
||||||
failedFiles = it.failedFiles,
|
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
|
||||||
currentFile = it.currentFile,
|
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) {
|
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@ -12,6 +12,8 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? = null
|
|||||||
|
|
||||||
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = null
|
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = null
|
||||||
|
|
||||||
|
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = false
|
||||||
|
|
||||||
actual fun defaultLibraryScanPath(): String? = null
|
actual fun defaultLibraryScanPath(): String? = null
|
||||||
|
|
||||||
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
|
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
|
||||||
|
|||||||
@ -94,6 +94,7 @@ data class BookFileRecord(
|
|||||||
val bookId: String? = null,
|
val bookId: String? = null,
|
||||||
val bodyId: String? = null,
|
val bodyId: String? = null,
|
||||||
val bodyClusterId: String? = null,
|
val bodyClusterId: String? = null,
|
||||||
|
val duplicateOfFileId: String? = null,
|
||||||
val rawSha256: String,
|
val rawSha256: String,
|
||||||
val format: String? = null,
|
val format: String? = null,
|
||||||
val mimeType: String? = null,
|
val mimeType: String? = null,
|
||||||
@ -194,6 +195,10 @@ interface BookFileRepository {
|
|||||||
fun get(id: String): BookFileRecord?
|
fun get(id: String): BookFileRecord?
|
||||||
fun getLibraryFile(id: String): LibraryFileRecord?
|
fun getLibraryFile(id: String): LibraryFileRecord?
|
||||||
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
|
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
|
||||||
|
fun findByOriginalFilenameSizeAndModified(originalFilename: String, sizeBytes: Long, lastModifiedMillis: Long): List<BookFileRecord>
|
||||||
|
fun findByOriginalFilenameSizeAndRawSha256(originalFilename: String, sizeBytes: Long, rawSha256: String): List<BookFileRecord>
|
||||||
|
fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord?
|
||||||
|
fun markDuplicateFiles(): Int
|
||||||
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
|
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
|
||||||
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
|
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
|
||||||
fun listForBook(bookId: String): List<BookFileRecord>
|
fun listForBook(bookId: String): List<BookFileRecord>
|
||||||
|
|||||||
@ -199,6 +199,7 @@ private fun migrate(connection: Connection) {
|
|||||||
book_id VARCHAR,
|
book_id VARCHAR,
|
||||||
body_id VARCHAR,
|
body_id VARCHAR,
|
||||||
body_cluster_id VARCHAR,
|
body_cluster_id VARCHAR,
|
||||||
|
duplicate_of_file_id VARCHAR,
|
||||||
raw_sha256 VARCHAR NOT NULL,
|
raw_sha256 VARCHAR NOT NULL,
|
||||||
format VARCHAR,
|
format VARCHAR,
|
||||||
mime_type VARCHAR,
|
mime_type VARCHAR,
|
||||||
@ -219,10 +220,14 @@ private fun migrate(connection: Connection) {
|
|||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
|
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR")
|
||||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
|
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
|
||||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
|
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
|
||||||
connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
|
connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
|
||||||
connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
|
connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
|
||||||
|
connection.createIndexIfMissing("idx_book_files_duplicate", "CREATE INDEX IF NOT EXISTS idx_book_files_duplicate ON book_files(duplicate_of_file_id)")
|
||||||
|
connection.createIndexIfMissing("idx_book_files_name_size_mtime", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_mtime ON book_files(original_filename, size_bytes, last_modified_millis)")
|
||||||
|
connection.createIndexIfMissing("idx_book_files_name_size_hash", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_hash ON book_files(original_filename, size_bytes, raw_sha256)")
|
||||||
connection.createIndexIfMissing("idx_book_files_updated_at", "CREATE INDEX IF NOT EXISTS idx_book_files_updated_at ON book_files(updated_at)")
|
connection.createIndexIfMissing("idx_book_files_updated_at", "CREATE INDEX IF NOT EXISTS idx_book_files_updated_at ON book_files(updated_at)")
|
||||||
connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)")
|
connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)")
|
||||||
statement.execute(
|
statement.execute(
|
||||||
@ -470,31 +475,32 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
connection.prepareStatement(
|
connection.prepareStatement(
|
||||||
"""
|
"""
|
||||||
MERGE INTO book_files(
|
MERGE INTO book_files(
|
||||||
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes,
|
id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format,
|
||||||
original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis,
|
mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id,
|
||||||
last_seen_at, reading_status, last_read_at, created_at, updated_at
|
last_modified_millis, last_seen_at, reading_status, last_read_at, created_at, updated_at
|
||||||
)
|
)
|
||||||
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
).use { statement ->
|
).use { statement ->
|
||||||
statement.setString(1, file.id)
|
statement.setString(1, file.id)
|
||||||
statement.setStringOrNull(2, file.bookId)
|
statement.setStringOrNull(2, file.bookId)
|
||||||
statement.setStringOrNull(3, file.bodyId)
|
statement.setStringOrNull(3, file.bodyId)
|
||||||
statement.setStringOrNull(4, file.bodyClusterId)
|
statement.setStringOrNull(4, file.bodyClusterId)
|
||||||
statement.setString(5, file.rawSha256)
|
statement.setStringOrNull(5, file.duplicateOfFileId)
|
||||||
statement.setStringOrNull(6, file.format)
|
statement.setString(6, file.rawSha256)
|
||||||
statement.setStringOrNull(7, file.mimeType)
|
statement.setStringOrNull(7, file.format)
|
||||||
statement.setLongOrNull(8, file.sizeBytes)
|
statement.setStringOrNull(8, file.mimeType)
|
||||||
statement.setStringOrNull(9, file.originalFilename)
|
statement.setLongOrNull(9, file.sizeBytes)
|
||||||
statement.setString(10, file.storageKind.name)
|
statement.setStringOrNull(10, file.originalFilename)
|
||||||
statement.setStringOrNull(11, file.storageUri)
|
statement.setString(11, file.storageKind.name)
|
||||||
statement.setStringOrNull(12, file.contentObjectId)
|
statement.setStringOrNull(12, file.storageUri)
|
||||||
statement.setLongOrNull(13, file.lastModifiedMillis)
|
statement.setStringOrNull(13, file.contentObjectId)
|
||||||
statement.setLongOrNull(14, file.lastSeenAt)
|
statement.setLongOrNull(14, file.lastModifiedMillis)
|
||||||
statement.setString(15, file.readingStatus.name)
|
statement.setLongOrNull(15, file.lastSeenAt)
|
||||||
statement.setLongOrNull(16, file.lastReadAt)
|
statement.setString(16, file.readingStatus.name)
|
||||||
statement.setLong(17, file.createdAt)
|
statement.setLongOrNull(17, file.lastReadAt)
|
||||||
statement.setLong(18, file.updatedAt)
|
statement.setLong(18, file.createdAt)
|
||||||
|
statement.setLong(19, file.updatedAt)
|
||||||
statement.executeUpdate()
|
statement.executeUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -534,27 +540,133 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
|
override fun findByOriginalFilenameSizeAndModified(
|
||||||
|
originalFilename: String,
|
||||||
|
sizeBytes: Long,
|
||||||
|
lastModifiedMillis: Long,
|
||||||
|
): List<BookFileRecord> {
|
||||||
return connection.prepareStatement(
|
return connection.prepareStatement(
|
||||||
"""
|
"""
|
||||||
WITH visible_files AS (
|
SELECT * FROM book_files
|
||||||
|
WHERE original_filename = ?
|
||||||
|
AND size_bytes = ?
|
||||||
|
AND last_modified_millis = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, originalFilename)
|
||||||
|
statement.setLong(2, sizeBytes)
|
||||||
|
statement.setLong(3, lastModifiedMillis)
|
||||||
|
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByOriginalFilenameSizeAndRawSha256(
|
||||||
|
originalFilename: String,
|
||||||
|
sizeBytes: Long,
|
||||||
|
rawSha256: String,
|
||||||
|
): List<BookFileRecord> {
|
||||||
|
return connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
SELECT * FROM book_files
|
||||||
|
WHERE original_filename = ?
|
||||||
|
AND size_bytes = ?
|
||||||
|
AND raw_sha256 = ?
|
||||||
|
ORDER BY created_at
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, originalFilename)
|
||||||
|
statement.setLong(2, sizeBytes)
|
||||||
|
statement.setString(3, rawSha256)
|
||||||
|
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord? {
|
||||||
|
return connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
SELECT * FROM book_files
|
||||||
|
WHERE duplicate_of_file_id IS NULL
|
||||||
|
AND (
|
||||||
|
(? IS NOT NULL AND body_cluster_id = ?)
|
||||||
|
OR (? IS NOT NULL AND body_id = ?)
|
||||||
|
OR raw_sha256 = ?
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||||
|
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||||
|
THEN 0 ELSE 1
|
||||||
|
END,
|
||||||
|
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||||
|
last_read_at DESC NULLS LAST,
|
||||||
|
updated_at DESC,
|
||||||
|
id
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setStringOrNull(1, bodyClusterId)
|
||||||
|
statement.setStringOrNull(2, bodyClusterId)
|
||||||
|
statement.setStringOrNull(3, bodyId)
|
||||||
|
statement.setStringOrNull(4, bodyId)
|
||||||
|
statement.setString(5, rawSha256)
|
||||||
|
statement.executeQuery().use { resultSet ->
|
||||||
|
if (resultSet.next()) resultSet.toBookFileRecord() else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markDuplicateFiles(): Int {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
MERGE INTO book_files(id, duplicate_of_file_id, updated_at)
|
||||||
|
KEY(id)
|
||||||
|
SELECT id, primary_file_id, ?
|
||||||
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
f.*,
|
id,
|
||||||
ROW_NUMBER() OVER (
|
FIRST_VALUE(id) OVER (
|
||||||
PARTITION BY COALESCE(f.body_cluster_id, f.body_id, f.book_id, f.raw_sha256, f.id)
|
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN LOWER(COALESCE(f.format, '')) = 'fb2.zip'
|
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||||
OR LOWER(COALESCE(f.original_filename, '')) LIKE '%.fb2.zip'
|
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||||
THEN 0 ELSE 1
|
THEN 0 ELSE 1
|
||||||
END,
|
END,
|
||||||
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||||
f.last_read_at DESC NULLS LAST,
|
last_read_at DESC NULLS LAST,
|
||||||
f.updated_at DESC,
|
updated_at DESC,
|
||||||
f.id
|
id
|
||||||
|
) AS primary_file_id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||||
|
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||||
|
THEN 0 ELSE 1
|
||||||
|
END,
|
||||||
|
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||||
|
last_read_at DESC NULLS LAST,
|
||||||
|
updated_at DESC,
|
||||||
|
id
|
||||||
) AS duplicate_rank
|
) AS duplicate_rank
|
||||||
FROM book_files f
|
FROM book_files
|
||||||
)
|
WHERE duplicate_of_file_id IS NULL
|
||||||
|
) ranked
|
||||||
|
WHERE duplicate_rank > 1
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setLong(1, now)
|
||||||
|
statement.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
|
||||||
|
markDuplicateFiles()
|
||||||
|
return connection.prepareStatement(
|
||||||
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
f.id AS file_id,
|
f.id AS file_id,
|
||||||
f.book_id AS book_id,
|
f.book_id AS book_id,
|
||||||
@ -569,9 +681,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
f.last_seen_at AS last_seen_at,
|
f.last_seen_at AS last_seen_at,
|
||||||
f.reading_status AS reading_status,
|
f.reading_status AS reading_status,
|
||||||
f.last_read_at AS last_read_at
|
f.last_read_at AS last_read_at
|
||||||
FROM visible_files f
|
FROM book_files f
|
||||||
LEFT JOIN books b ON b.id = f.book_id
|
LEFT JOIN books b ON b.id = f.book_id
|
||||||
WHERE f.duplicate_rank = 1
|
WHERE f.duplicate_of_file_id IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||||
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
||||||
@ -836,6 +948,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
|
|||||||
bookId = getString("book_id"),
|
bookId = getString("book_id"),
|
||||||
bodyId = getString("body_id"),
|
bodyId = getString("body_id"),
|
||||||
bodyClusterId = getString("body_cluster_id"),
|
bodyClusterId = getString("body_cluster_id"),
|
||||||
|
duplicateOfFileId = getString("duplicate_of_file_id"),
|
||||||
rawSha256 = getString("raw_sha256"),
|
rawSha256 = getString("raw_sha256"),
|
||||||
format = getString("format"),
|
format = getString("format"),
|
||||||
mimeType = getString("mime_type"),
|
mimeType = getString("mime_type"),
|
||||||
|
|||||||
@ -82,34 +82,65 @@ class LibraryScanner(
|
|||||||
lastModifiedMillis: Long?,
|
lastModifiedMillis: Long?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val rawSha256 = bytes.sha256Hex()
|
val rawSha256 = bytes.sha256Hex()
|
||||||
if (database.files.findByRawSha256(rawSha256).isNotEmpty()) return false
|
if (sizeBytes != null && database.files.findByOriginalFilenameSizeAndRawSha256(displayName, sizeBytes, rawSha256).isNotEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
database.files.findPrimaryDuplicateTarget(bodyClusterId = null, bodyId = null, rawSha256 = rawSha256)?.let { duplicateTarget ->
|
||||||
|
database.files.upsert(
|
||||||
|
BookFileRecord(
|
||||||
|
id = "file-${UUID.randomUUID()}",
|
||||||
|
bookId = duplicateTarget.bookId,
|
||||||
|
bodyId = duplicateTarget.bodyId,
|
||||||
|
bodyClusterId = duplicateTarget.bodyClusterId,
|
||||||
|
duplicateOfFileId = duplicateTarget.id,
|
||||||
|
rawSha256 = rawSha256,
|
||||||
|
format = displayName.bookFormat(),
|
||||||
|
mimeType = displayName.bookMimeType(),
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
originalFilename = displayName,
|
||||||
|
storageKind = BookFileStorageKind.EXTERNAL_URI,
|
||||||
|
storageUri = storageUri,
|
||||||
|
lastModifiedMillis = lastModifiedMillis,
|
||||||
|
lastSeenAt = now,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
val book = Fb2Format.parse(bytes, displayName)
|
val book = Fb2Format.parse(bytes, displayName)
|
||||||
val cover = book.coverImage()
|
val cover = book.coverImage()
|
||||||
val canonicalText = book.canonicalText()
|
val canonicalText = book.canonicalText()
|
||||||
val bodyHash = canonicalText.encodeToByteArray().sha256Hex()
|
val bodyHash = canonicalText.encodeToByteArray().sha256Hex()
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion)
|
val knownBody = database.bodies.findByExactTextHash(bodyHash, CanonicalizationVersion)
|
||||||
val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}"
|
val bodyId = knownBody?.id ?: "body-${UUID.randomUUID()}"
|
||||||
val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) }
|
val knownCluster = knownBody?.let { database.clusters.findByRepresentativeBodyId(it.id) }
|
||||||
val clusterId = knownCluster?.id ?: "cluster-${UUID.randomUUID()}"
|
val clusterId = knownCluster?.id ?: "cluster-${UUID.randomUUID()}"
|
||||||
val bookId = "book-${UUID.randomUUID()}"
|
val duplicateTarget = knownBody?.let {
|
||||||
|
database.files.findPrimaryDuplicateTarget(bodyClusterId = clusterId, bodyId = bodyId, rawSha256 = rawSha256)
|
||||||
|
}
|
||||||
|
val bookId = duplicateTarget?.bookId ?: "book-${UUID.randomUUID()}"
|
||||||
|
|
||||||
database.transaction {
|
database.transaction {
|
||||||
books.upsert(
|
if (duplicateTarget == null || duplicateTarget.bookId == null) {
|
||||||
BookRecord(
|
books.upsert(
|
||||||
id = bookId,
|
BookRecord(
|
||||||
title = book.title.ifBlank { displayName.substringBeforeLast('.') },
|
id = bookId,
|
||||||
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
|
title = book.title.ifBlank { displayName.substringBeforeLast('.') },
|
||||||
language = book.language,
|
authors = book.authors.mapNotNull { it.displayName.takeIf(String::isNotBlank) },
|
||||||
date = book.date,
|
language = book.language,
|
||||||
description = book.annotation,
|
date = book.date,
|
||||||
coverImage = cover?.bytes,
|
description = book.annotation,
|
||||||
coverImageMimeType = cover?.mimeType,
|
coverImage = cover?.bytes,
|
||||||
createdAt = now,
|
coverImageMimeType = cover?.mimeType,
|
||||||
updatedAt = now,
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
if (knownBody == null) {
|
if (knownBody == null) {
|
||||||
bodies.upsert(
|
bodies.upsert(
|
||||||
BookBodyRecord(
|
BookBodyRecord(
|
||||||
@ -143,6 +174,7 @@ class LibraryScanner(
|
|||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
bodyId = bodyId,
|
bodyId = bodyId,
|
||||||
bodyClusterId = clusterId,
|
bodyClusterId = clusterId,
|
||||||
|
duplicateOfFileId = duplicateTarget?.id,
|
||||||
rawSha256 = rawSha256,
|
rawSha256 = rawSha256,
|
||||||
format = displayName.bookFormat(),
|
format = displayName.bookFormat(),
|
||||||
mimeType = displayName.bookMimeType(),
|
mimeType = displayName.bookMimeType(),
|
||||||
@ -160,14 +192,27 @@ class LibraryScanner(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importLinkedFile(file: File): Boolean =
|
private fun importLinkedFile(file: File): Boolean {
|
||||||
importExternalFile(
|
val sizeBytes = file.length()
|
||||||
|
val lastModifiedMillis = file.lastModified()
|
||||||
|
if (database.files.findByOriginalFilenameSizeAndModified(file.name, sizeBytes, lastModifiedMillis).isNotEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return importExternalFile(
|
||||||
bytes = file.readBytes(),
|
bytes = file.readBytes(),
|
||||||
displayName = file.name,
|
displayName = file.name,
|
||||||
storageUri = file.absolutePath,
|
storageUri = file.absolutePath,
|
||||||
sizeBytes = file.length(),
|
sizeBytes = sizeBytes,
|
||||||
lastModifiedMillis = file.lastModified(),
|
lastModifiedMillis = lastModifiedMillis,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun countSupportedBookFiles(root: File): Int {
|
||||||
|
require(root.isDirectory) { "Scan root is not a directory: ${root.path}" }
|
||||||
|
return root.walkTopDown().count { it.isFile && it.isSupportedBookFile() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val CanonicalizationVersion = 1
|
private const val CanonicalizationVersion = 1
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
package net.sergeych.toread.storage.jdbc
|
package net.sergeych.toread.storage.jdbc
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class LibraryScannerTest {
|
class LibraryScannerTest {
|
||||||
@ -35,6 +37,61 @@ class LibraryScannerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scanMarksImportedDuplicateFilesImmediately() {
|
||||||
|
val root = findProjectRoot()
|
||||||
|
val source = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip")
|
||||||
|
val tempDir = Files.createTempDirectory("yereed-dupe-scan").toFile()
|
||||||
|
try {
|
||||||
|
source.copyTo(File(tempDir, source.name))
|
||||||
|
source.copyTo(File(tempDir, "same-book-different-name.fb2.zip"))
|
||||||
|
|
||||||
|
val db = H2LibraryDatabase.openMemory("scanMarksImportedDuplicateFilesImmediately")
|
||||||
|
try {
|
||||||
|
val summary = LibraryScanner(db).scanSubtree(tempDir)
|
||||||
|
|
||||||
|
assertEquals(2, summary.scannedFiles)
|
||||||
|
assertEquals(2, summary.importedFiles)
|
||||||
|
assertEquals(0, summary.skippedFiles)
|
||||||
|
|
||||||
|
val files = db.files.list().sortedBy { it.duplicateOfFileId != null }
|
||||||
|
assertEquals(2, files.size)
|
||||||
|
assertNull(files.first().duplicateOfFileId)
|
||||||
|
assertEquals(files.first().id, files.last().duplicateOfFileId)
|
||||||
|
assertEquals(1, db.files.listLibraryFiles().size)
|
||||||
|
|
||||||
|
val second = LibraryScanner(db).scanSubtree(tempDir)
|
||||||
|
assertEquals(0, second.importedFiles)
|
||||||
|
assertEquals(2, second.skippedFiles)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
tempDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listingLibraryFilesPersistsLegacyDuplicateMarkers() {
|
||||||
|
val root = findProjectRoot()
|
||||||
|
val bytes = File(root, "test_books/Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip").readBytes()
|
||||||
|
val db = H2LibraryDatabase.openMemory("listingLibraryFilesPersistsLegacyDuplicateMarkers")
|
||||||
|
try {
|
||||||
|
val scanner = LibraryScanner(db)
|
||||||
|
assertTrue(scanner.importExternalFile(bytes, "primary.fb2.zip", "/tmp/primary.fb2.zip", bytes.size.toLong(), 1L))
|
||||||
|
assertTrue(scanner.importExternalFile(bytes, "secondary.fb2.zip", "/tmp/secondary.fb2.zip", bytes.size.toLong(), 2L))
|
||||||
|
|
||||||
|
val duplicate = db.files.list().single { it.duplicateOfFileId != null }
|
||||||
|
db.files.upsert(duplicate.copy(duplicateOfFileId = null))
|
||||||
|
|
||||||
|
assertEquals(2, db.files.list().count { it.duplicateOfFileId == null })
|
||||||
|
assertEquals(1, db.files.listLibraryFiles().size)
|
||||||
|
assertEquals(1, db.files.list().count { it.duplicateOfFileId != null })
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun findProjectRoot(): File {
|
private fun findProjectRoot(): File {
|
||||||
var current = File(System.getProperty("user.dir")).absoluteFile
|
var current = File(System.getProperty("user.dir")).absoluteFile
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user