+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"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.imageviewer.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/image_clipboard_paths"/>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package net.sergeych.toread
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.ComponentCallbacks
|
||||
import android.content.Context
|
||||
@ -12,6 +14,7 @@ import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.content.FileProvider
|
||||
import net.sergeych.toread.fb2.Fb2Binary
|
||||
import net.sergeych.toread.storage.BookReadingStatus
|
||||
import net.sergeych.toread.storage.ContentAnchor
|
||||
@ -24,7 +27,12 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private lateinit var appContext: Context
|
||||
@ -54,6 +62,34 @@ actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
|
||||
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val imageDir = File(appContext.cacheDir, "clipboard-images").also { it.mkdirs() }
|
||||
val extension = when (mimeType.lowercase()) {
|
||||
"image/png" -> "png"
|
||||
"image/gif" -> "gif"
|
||||
"image/webp" -> "webp"
|
||||
else -> "jpg"
|
||||
}
|
||||
val imageFile = File(imageDir, "book-image.$extension")
|
||||
imageFile.writeBytes(bytes)
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
appContext,
|
||||
"${appContext.packageName}.imageviewer.fileprovider",
|
||||
imageFile,
|
||||
)
|
||||
val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newUri(appContext.contentResolver, label, uri).apply {
|
||||
description.extras = android.os.PersistableBundle().apply {
|
||||
putString("mime_type", mimeType)
|
||||
}
|
||||
}
|
||||
clipboard.setPrimaryClip(clip)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
actual fun defaultLibraryScanPath(): String? =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
|
||||
?: appContext.getExternalFilesDir(null)?.absolutePath
|
||||
@ -105,26 +141,68 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
|
||||
actual suspend fun scanLibrarySubtree(
|
||||
path: String,
|
||||
onProgress: (LibraryScanProgress) -> Unit,
|
||||
): LibraryScanReport = withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("scan requested path=$path")
|
||||
if (path.isContentUri()) {
|
||||
scanLibraryContentTree(Uri.parse(path), onProgress)
|
||||
} else {
|
||||
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
|
||||
error("All files access is required to scan $path.")
|
||||
}
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
|
||||
onProgress(it.toLibraryScanProgress())
|
||||
): LibraryScanReport = coroutineScope {
|
||||
withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("scan requested path=$path")
|
||||
}
|
||||
val totalFiles = AtomicInteger(-1)
|
||||
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
|
||||
|
||||
fun emitProgress(progress: LibraryScanProgress) {
|
||||
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
|
||||
latestProgress.set(enriched)
|
||||
onProgress(enriched)
|
||||
}
|
||||
|
||||
val totalJob = async(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (path.isContentUri()) {
|
||||
countContentTree(Uri.parse(path))
|
||||
} else {
|
||||
LibraryScanner.countSupportedBookFiles(File(path))
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
val totalPublisher = launch(Dispatchers.IO) {
|
||||
val total = totalJob.await() ?: return@launch
|
||||
totalFiles.set(total)
|
||||
emitProgress(latestProgress.get()?.copy(totalFiles = total) ?: LibraryScanProgress(0, 0, 0, 0, totalFiles = total))
|
||||
}
|
||||
|
||||
val report = withContext(Dispatchers.IO) {
|
||||
if (path.isContentUri()) {
|
||||
scanLibraryContentTree(Uri.parse(path), ::emitProgress)
|
||||
} else {
|
||||
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
|
||||
error("All files access is required to scan $path.")
|
||||
}
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
|
||||
emitProgress(it.toLibraryScanProgress())
|
||||
}
|
||||
LibraryScanReport(
|
||||
scannedFiles = summary.scannedFiles,
|
||||
importedFiles = summary.importedFiles,
|
||||
skippedFiles = summary.skippedFiles,
|
||||
failedFiles = summary.failedFiles,
|
||||
totalFiles = totalFiles.get().takeIf { it >= 0 },
|
||||
)
|
||||
}
|
||||
LibraryScanReport(
|
||||
scannedFiles = summary.scannedFiles,
|
||||
importedFiles = summary.importedFiles,
|
||||
skippedFiles = summary.skippedFiles,
|
||||
failedFiles = summary.failedFiles,
|
||||
)
|
||||
}
|
||||
}
|
||||
totalPublisher.join()
|
||||
report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
|
||||
}
|
||||
|
||||
private fun countContentTree(rootUri: Uri): Int {
|
||||
var count = 0
|
||||
walkContentTree(
|
||||
rootUri = rootUri,
|
||||
onVisited = {},
|
||||
) {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
|
||||
|
||||
@ -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.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ -92,25 +94,64 @@ private fun AppToast(message: String?, modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
||||
var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) }
|
||||
var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) }
|
||||
var scanJob by remember { mutableStateOf<Job?>(null) }
|
||||
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
state = loadStartupState()
|
||||
}
|
||||
|
||||
fun startScan(path: String) {
|
||||
if (scanJob?.isActive == true) return
|
||||
activeScan = LibraryScanProgress(0, 0, 0, 0)
|
||||
scanJob = scope.launch {
|
||||
val report = runCatching {
|
||||
scanLibrarySubtree(path) { progress ->
|
||||
scope.launch {
|
||||
if (scanJob?.isActive == true) activeScan = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
val message = report.fold(
|
||||
onSuccess = {
|
||||
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
|
||||
},
|
||||
onFailure = { it.message ?: "Scan failed." },
|
||||
)
|
||||
scanJob = null
|
||||
activeScan = null
|
||||
state = when (val current = state) {
|
||||
is AppState.Library, is AppState.Scan, AppState.LoadingLibrary -> loadLibraryState(message, path)
|
||||
is AppState.Reader -> current.copy(message = message)
|
||||
is AppState.BookInfo -> current.copy(message = message)
|
||||
is AppState.Error -> current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val current = state) {
|
||||
AppState.LoadingLibrary -> LoadingScreen("Opening library")
|
||||
is AppState.Library -> LibraryScreen(
|
||||
state = current,
|
||||
activeScan = activeScan,
|
||||
onStateChange = { state = it },
|
||||
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
|
||||
)
|
||||
is AppState.Scan -> ScanScreen(
|
||||
state = current,
|
||||
activeScan = activeScan,
|
||||
onStateChange = { state = it },
|
||||
onStartScan = { path ->
|
||||
startScan(path)
|
||||
state = AppState.Library(current.items, path, "Scanning...")
|
||||
},
|
||||
)
|
||||
is AppState.Reader -> BookView(
|
||||
fileId = current.fileId,
|
||||
book = current.book,
|
||||
onImageOpen = { imageViewer = it },
|
||||
onThemeToggle = onThemeToggle,
|
||||
onBookInfo = {
|
||||
state = AppState.BookInfo(
|
||||
@ -128,6 +169,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
||||
is AppState.BookInfo -> BookInfoScreen(
|
||||
fileId = current.fileId,
|
||||
book = current.book,
|
||||
onImageOpen = { imageViewer = it },
|
||||
onBack = {
|
||||
state = AppState.Reader(
|
||||
fileId = current.fileId,
|
||||
@ -140,4 +182,18 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
||||
)
|
||||
is AppState.Error -> ErrorScreen(current.message, onBack = { state = AppState.LoadingLibrary })
|
||||
}
|
||||
|
||||
imageViewer?.let { image ->
|
||||
ImageViewer(
|
||||
image = image,
|
||||
onBack = { imageViewer = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ViewedBookImage(
|
||||
val bitmap: ImageBitmap,
|
||||
val bytes: ByteArray,
|
||||
val mimeType: String,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 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(
|
||||
|
||||
@ -54,12 +54,14 @@ import androidx.compose.ui.unit.dp
|
||||
import net.sergeych.toread.fb2.Fb2Format
|
||||
import net.sergeych.toread.storage.BookReadingStatus
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
internal fun LibraryScreen(
|
||||
state: AppState.Library,
|
||||
activeScan: LibraryScanProgress?,
|
||||
onStateChange: (AppState) -> Unit,
|
||||
onNavigateToScan: () -> Unit,
|
||||
) {
|
||||
@ -70,6 +72,7 @@ internal fun LibraryScreen(
|
||||
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
|
||||
var loadingPage by remember(state.items) { mutableStateOf(false) }
|
||||
var endReached by remember(state.items) { mutableStateOf(false) }
|
||||
var wasScanning by remember { mutableStateOf(false) }
|
||||
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
||||
|
||||
suspend fun loadPage(reset: Boolean = false) {
|
||||
@ -104,6 +107,19 @@ internal fun LibraryScreen(
|
||||
if (items.isEmpty() && !endReached) loadPage(reset = true)
|
||||
}
|
||||
|
||||
LaunchedEffect(activeScan != null) {
|
||||
if (activeScan != null) {
|
||||
wasScanning = true
|
||||
while (true) {
|
||||
delay(5_000)
|
||||
loadPage(reset = true)
|
||||
}
|
||||
} else if (wasScanning) {
|
||||
wasScanning = false
|
||||
loadPage(reset = true)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
@ -140,7 +156,7 @@ internal fun LibraryScreen(
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
||||
@ -264,6 +280,14 @@ internal fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
activeScan?.let { progress ->
|
||||
LibraryScanStatusPanel(
|
||||
progress = progress,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = if (wide) 24.dp else 14.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -291,6 +315,36 @@ private fun LibrarySectionHeader(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryScanStatusPanel(progress: LibraryScanProgress, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = quietCardColors(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.width(22.dp).height(22.dp), strokeWidth = 2.dp)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
progress.toCatalogScanMessage(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Imported ${progress.importedFiles}, skipped ${progress.skippedFiles}, failed ${progress.failedFiles}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryRow(
|
||||
item: LibraryItem,
|
||||
@ -454,4 +508,14 @@ private fun Long.formatBytes(): String =
|
||||
else -> "$this B"
|
||||
}
|
||||
|
||||
private fun LibraryScanProgress.toCatalogScanMessage(): String {
|
||||
val total = totalFiles ?: return "Scanned $scannedFiles books"
|
||||
val percent = if (total <= 0) {
|
||||
100
|
||||
} else {
|
||||
((scannedFiles.toDouble() / total.toDouble()) * 100.0).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
return "Scanned $scannedFiles of $total, $percent% done"
|
||||
}
|
||||
|
||||
private const val LibraryPageSize: Int = 50
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,13 +44,14 @@ import kotlinx.coroutines.launch
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
internal fun ScanScreen(
|
||||
state: AppState.Scan,
|
||||
activeScan: LibraryScanProgress?,
|
||||
onStateChange: (AppState) -> Unit,
|
||||
onStartScan: (String) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var scanPath by remember(state.scanPath) { mutableStateOf(state.scanPath) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var message by remember(state.message) { mutableStateOf(state.message) }
|
||||
var scanProgress by remember { mutableStateOf<LibraryScanProgress?>(null) }
|
||||
val busy = activeScan != null
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@ -92,28 +93,8 @@ internal fun ScanScreen(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
scanProgress = null
|
||||
try {
|
||||
message = "Scanning..."
|
||||
val report = runCatching {
|
||||
scanLibrarySubtree(scanPath) { progress ->
|
||||
scope.launch { scanProgress = progress }
|
||||
}
|
||||
}
|
||||
val nextMessage = report.fold(
|
||||
onSuccess = {
|
||||
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
|
||||
},
|
||||
onFailure = { it.message ?: "Scan failed." },
|
||||
)
|
||||
onStateChange(loadLibraryState(nextMessage, scanPath))
|
||||
} finally {
|
||||
busy = false
|
||||
scanProgress = null
|
||||
}
|
||||
}
|
||||
message = "Scanning..."
|
||||
onStartScan(scanPath)
|
||||
},
|
||||
enabled = !busy && scanPath.isNotBlank(),
|
||||
) {
|
||||
@ -137,14 +118,12 @@ internal fun ScanScreen(
|
||||
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
if (busy) {
|
||||
scanProgress?.let { progress ->
|
||||
Text(
|
||||
progress.toScanMessage(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
if (activeScan != null) {
|
||||
Text(
|
||||
activeScan.toScanMessage(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
message?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
|
||||
|
||||
@ -10,15 +10,26 @@ import net.sergeych.toread.storage.ReadingStateRecord
|
||||
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
|
||||
import net.sergeych.toread.storage.jdbc.LibraryScanner
|
||||
import org.jetbrains.skia.Image
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.datatransfer.UnsupportedFlavorException
|
||||
import java.io.File
|
||||
import java.io.ByteArrayInputStream
|
||||
import javax.imageio.ImageIO
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.swing.JFileChooser
|
||||
import kotlin.concurrent.thread
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
actual fun loadDefaultBookBytes(): ByteArray? {
|
||||
@ -37,6 +48,22 @@ actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
|
||||
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
|
||||
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
|
||||
|
||||
actual suspend fun copyImageToClipboard(bytes: ByteArray, mimeType: String, label: String): Boolean = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext false
|
||||
val transferable = object : Transferable {
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.imageFlavor)
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor
|
||||
override fun getTransferData(flavor: DataFlavor): Any {
|
||||
if (!isDataFlavorSupported(flavor)) throw UnsupportedFlavorException(flavor)
|
||||
return image
|
||||
}
|
||||
}
|
||||
Toolkit.getDefaultToolkit().systemClipboard.setContents(transferable, null)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
|
||||
|
||||
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
|
||||
@ -79,27 +106,53 @@ actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withCon
|
||||
actual suspend fun scanLibrarySubtree(
|
||||
path: String,
|
||||
onProgress: (LibraryScanProgress) -> Unit,
|
||||
): LibraryScanReport = withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("scan requested path=$path")
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
|
||||
onProgress(
|
||||
LibraryScanProgress(
|
||||
scannedFiles = it.scannedFiles,
|
||||
importedFiles = it.importedFiles,
|
||||
skippedFiles = it.skippedFiles,
|
||||
failedFiles = it.failedFiles,
|
||||
currentFile = it.currentFile,
|
||||
): LibraryScanReport = coroutineScope {
|
||||
withContext(Dispatchers.IO) {
|
||||
appendLibraryLog("scan requested path=$path")
|
||||
}
|
||||
val root = File(path)
|
||||
val totalFiles = AtomicInteger(-1)
|
||||
val latestProgress = AtomicReference<LibraryScanProgress?>(null)
|
||||
|
||||
fun emitProgress(progress: LibraryScanProgress) {
|
||||
val enriched = totalFiles.get().takeIf { it >= 0 }?.let { progress.copy(totalFiles = it) } ?: progress
|
||||
latestProgress.set(enriched)
|
||||
onProgress(enriched)
|
||||
}
|
||||
|
||||
val totalJob = async(Dispatchers.IO) {
|
||||
runCatching { LibraryScanner.countSupportedBookFiles(root) }.getOrNull()
|
||||
}
|
||||
val totalPublisher = launch(Dispatchers.IO) {
|
||||
val total = totalJob.await() ?: return@launch
|
||||
totalFiles.set(total)
|
||||
emitProgress(latestProgress.get()?.copy(totalFiles = total) ?: LibraryScanProgress(0, 0, 0, 0, totalFiles = total))
|
||||
}
|
||||
|
||||
val report = withContext(Dispatchers.IO) {
|
||||
openLibraryDatabase().useLibrary { db ->
|
||||
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(root) {
|
||||
emitProgress(
|
||||
LibraryScanProgress(
|
||||
scannedFiles = it.scannedFiles,
|
||||
importedFiles = it.importedFiles,
|
||||
skippedFiles = it.skippedFiles,
|
||||
failedFiles = it.failedFiles,
|
||||
currentFile = it.currentFile,
|
||||
)
|
||||
)
|
||||
}
|
||||
LibraryScanReport(
|
||||
scannedFiles = summary.scannedFiles,
|
||||
importedFiles = summary.importedFiles,
|
||||
skippedFiles = summary.skippedFiles,
|
||||
failedFiles = summary.failedFiles,
|
||||
totalFiles = totalFiles.get().takeIf { it >= 0 },
|
||||
)
|
||||
}
|
||||
LibraryScanReport(
|
||||
scannedFiles = summary.scannedFiles,
|
||||
importedFiles = summary.importedFiles,
|
||||
skippedFiles = summary.skippedFiles,
|
||||
failedFiles = summary.failedFiles,
|
||||
)
|
||||
}
|
||||
totalPublisher.join()
|
||||
report.copy(totalFiles = totalFiles.get().takeIf { it >= 0 } ?: report.totalFiles)
|
||||
}
|
||||
|
||||
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -94,6 +94,7 @@ data class BookFileRecord(
|
||||
val bookId: String? = null,
|
||||
val bodyId: String? = null,
|
||||
val bodyClusterId: String? = null,
|
||||
val duplicateOfFileId: String? = null,
|
||||
val rawSha256: String,
|
||||
val format: String? = null,
|
||||
val mimeType: String? = null,
|
||||
@ -194,6 +195,10 @@ interface BookFileRepository {
|
||||
fun get(id: String): BookFileRecord?
|
||||
fun getLibraryFile(id: String): LibraryFileRecord?
|
||||
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
|
||||
fun findByOriginalFilenameSizeAndModified(originalFilename: String, sizeBytes: Long, lastModifiedMillis: Long): List<BookFileRecord>
|
||||
fun findByOriginalFilenameSizeAndRawSha256(originalFilename: String, sizeBytes: Long, rawSha256: String): List<BookFileRecord>
|
||||
fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord?
|
||||
fun markDuplicateFiles(): Int
|
||||
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
|
||||
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
|
||||
fun listForBook(bookId: String): List<BookFileRecord>
|
||||
|
||||
@ -199,6 +199,7 @@ private fun migrate(connection: Connection) {
|
||||
book_id VARCHAR,
|
||||
body_id VARCHAR,
|
||||
body_cluster_id VARCHAR,
|
||||
duplicate_of_file_id VARCHAR,
|
||||
raw_sha256 VARCHAR NOT NULL,
|
||||
format VARCHAR,
|
||||
mime_type VARCHAR,
|
||||
@ -219,10 +220,14 @@ private fun migrate(connection: Connection) {
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR")
|
||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
|
||||
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
|
||||
connection.createIndexIfMissing("idx_book_files_raw_sha256", "CREATE INDEX IF NOT EXISTS idx_book_files_raw_sha256 ON book_files(raw_sha256)")
|
||||
connection.createIndexIfMissing("idx_book_files_book_id", "CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id)")
|
||||
connection.createIndexIfMissing("idx_book_files_duplicate", "CREATE INDEX IF NOT EXISTS idx_book_files_duplicate ON book_files(duplicate_of_file_id)")
|
||||
connection.createIndexIfMissing("idx_book_files_name_size_mtime", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_mtime ON book_files(original_filename, size_bytes, last_modified_millis)")
|
||||
connection.createIndexIfMissing("idx_book_files_name_size_hash", "CREATE INDEX IF NOT EXISTS idx_book_files_name_size_hash ON book_files(original_filename, size_bytes, raw_sha256)")
|
||||
connection.createIndexIfMissing("idx_book_files_updated_at", "CREATE INDEX IF NOT EXISTS idx_book_files_updated_at ON book_files(updated_at)")
|
||||
connection.createIndexIfMissing("idx_book_files_reading_order", "CREATE INDEX IF NOT EXISTS idx_book_files_reading_order ON book_files(reading_status, last_read_at)")
|
||||
statement.execute(
|
||||
@ -470,31 +475,32 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
MERGE INTO book_files(
|
||||
id, book_id, body_id, body_cluster_id, raw_sha256, format, mime_type, size_bytes,
|
||||
original_filename, storage_kind, storage_uri, content_object_id, last_modified_millis,
|
||||
last_seen_at, reading_status, last_read_at, created_at, updated_at
|
||||
id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format,
|
||||
mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id,
|
||||
last_modified_millis, last_seen_at, reading_status, last_read_at, created_at, updated_at
|
||||
)
|
||||
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setString(1, file.id)
|
||||
statement.setStringOrNull(2, file.bookId)
|
||||
statement.setStringOrNull(3, file.bodyId)
|
||||
statement.setStringOrNull(4, file.bodyClusterId)
|
||||
statement.setString(5, file.rawSha256)
|
||||
statement.setStringOrNull(6, file.format)
|
||||
statement.setStringOrNull(7, file.mimeType)
|
||||
statement.setLongOrNull(8, file.sizeBytes)
|
||||
statement.setStringOrNull(9, file.originalFilename)
|
||||
statement.setString(10, file.storageKind.name)
|
||||
statement.setStringOrNull(11, file.storageUri)
|
||||
statement.setStringOrNull(12, file.contentObjectId)
|
||||
statement.setLongOrNull(13, file.lastModifiedMillis)
|
||||
statement.setLongOrNull(14, file.lastSeenAt)
|
||||
statement.setString(15, file.readingStatus.name)
|
||||
statement.setLongOrNull(16, file.lastReadAt)
|
||||
statement.setLong(17, file.createdAt)
|
||||
statement.setLong(18, file.updatedAt)
|
||||
statement.setStringOrNull(5, file.duplicateOfFileId)
|
||||
statement.setString(6, file.rawSha256)
|
||||
statement.setStringOrNull(7, file.format)
|
||||
statement.setStringOrNull(8, file.mimeType)
|
||||
statement.setLongOrNull(9, file.sizeBytes)
|
||||
statement.setStringOrNull(10, file.originalFilename)
|
||||
statement.setString(11, file.storageKind.name)
|
||||
statement.setStringOrNull(12, file.storageUri)
|
||||
statement.setStringOrNull(13, file.contentObjectId)
|
||||
statement.setLongOrNull(14, file.lastModifiedMillis)
|
||||
statement.setLongOrNull(15, file.lastSeenAt)
|
||||
statement.setString(16, file.readingStatus.name)
|
||||
statement.setLongOrNull(17, file.lastReadAt)
|
||||
statement.setLong(18, file.createdAt)
|
||||
statement.setLong(19, file.updatedAt)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
@ -534,27 +540,133 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
||||
}
|
||||
}
|
||||
|
||||
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
|
||||
override fun findByOriginalFilenameSizeAndModified(
|
||||
originalFilename: String,
|
||||
sizeBytes: Long,
|
||||
lastModifiedMillis: Long,
|
||||
): List<BookFileRecord> {
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
WITH visible_files AS (
|
||||
SELECT * FROM book_files
|
||||
WHERE original_filename = ?
|
||||
AND size_bytes = ?
|
||||
AND last_modified_millis = ?
|
||||
ORDER BY created_at
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setString(1, originalFilename)
|
||||
statement.setLong(2, sizeBytes)
|
||||
statement.setLong(3, lastModifiedMillis)
|
||||
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun findByOriginalFilenameSizeAndRawSha256(
|
||||
originalFilename: String,
|
||||
sizeBytes: Long,
|
||||
rawSha256: String,
|
||||
): List<BookFileRecord> {
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
SELECT * FROM book_files
|
||||
WHERE original_filename = ?
|
||||
AND size_bytes = ?
|
||||
AND raw_sha256 = ?
|
||||
ORDER BY created_at
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setString(1, originalFilename)
|
||||
statement.setLong(2, sizeBytes)
|
||||
statement.setString(3, rawSha256)
|
||||
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toBookFileRecord() } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord? {
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
SELECT * FROM book_files
|
||||
WHERE duplicate_of_file_id IS NULL
|
||||
AND (
|
||||
(? IS NOT NULL AND body_cluster_id = ?)
|
||||
OR (? IS NOT NULL AND body_id = ?)
|
||||
OR raw_sha256 = ?
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||
THEN 0 ELSE 1
|
||||
END,
|
||||
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
last_read_at DESC NULLS LAST,
|
||||
updated_at DESC,
|
||||
id
|
||||
LIMIT 1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setStringOrNull(1, bodyClusterId)
|
||||
statement.setStringOrNull(2, bodyClusterId)
|
||||
statement.setStringOrNull(3, bodyId)
|
||||
statement.setStringOrNull(4, bodyId)
|
||||
statement.setString(5, rawSha256)
|
||||
statement.executeQuery().use { resultSet ->
|
||||
if (resultSet.next()) resultSet.toBookFileRecord() else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun markDuplicateFiles(): Int {
|
||||
val now = System.currentTimeMillis()
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
MERGE INTO book_files(id, duplicate_of_file_id, updated_at)
|
||||
KEY(id)
|
||||
SELECT id, primary_file_id, ?
|
||||
FROM (
|
||||
SELECT
|
||||
f.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(f.body_cluster_id, f.body_id, f.book_id, f.raw_sha256, f.id)
|
||||
id,
|
||||
FIRST_VALUE(id) OVER (
|
||||
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(f.format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(f.original_filename, '')) LIKE '%.fb2.zip'
|
||||
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||
THEN 0 ELSE 1
|
||||
END,
|
||||
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
f.last_read_at DESC NULLS LAST,
|
||||
f.updated_at DESC,
|
||||
f.id
|
||||
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
last_read_at DESC NULLS LAST,
|
||||
updated_at DESC,
|
||||
id
|
||||
) AS primary_file_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(body_cluster_id, body_id, book_id, raw_sha256, id)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(format, '')) = 'fb2.zip'
|
||||
OR LOWER(COALESCE(original_filename, '')) LIKE '%.fb2.zip'
|
||||
THEN 0 ELSE 1
|
||||
END,
|
||||
CASE WHEN reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
last_read_at DESC NULLS LAST,
|
||||
updated_at DESC,
|
||||
id
|
||||
) AS duplicate_rank
|
||||
FROM book_files f
|
||||
)
|
||||
FROM book_files
|
||||
WHERE duplicate_of_file_id IS NULL
|
||||
) ranked
|
||||
WHERE duplicate_rank > 1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setLong(1, now)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun listLibraryFiles(limit: Int, offset: Int): List<LibraryFileRecord> {
|
||||
markDuplicateFiles()
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
SELECT
|
||||
f.id AS file_id,
|
||||
f.book_id AS book_id,
|
||||
@ -569,9 +681,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
||||
f.last_seen_at AS last_seen_at,
|
||||
f.reading_status AS reading_status,
|
||||
f.last_read_at AS last_read_at
|
||||
FROM visible_files f
|
||||
FROM book_files f
|
||||
LEFT JOIN books b ON b.id = f.book_id
|
||||
WHERE f.duplicate_rank = 1
|
||||
WHERE f.duplicate_of_file_id IS NULL
|
||||
ORDER BY
|
||||
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
||||
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
||||
@ -836,6 +948,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
|
||||
bookId = getString("book_id"),
|
||||
bodyId = getString("body_id"),
|
||||
bodyClusterId = getString("body_cluster_id"),
|
||||
duplicateOfFileId = getString("duplicate_of_file_id"),
|
||||
rawSha256 = getString("raw_sha256"),
|
||||
format = getString("format"),
|
||||
mimeType = getString("mime_type"),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user