فهرست منبع

[优化] 更牛逼的换源设置

HapeLee 6 ماه پیش
والد
کامیت
2989fe0405

+ 3 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -39,6 +39,7 @@ import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
+import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
 import io.legado.app.domain.usecase.DeleteBooksUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
@@ -113,6 +114,7 @@ val appModule = module {
     singleOf(::AppStartupMaintenanceUseCase)
     singleOf(::BatchCacheDownloadUseCase)
     singleOf(::CacheBookChaptersUseCase)
+    singleOf(::ChangeBookSourceUseCase)
     singleOf(::ClearBookCacheUseCase)
     singleOf(::DeleteBooksUseCase)
     singleOf(::GetReadingProgressUseCase)
@@ -194,6 +196,7 @@ val appModule = module {
             cacheConfig = get(),
             batchCacheDownloadUseCase = get(),
             cacheBookChaptersUseCase = get(),
+            changeBookSourceUseCase = get(),
             clearBookCacheUseCase = get(),
             deleteBooksUseCase = get(),
             updateBooksGroupUseCase = get()

+ 295 - 0
app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt

@@ -0,0 +1,295 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
+import io.legado.app.data.dao.BookChapterDao
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.removeType
+import io.legado.app.model.ReadBook
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.utils.mapAsync
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.toList
+import java.util.concurrent.atomic.AtomicInteger
+
+data class ChangeSourceMigrationOptions(
+    val migrateChapters: Boolean = true,
+    val migrateReadingProgress: Boolean = true,
+    val migrateGroup: Boolean = true,
+    val migrateCover: Boolean = true,
+    val migrateCategory: Boolean = true,
+    val migrateRemark: Boolean = true,
+    val migrateReadConfig: Boolean = true,
+    val deleteDownloadedChapters: Boolean = false,
+)
+
+data class ChangeBookSourceResult(
+    val oldBookUrl: String,
+    val book: Book,
+    val chapters: List<BookChapter>,
+)
+
+data class BatchChangeBookSourceResult(
+    val changedCount: Int,
+    val failedCount: Int,
+    val skippedCount: Int,
+)
+
+data class BatchChangeSourceCandidate(
+    val source: BookSource,
+    val book: Book,
+    val chapters: List<BookChapter>,
+)
+
+data class BatchChangeSourcePreviewItem(
+    val oldBook: Book,
+    val candidates: List<BatchChangeSourceCandidate> = emptyList(),
+    val selectedCandidateIndex: Int = 0,
+    val status: BatchChangeSourcePreviewStatus = BatchChangeSourcePreviewStatus.NotFound,
+) {
+    val selectedCandidate: BatchChangeSourceCandidate?
+        get() = candidates.getOrNull(selectedCandidateIndex)
+
+    val canMigrate: Boolean
+        get() = status == BatchChangeSourcePreviewStatus.Matched && selectedCandidate != null
+}
+
+enum class BatchChangeSourcePreviewStatus {
+    Matched,
+    NotFound,
+    Skipped,
+}
+
+class ChangeBookSourceUseCase(
+    private val bookDao: BookDao,
+    private val bookChapterDao: BookChapterDao,
+) {
+
+    fun applyMigration(
+        oldBook: Book,
+        newBook: Book,
+        chapters: List<BookChapter>,
+        options: ChangeSourceMigrationOptions,
+    ): Book {
+        oldBook.applyMigrationTo(newBook, chapters, options)
+        newBook.removeType(BookType.updateError)
+        return newBook
+    }
+
+    fun changeTo(
+        oldBook: Book,
+        newBook: Book,
+        chapters: List<BookChapter>,
+        options: ChangeSourceMigrationOptions,
+    ): ChangeBookSourceResult {
+        val oldBookUrl = oldBook.bookUrl
+        applyMigration(oldBook, newBook, chapters, options)
+        if (options.deleteDownloadedChapters) {
+            BookHelp.clearCache(oldBook)
+        } else if (oldBook.bookUrl != newBook.bookUrl) {
+            BookHelp.updateCacheFolder(oldBook, newBook)
+        }
+        bookChapterDao.delByBook(oldBook.bookUrl)
+        bookDao.delete(oldBook)
+        bookDao.insert(newBook)
+        if (options.migrateChapters) {
+            bookChapterDao.insert(*chapters.toTypedArray())
+            ReadBook.onChapterListUpdated(newBook)
+        }
+        return ChangeBookSourceResult(oldBookUrl, newBook, chapters)
+    }
+
+    suspend fun batchChangeTo(
+        books: List<Book>,
+        source: BookSource,
+        options: ChangeSourceMigrationOptions,
+        onProgress: (current: Int, total: Int, bookName: String) -> Unit,
+    ): BatchChangeBookSourceResult {
+        var changedCount = 0
+        var failedCount = 0
+        var skippedCount = 0
+        books.forEachIndexed { index, book ->
+            onProgress(index + 1, books.size, book.name)
+            if (book.isLocal || book.origin == source.bookSourceUrl) {
+                skippedCount++
+                return@forEachIndexed
+            }
+            val newBook = WebBook.preciseSearchAwait(source, book.name, book.author)
+                .onFailure {
+                    AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
+                }.getOrNull()
+            if (newBook == null) {
+                failedCount++
+                return@forEachIndexed
+            }
+            val infoLoaded = kotlin.runCatching {
+                if (newBook.tocUrl.isEmpty()) {
+                    WebBook.getBookInfoAwait(source, newBook)
+                }
+            }.onFailure {
+                AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
+            }.isSuccess
+            if (!infoLoaded) {
+                failedCount++
+                return@forEachIndexed
+            }
+            val chapters = WebBook.getChapterListAwait(source, newBook)
+                .onFailure {
+                    AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
+                }.getOrNull()
+            if (chapters == null) {
+                failedCount++
+            } else {
+                changeTo(book, newBook, chapters, options)
+                changedCount++
+            }
+        }
+        return BatchChangeBookSourceResult(changedCount, failedCount, skippedCount)
+    }
+
+    suspend fun prepareBatchChange(
+        books: List<Book>,
+        sources: List<BookSource>,
+        concurrency: Int,
+        onProgress: (current: Int, total: Int, bookName: String) -> Unit,
+    ): List<BatchChangeSourcePreviewItem> {
+        val progress = AtomicInteger(0)
+        return books.withIndex().asFlow()
+            .mapAsync(concurrency.coerceAtLeast(1)) { indexedBook ->
+                val book = indexedBook.value
+                onProgress(progress.incrementAndGet(), books.size, book.name)
+                val previewItem = if (book.isLocal) {
+                    BatchChangeSourcePreviewItem(
+                        oldBook = book,
+                        status = BatchChangeSourcePreviewStatus.Skipped
+                    )
+                } else {
+                    val candidates = arrayListOf<BatchChangeSourceCandidate>()
+                    sources.filterNot { it.bookSourceUrl == book.origin }.forEach { source ->
+                        findBookInSource(book, source)?.let { (newBook, chapters) ->
+                            candidates.add(
+                                BatchChangeSourceCandidate(
+                                    source = source,
+                                    book = newBook,
+                                    chapters = chapters,
+                                )
+                            )
+                        }
+                    }
+                    if (candidates.isEmpty()) {
+                        BatchChangeSourcePreviewItem(oldBook = book)
+                    } else {
+                        BatchChangeSourcePreviewItem(
+                            oldBook = book,
+                            candidates = candidates,
+                            status = BatchChangeSourcePreviewStatus.Matched
+                        )
+                    }
+                }
+                indexedBook.index to previewItem
+            }
+            .toList()
+            .sortedBy { it.first }
+            .map { it.second }
+    }
+
+    fun changePreviewItems(
+        items: List<BatchChangeSourcePreviewItem>,
+        options: ChangeSourceMigrationOptions,
+    ): BatchChangeBookSourceResult {
+        var changedCount = 0
+        var failedCount = 0
+        var skippedCount = 0
+        items.forEach { item ->
+            if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
+                skippedCount++
+                return@forEach
+            }
+            val candidate = item.selectedCandidate
+            if (candidate == null) {
+                failedCount++
+                return@forEach
+            }
+            changeTo(item.oldBook, candidate.book, candidate.chapters, options)
+            changedCount++
+        }
+        return BatchChangeBookSourceResult(changedCount, failedCount, skippedCount)
+    }
+
+    private suspend fun findBookInSource(
+        oldBook: Book,
+        source: BookSource,
+    ): Pair<Book, List<BookChapter>>? {
+        val newBook = WebBook.preciseSearchAwait(source, oldBook.name, oldBook.author)
+            .onFailure {
+                AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
+            }.getOrNull() ?: return null
+        val infoLoaded = kotlin.runCatching {
+            if (newBook.tocUrl.isEmpty()) {
+                WebBook.getBookInfoAwait(source, newBook)
+            }
+        }.onFailure {
+            AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
+        }.isSuccess
+        if (!infoLoaded) return null
+        val chapters = WebBook.getChapterListAwait(source, newBook)
+            .onFailure {
+                AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
+            }.getOrNull() ?: return null
+        return newBook to chapters
+    }
+
+    private fun Book.applyMigrationTo(
+        newBook: Book,
+        chapters: List<BookChapter>,
+        options: ChangeSourceMigrationOptions,
+    ) {
+        newBook.totalChapterNum = chapters.size
+        if (options.migrateReadingProgress && chapters.isNotEmpty()) {
+            newBook.durChapterIndex = BookHelp
+                .getDurChapter(durChapterIndex, durChapterTitle, chapters, totalChapterNum)
+                .coerceIn(0, chapters.lastIndex)
+            newBook.durChapterTitle = chapters[newBook.durChapterIndex].getDisplayTitle(
+                ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules(),
+                getUseReplaceRule()
+            )
+            newBook.durChapterPos = durChapterPos
+            newBook.durChapterTime = durChapterTime
+        } else {
+            newBook.durChapterIndex = 0
+            newBook.durChapterTitle = chapters.firstOrNull()?.getDisplayTitle(
+                ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules(),
+                getUseReplaceRule()
+            )
+            newBook.durChapterPos = 0
+            newBook.durChapterTime = System.currentTimeMillis()
+        }
+        if (options.migrateGroup) {
+            newBook.group = group
+            newBook.order = order
+        }
+        if (options.migrateCover) {
+            newBook.customCoverUrl = customCoverUrl
+        }
+        if (options.migrateCategory) {
+            newBook.customTag = customTag
+        }
+        if (options.migrateRemark) {
+            newBook.customIntro = customIntro
+            newBook.remark = remark
+        }
+        newBook.canUpdate = canUpdate
+        if (config.fixedType) {
+            newBook.type = type
+        }
+        if (options.migrateReadConfig) {
+            newBook.readConfig = readConfig
+        }
+    }
+}

+ 570 - 0
app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt

@@ -4,16 +4,23 @@ import androidx.compose.ui.focus.focusRequester
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
@@ -23,12 +30,18 @@ import androidx.compose.material.icons.filled.Bookmarks
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Info
 import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.SkipNext
 import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material.icons.filled.Upload
 import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.FloatingActionButtonMenu
 import androidx.compose.material3.FloatingActionButtonMenuItem
@@ -57,21 +70,33 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.constant.IntentAction
+import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.BookSourcePart
+import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem
+import io.legado.app.domain.usecase.BatchChangeSourcePreviewStatus
 import io.legado.app.help.book.getExportFileName
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.tryParesExportFileName
 import io.legado.app.service.ExportBookService
 import io.legado.app.ui.about.AppLogSheet
+import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
+import io.legado.app.ui.book.info.BookInfoActivity
+import io.legado.app.ui.book.info.ChangeSourceSheet
 import io.legado.app.ui.book.info.GroupSelectSheet
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppLinearProgressIndicator
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.SmallTonalTextButton
 import io.legado.app.ui.widget.components.button.TopBarActionButton
 import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.cover.CoilBookCover
 import io.legado.app.ui.widget.components.divider.PillDivider
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -80,17 +105,22 @@ import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.list.ListScaffold
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.ACache
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.checkWrite
 import io.legado.app.utils.isContentScheme
+import io.legado.app.utils.move
+import io.legado.app.utils.startActivity
 import io.legado.app.utils.startService
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.verificationField
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.widget.components.button.MediumIconButton
 import org.koin.androidx.compose.koinViewModel
+import sh.calvin.reorderable.rememberReorderableLazyListState
 
 data class CacheFabAction(
     val icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -137,6 +167,11 @@ private fun CacheScreen(
     var showGroupSelectSheet by remember { mutableStateOf(false) }
     var showDeleteBookConfirmDialog by remember { mutableStateOf(false) }
     var showCustomExportDialog by remember { mutableStateOf(false) }
+    var showBatchSourcePickerSheet by remember { mutableStateOf(false) }
+    var pendingBatchSources by remember { mutableStateOf<List<BookSource>>(emptyList()) }
+    var singleChangeSourceBook by remember { mutableStateOf<Book?>(null) }
+    var manualSearchPreviewBook by remember { mutableStateOf<Book?>(null) }
+    var otherSourcePreviewItem by remember { mutableStateOf<BatchChangeSourcePreviewItem?>(null) }
     var pendingMoveGroupBookUrl by remember { mutableStateOf<String?>(null) }
     var groupPickerCurrentGroupId by remember { mutableLongStateOf(0L) }
     var moreMenuBookUrl by remember { mutableStateOf<String?>(null) }
@@ -294,6 +329,11 @@ private fun CacheScreen(
             when (effect) {
                 is CacheEffect.ShowMessage -> context.toastOnUi(effect.message)
                 is CacheEffect.NotifyBookChanged -> Unit
+                is CacheEffect.OpenBookInfo -> context.startActivity<BookInfoActivity> {
+                    putExtra("name", effect.name)
+                    putExtra("author", effect.author)
+                    putExtra("bookUrl", effect.bookUrl)
+                }
             }
         }
     }
@@ -379,6 +419,14 @@ private fun CacheScreen(
                 showBatchDownloadConfirmDialog = true
             }
         },
+        CacheFabAction(
+            Icons.Default.Refresh,
+            "批量换源"
+        ) {
+            if (selectedBookUrls.isNotEmpty()) {
+                showBatchSourcePickerSheet = true
+            }
+        },
         CacheFabAction(
             Icons.Default.Bookmarks,
             stringResource(R.string.move_to_group)
@@ -722,6 +770,13 @@ private fun CacheScreen(
                                     expanded = moreMenuBookUrl == book.bookUrl,
                                     onDismissRequest = { moreMenuBookUrl = null }
                                 ) { dismiss ->
+                                    RoundDropdownMenuItem(
+                                        text = "换源",
+                                        onClick = {
+                                            singleChangeSourceBook = book
+                                            dismiss()
+                                        }
+                                    )
                                     RoundDropdownMenuItem(
                                         text = "删除书籍",
                                         onClick = {
@@ -747,6 +802,144 @@ private fun CacheScreen(
         }
     }
 
+    singleChangeSourceBook?.let { book ->
+        ChangeSourceSheet(
+            show = true,
+            oldBook = book,
+            onDismissRequest = { singleChangeSourceBook = null },
+            onReplace = { source, newBook, toc, options ->
+                viewModel.dispatch(
+                    CacheIntent.ChangeBookSource(
+                        oldBookUrl = book.bookUrl,
+                        source = source,
+                        book = newBook,
+                        chapters = toc,
+                        options = options,
+                    )
+                )
+                singleChangeSourceBook = null
+            },
+            onAddAsNew = { _, _ ->
+                context.toastOnUi("请在书籍详情页添加为新书")
+            },
+        )
+    }
+
+    BookSourcePickerSheet(
+        show = showBatchSourcePickerSheet,
+        title = "选择目标书源",
+        onDismissRequest = { showBatchSourcePickerSheet = false },
+        onConfirm = { sources ->
+            pendingBatchSources = sources
+            showBatchSourcePickerSheet = false
+        }
+    )
+
+    ChangeSourceMigrationOptionsSheet(
+        show = pendingBatchSources.isNotEmpty(),
+        title = "批量换源选项",
+        subtitle = "将对已选 ${selectedBookUrls.size} 本书执行换源;选项只对本次操作生效。",
+        onDismissRequest = { pendingBatchSources = emptyList() },
+        onConfirm = { options ->
+            viewModel.dispatch(
+                CacheIntent.BatchChangeBookSource(
+                    bookUrls = selectedBookUrls,
+                    sources = pendingBatchSources,
+                    options = options,
+                )
+            )
+            pendingBatchSources = emptyList()
+            clearSelection()
+        }
+    )
+
+    BatchChangePreviewSheet(
+        show = state.batchChangePreviewItems.isNotEmpty(),
+        items = state.batchChangePreviewItems,
+        onDismissRequest = { viewModel.dispatch(CacheIntent.DismissBatchChangePreview) },
+        onOpenBook = { book, inBookshelf ->
+            viewModel.dispatch(CacheIntent.OpenBookInfoPreview(book, inBookshelf))
+        },
+        onManualSearch = { book -> manualSearchPreviewBook = book },
+        onSkip = { bookUrl -> viewModel.dispatch(CacheIntent.SkipPreviewItem(bookUrl)) },
+        onMigrate = { bookUrl -> viewModel.dispatch(CacheIntent.MigratePreviewItem(bookUrl)) },
+        onAddToShelf = { bookUrl ->
+            viewModel.dispatch(CacheIntent.AddPreviewItemToShelf(bookUrl))
+        },
+        onShowOtherSources = { item -> otherSourcePreviewItem = item },
+        onMigrateAll = { viewModel.dispatch(CacheIntent.MigrateAllPreviewItems) },
+    )
+
+    manualSearchPreviewBook?.let { book ->
+        ChangeSourceSheet(
+            show = true,
+            oldBook = book,
+            onDismissRequest = { manualSearchPreviewBook = null },
+            onReplace = { source, newBook, toc, _ ->
+                viewModel.dispatch(
+                    CacheIntent.UpdatePreviewItem(
+                        oldBookUrl = book.bookUrl,
+                        source = source,
+                        book = newBook,
+                        chapters = toc,
+                    )
+                )
+                manualSearchPreviewBook = null
+            },
+            onAddAsNew = { _, _ ->
+                context.toastOnUi("请先选择替换候选后再新增至书架")
+            },
+        )
+    }
+
+    OtherSourceOptionsSheet(
+        item = otherSourcePreviewItem,
+        onDismissRequest = { otherSourcePreviewItem = null },
+        onSelect = { oldBookUrl, index ->
+            viewModel.dispatch(CacheIntent.SelectPreviewCandidate(oldBookUrl, index))
+            otherSourcePreviewItem = null
+        },
+        onOpenBook = { book ->
+            viewModel.dispatch(CacheIntent.OpenBookInfoPreview(book, false))
+        }
+    )
+
+    AppAlertDialog(
+        show = state.isChangingSource || state.changeSourceError != null,
+        onDismissRequest = {
+            if (!state.isChangingSource) {
+                viewModel.dispatch(CacheIntent.DismissChangeSourceStatus)
+            }
+        },
+        title = stringResource(R.string.change_source_batch),
+        content = {
+            Column(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalAlignment = Alignment.CenterHorizontally,
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                if (state.isChangingSource) {
+                    CircularProgressIndicator()
+                }
+                AppText(
+                    text = state.changeSourceError ?: state.changeSourceProgress ?: "准备中",
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = if (state.changeSourceError == null) {
+                        LegadoTheme.colorScheme.onSurface
+                    } else {
+                        LegadoTheme.colorScheme.error
+                    },
+                )
+            }
+        },
+        confirmText = stringResource(android.R.string.ok),
+        onConfirm = if (state.isChangingSource) {
+            null
+        } else {
+            { viewModel.dispatch(CacheIntent.DismissChangeSourceStatus) }
+        },
+    )
+
     FilePickerSheet(
         show = showFilePickerSheet,
         onDismissRequest = { showFilePickerSheet = false },
@@ -1056,6 +1249,383 @@ private fun CacheScreen(
     )
 }
 
+@Composable
+private fun BookSourcePickerSheet(
+    show: Boolean,
+    title: String,
+    onDismissRequest: () -> Unit,
+    onConfirm: (List<BookSource>) -> Unit,
+) {
+    val sources by appDb.bookSourceDao.flowEnabled()
+        .collectAsStateWithLifecycle(initialValue = emptyList())
+    var searchKey by rememberSaveable(show) { mutableStateOf("") }
+    var selectedSources by remember(show) { mutableStateOf<List<BookSourcePart>>(emptyList()) }
+    val selectedUrls = remember(selectedSources) {
+        selectedSources.mapTo(hashSetOf()) { it.bookSourceUrl }
+    }
+    val selectedListState = rememberLazyListState()
+    val reorderableState = rememberReorderableLazyListState(selectedListState) { from, to ->
+        selectedSources = selectedSources.toMutableList().apply {
+            move(from.index, to.index)
+        }
+    }
+    val filteredSources = remember(sources, searchKey) {
+        if (searchKey.isBlank()) {
+            sources
+        } else {
+            val key = searchKey.trim()
+            sources.filter { source ->
+                source.bookSourceName.contains(key, true) ||
+                        source.bookSourceGroup.orEmpty().contains(key, true) ||
+                        source.bookSourceUrl.contains(key, true)
+            }
+        }
+    }
+    val availableSources = remember(filteredSources, selectedUrls) {
+        filteredSources.filterNot { selectedUrls.contains(it.bookSourceUrl) }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = title,
+        endAction = {
+            SmallTonalTextButton(
+                text = stringResource(android.R.string.ok),
+                imageVector = Icons.Default.PlayArrow,
+                onClick = {
+                    onConfirm(selectedSources.mapNotNull { it.getBookSource() })
+                }
+            )
+        }
+    ) {
+        AppTextField(
+            value = searchKey,
+            onValueChange = { searchKey = it },
+            modifier = Modifier.fillMaxWidth(),
+            label = stringResource(R.string.screen),
+            backgroundColor = LegadoTheme.colorScheme.surface,
+        )
+        Spacer(modifier = Modifier.height(12.dp))
+        if (selectedSources.isNotEmpty()) {
+            AppText(
+                text = "已选书源(长按拖拽排序)",
+                style = LegadoTheme.typography.labelMedium,
+                color = LegadoTheme.colorScheme.primary,
+            )
+            Spacer(modifier = Modifier.height(8.dp))
+            LazyColumn(
+                state = selectedListState,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 280.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                items(selectedSources, key = { it.bookSourceUrl }) { source ->
+                    ReorderableSelectionItem(
+                        state = reorderableState,
+                        key = source.bookSourceUrl,
+                        title = source.bookSourceName,
+                        subtitle = source.bookSourceGroup,
+                        isSelected = true,
+                        containerColor = LegadoTheme.colorScheme.onSheetContent,
+                        onToggleSelection = {
+                            selectedSources = selectedSources.filterNot {
+                                it.bookSourceUrl == source.bookSourceUrl
+                            }
+                        }
+                    )
+                }
+            }
+            Spacer(modifier = Modifier.height(12.dp))
+        }
+        AppText(
+            text = "可选书源",
+            style = LegadoTheme.typography.labelMedium,
+            color = LegadoTheme.colorScheme.onSurfaceVariant,
+        )
+        Spacer(modifier = Modifier.height(8.dp))
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxWidth()
+                .heightIn(max = 560.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(availableSources, key = { it.bookSourceUrl }) { source ->
+                SourcePickerItem(
+                    source = source,
+                    isSelected = false,
+                    onClick = {
+                        selectedSources = selectedSources + source
+                    },
+                )
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+}
+
+@Composable
+private fun SourcePickerItem(
+    source: BookSourcePart,
+    isSelected: Boolean,
+    onClick: () -> Unit,
+) {
+    SelectionItemCard(
+        title = source.bookSourceName,
+        supportingContent = {
+            AppText(
+                text = source.bookSourceGroup.orEmpty().ifBlank { source.bookSourceUrl },
+                style = LegadoTheme.typography.bodySmall,
+                maxLines = 2,
+            )
+        },
+        isSelected = isSelected,
+        onToggleSelection = onClick,
+        containerColor = LegadoTheme.colorScheme.onSheetContent,
+    )
+}
+
+@Composable
+private fun BatchChangePreviewSheet(
+    show: Boolean,
+    items: List<BatchChangeSourcePreviewItem>,
+    onDismissRequest: () -> Unit,
+    onOpenBook: (Book, Boolean) -> Unit,
+    onManualSearch: (Book) -> Unit,
+    onSkip: (String) -> Unit,
+    onMigrate: (String) -> Unit,
+    onAddToShelf: (String) -> Unit,
+    onShowOtherSources: (BatchChangeSourcePreviewItem) -> Unit,
+    onMigrateAll: () -> Unit,
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = "批量换源预览",
+        endAction = {
+            SmallTonalTextButton(
+                text = "迁移全部",
+                imageVector = Icons.Default.PlayArrow,
+                onClick = onMigrateAll,
+            )
+        }
+    ) {
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxWidth()
+                .heightIn(max = 640.dp),
+            verticalArrangement = Arrangement.spacedBy(10.dp),
+        ) {
+            items(items, key = { it.oldBook.bookUrl }) { item ->
+                BatchChangePreviewRow(
+                    item = item,
+                    onOpenBook = onOpenBook,
+                    onManualSearch = onManualSearch,
+                    onSkip = onSkip,
+                    onMigrate = onMigrate,
+                    onAddToShelf = onAddToShelf,
+                    onShowOtherSources = onShowOtherSources,
+                )
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun BatchChangePreviewRow(
+    item: BatchChangeSourcePreviewItem,
+    onOpenBook: (Book, Boolean) -> Unit,
+    onManualSearch: (Book) -> Unit,
+    onSkip: (String) -> Unit,
+    onMigrate: (String) -> Unit,
+    onAddToShelf: (String) -> Unit,
+    onShowOtherSources: (BatchChangeSourcePreviewItem) -> Unit,
+) {
+    val candidate = item.selectedCandidate
+    NormalCard(
+        modifier = Modifier.fillMaxWidth(),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(10.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+                verticalAlignment = Alignment.Top,
+            ) {
+                PreviewBookInfo(
+                    title = "原书籍",
+                    book = item.oldBook,
+                    chapterCount = item.oldBook.totalChapterNum,
+                    onClick = { onOpenBook(item.oldBook, true) },
+                    modifier = Modifier.weight(1f),
+                )
+                PreviewBookInfo(
+                    title = if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
+                        statusText(item.status)
+                    } else {
+                        candidate?.source?.bookSourceName ?: statusText(item.status)
+                    },
+                    book = if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
+                        null
+                    } else {
+                        candidate?.book
+                    },
+                    chapterCount = if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
+                        null
+                    } else {
+                        candidate?.chapters?.size
+                    },
+                    onClick = {
+                        candidate?.book?.let { onOpenBook(it, false) }
+                    },
+                    modifier = Modifier.weight(1f),
+                )
+            }
+            FlowRow(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                MediumIconButton(
+                    imageVector = Icons.Default.Search,
+                    onClick = { onManualSearch(item.oldBook) },
+                )
+                SmallTonalTextButton(
+                    text = "不迁移",
+                    imageVector = Icons.Default.SkipNext,
+                    onClick = { onSkip(item.oldBook.bookUrl) },
+                )
+                SmallTonalTextButton(
+                    text = "开始迁移",
+                    imageVector = Icons.Default.PlayArrow,
+                    onClick = { onMigrate(item.oldBook.bookUrl) },
+                )
+                SmallTonalTextButton(
+                    text = "新增至书架",
+                    imageVector = Icons.Default.Add,
+                    onClick = { onAddToShelf(item.oldBook.bookUrl) },
+                )
+                if (item.candidates.size > 1) {
+                    SmallTonalTextButton(
+                        text = "查看其他源信息",
+                        imageVector = Icons.Default.Info,
+                        onClick = { onShowOtherSources(item) },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun PreviewBookInfo(
+    title: String,
+    book: Book?,
+    chapterCount: Int?,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier.clickable(enabled = book != null, onClick = onClick),
+        verticalArrangement = Arrangement.spacedBy(6.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+    ) {
+        if (book != null) {
+            CoilBookCover(
+                name = book.name,
+                author = book.author,
+                path = book.getDisplayCover(),
+                sourceOrigin = book.origin,
+                modifier = Modifier.width(54.dp),
+            )
+            AppText(
+                text = book.name,
+                style = LegadoTheme.typography.labelMedium,
+                maxLines = 1,
+            )
+            AppText(
+                text = "${book.getRealAuthor()} · ${chapterCount ?: 0}章",
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 1,
+            )
+        } else {
+            Spacer(modifier = Modifier.size(54.dp))
+            AppText(
+                text = title,
+                style = LegadoTheme.typography.labelMedium,
+                color = LegadoTheme.colorScheme.error,
+                maxLines = 2,
+            )
+        }
+    }
+}
+
+@Composable
+private fun OtherSourceOptionsSheet(
+    item: BatchChangeSourcePreviewItem?,
+    onDismissRequest: () -> Unit,
+    onSelect: (String, Int) -> Unit,
+    onOpenBook: (Book) -> Unit,
+) {
+    AppModalBottomSheet(
+        show = item != null,
+        onDismissRequest = onDismissRequest,
+        title = "其他源信息",
+    ) {
+        val currentItem = item
+        if (currentItem != null) {
+            LazyColumn(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 560.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                items(currentItem.candidates.indices.toList(), key = { it }) { index ->
+                    val candidate = currentItem.candidates[index]
+                    SelectionItemCard(
+                        title = candidate.source.bookSourceName,
+                        subtitle = "${candidate.book.name} · ${candidate.chapters.size}章",
+                        supportingContent = {
+                            AppText(
+                                text = candidate.book.getRealAuthor(),
+                                style = LegadoTheme.typography.bodySmall,
+                            )
+                        },
+                        isSelected = index == currentItem.selectedCandidateIndex,
+                        onToggleSelection = { onSelect(currentItem.oldBook.bookUrl, index) },
+                        trailingAction = {
+                            SmallTonalIconButton(
+                                onClick = { onOpenBook(candidate.book) },
+                                imageVector = Icons.Default.Info,
+                                contentDescription = null,
+                            )
+                        },
+                        containerColor = LegadoTheme.colorScheme.onSheetContent,
+                    )
+                }
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+}
+
+private fun statusText(status: BatchChangeSourcePreviewStatus): String {
+    return when (status) {
+        BatchChangeSourcePreviewStatus.Matched -> "已匹配"
+        BatchChangeSourcePreviewStatus.NotFound -> "未找到"
+        BatchChangeSourcePreviewStatus.Skipped -> "不迁移"
+    }
+}
+
 private fun startExport(
     context: android.content.Context,
     path: String,

+ 347 - 0
app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt

@@ -6,21 +6,31 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.data.dao.BookChapterDao
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.dao.BookGroupDao
+import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.BookSource
+import io.legado.app.constant.BookType
+import io.legado.app.domain.usecase.BatchChangeSourceCandidate
+import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem
+import io.legado.app.domain.usecase.BatchChangeSourcePreviewStatus
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
+import io.legado.app.domain.usecase.ChangeBookSourceUseCase
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
 import io.legado.app.domain.usecase.DeleteBooksUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.removeType
 import io.legado.app.model.CacheBook
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.service.ExportBookService
 import io.legado.app.ui.config.cacheConfig.CacheConfig
+import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.main.bookshelf.toLightBook
 import io.legado.app.utils.cnCompare
 import kotlinx.coroutines.Job
@@ -53,6 +63,12 @@ data class CacheUiState(
     val groupList: List<BookGroup> = emptyList(),
     val books: List<Book> = emptyList(),
     val isDownloadRunning: Boolean = false,
+    val isChangingSource: Boolean = false,
+    val changeSourceProgress: String? = null,
+    val changeSourceMessage: String? = null,
+    val changeSourceError: String? = null,
+    val batchChangePreviewItems: List<BatchChangeSourcePreviewItem> = emptyList(),
+    val batchChangeOptions: ChangeSourceMigrationOptions = ChangeSourceMigrationOptions(),
     val cacheVersion: Long = 0,
     val deleteBookOriginal: Boolean = LocalConfig.deleteBookOriginal,
     val exportConfig: CacheExportConfig = CacheExportConfig()
@@ -73,6 +89,32 @@ sealed interface CacheIntent {
     data class DeleteBooks(val bookUrls: Set<String>, val deleteOriginal: Boolean) : CacheIntent
     data class ClearCachesForBooks(val bookUrls: Set<String>) : CacheIntent
     data class DownloadBooks(val bookUrls: Set<String>, val downloadAllChapters: Boolean) : CacheIntent
+    data class ChangeBookSource(
+        val oldBookUrl: String,
+        val source: BookSource,
+        val book: Book,
+        val chapters: List<BookChapter>,
+        val options: ChangeSourceMigrationOptions,
+    ) : CacheIntent
+    data class BatchChangeBookSource(
+        val bookUrls: Set<String>,
+        val sources: List<BookSource>,
+        val options: ChangeSourceMigrationOptions,
+    ) : CacheIntent
+    data class MigratePreviewItem(val oldBookUrl: String) : CacheIntent
+    data class SkipPreviewItem(val oldBookUrl: String) : CacheIntent
+    data class SelectPreviewCandidate(val oldBookUrl: String, val candidateIndex: Int) : CacheIntent
+    data class UpdatePreviewItem(
+        val oldBookUrl: String,
+        val source: BookSource,
+        val book: Book,
+        val chapters: List<BookChapter>,
+    ) : CacheIntent
+    data class AddPreviewItemToShelf(val oldBookUrl: String) : CacheIntent
+    data class OpenBookInfoPreview(val book: Book, val inBookshelf: Boolean) : CacheIntent
+    data object MigrateAllPreviewItems : CacheIntent
+    data object DismissChangeSourceStatus : CacheIntent
+    data object DismissBatchChangePreview : CacheIntent
     data class SetExportUseReplace(val enabled: Boolean) : CacheIntent
     data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent
     data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent
@@ -88,6 +130,7 @@ sealed interface CacheIntent {
 sealed interface CacheEffect {
     data class NotifyBookChanged(val bookUrl: String) : CacheEffect
     data class ShowMessage(val message: String) : CacheEffect
+    data class OpenBookInfo(val bookUrl: String, val name: String, val author: String) : CacheEffect
 }
 
 class CacheViewModel(
@@ -98,6 +141,7 @@ class CacheViewModel(
     val cacheConfig: CacheConfig,
     private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
     private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
+    private val changeBookSourceUseCase: ChangeBookSourceUseCase,
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
     private val deleteBooksUseCase: DeleteBooksUseCase,
     private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
@@ -131,6 +175,56 @@ class CacheViewModel(
             is CacheIntent.DeleteBooks -> deleteBooks(intent.bookUrls, intent.deleteOriginal)
             is CacheIntent.ClearCachesForBooks -> clearCachesForBooks(intent.bookUrls)
             is CacheIntent.DownloadBooks -> downloadBooks(intent.bookUrls, intent.downloadAllChapters)
+            is CacheIntent.ChangeBookSource -> changeBookSource(
+                intent.oldBookUrl,
+                intent.source,
+                intent.book,
+                intent.chapters,
+                intent.options
+            )
+
+            is CacheIntent.BatchChangeBookSource -> batchChangeBookSource(
+                intent.bookUrls,
+                intent.sources,
+                intent.options
+            )
+
+            is CacheIntent.MigratePreviewItem -> migratePreviewItem(intent.oldBookUrl)
+            is CacheIntent.SkipPreviewItem -> skipPreviewItem(intent.oldBookUrl)
+            is CacheIntent.SelectPreviewCandidate -> selectPreviewCandidate(
+                intent.oldBookUrl,
+                intent.candidateIndex
+            )
+
+            is CacheIntent.UpdatePreviewItem -> updatePreviewItem(
+                intent.oldBookUrl,
+                intent.source,
+                intent.book,
+                intent.chapters
+            )
+
+            is CacheIntent.AddPreviewItemToShelf -> addPreviewItemToShelf(intent.oldBookUrl)
+            is CacheIntent.OpenBookInfoPreview -> openBookInfoPreview(
+                intent.book,
+                intent.inBookshelf
+            )
+
+            CacheIntent.MigrateAllPreviewItems -> migrateAllPreviewItems()
+
+            CacheIntent.DismissChangeSourceStatus -> {
+                _uiState.update {
+                    it.copy(
+                        changeSourceProgress = null,
+                        changeSourceMessage = null,
+                        changeSourceError = null,
+                    )
+                }
+            }
+
+            CacheIntent.DismissBatchChangePreview -> {
+                _uiState.update { it.copy(batchChangePreviewItems = emptyList()) }
+            }
+
             is CacheIntent.SetExportUseReplace -> {
                 cacheConfig.exportUseReplace = intent.enabled
                 syncExportConfig()
@@ -422,6 +516,259 @@ class CacheViewModel(
         }
     }
 
+    private fun changeBookSource(
+        oldBookUrl: String,
+        source: BookSource,
+        book: Book,
+        chapters: List<BookChapter>,
+        options: ChangeSourceMigrationOptions,
+    ) {
+        execute {
+            val oldBook = bookDao.getBook(oldBookUrl) ?: return@execute null
+            changeBookSourceUseCase.changeTo(oldBook, book, chapters, options)
+        }.onSuccess { result ->
+            result ?: return@onSuccess
+            cacheChapters.remove(result.oldBookUrl)
+            cacheChapters[result.book.bookUrl] = hashSetOf()
+            emitBookChanged(result.book.bookUrl)
+            _effects.tryEmit(CacheEffect.ShowMessage("换源完成"))
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("换源失败\n${it.localizedMessage}"))
+        }
+    }
+
+    private fun batchChangeBookSource(
+        bookUrls: Set<String>,
+        sources: List<BookSource>,
+        options: ChangeSourceMigrationOptions,
+    ) {
+        if (bookUrls.isEmpty()) {
+            _uiState.update { it.copy(changeSourceError = "未选择书籍") }
+            return
+        }
+        if (sources.isEmpty()) {
+            _uiState.update { it.copy(changeSourceError = "未选择书源") }
+            return
+        }
+        execute {
+            val concurrency = OtherConfig.threadCount.coerceIn(1, 4)
+            _uiState.update {
+                it.copy(
+                    isChangingSource = true,
+                    changeSourceProgress = "0 / ${bookUrls.size}",
+                    changeSourceMessage = "开始查找:${bookUrls.size} 本,${sources.size} 个书源,并发 $concurrency",
+                    changeSourceError = null,
+                    batchChangeOptions = options,
+                    batchChangePreviewItems = emptyList()
+                )
+            }
+            val books = bookUrls.mapNotNull { bookDao.getBook(it) }
+            changeBookSourceUseCase.prepareBatchChange(
+                books = books,
+                sources = sources,
+                concurrency = concurrency,
+            ) { current, total, bookName ->
+                _uiState.update {
+                    it.copy(changeSourceProgress = "$current / $total  $bookName")
+                }
+            }
+        }.onSuccess { previewItems ->
+            _uiState.update {
+                it.copy(
+                    batchChangePreviewItems = previewItems,
+                    isChangingSource = false,
+                    changeSourceProgress = null
+                )
+            }
+            val matchedCount = previewItems.count { it.canMigrate }
+            val skippedCount = previewItems.count {
+                it.status == BatchChangeSourcePreviewStatus.Skipped
+            }
+            val notFoundCount = previewItems.size - matchedCount - skippedCount
+            _uiState.update {
+                it.copy(
+                    changeSourceMessage = "查找完成:可迁移 $matchedCount 本,未找到 $notFoundCount 本,跳过 $skippedCount 本",
+                    changeSourceError = null
+                )
+            }
+        }.onError {
+            val progress = uiState.value.changeSourceProgress.orEmpty()
+            _uiState.update { state ->
+                state.copy(
+                    changeSourceError = "批量换源查找失败${if (progress.isBlank()) "" else "\n进度:$progress"}\n${it.localizedMessage}"
+                )
+            }
+        }.onFinally {
+            _uiState.update {
+                it.copy(
+                    isChangingSource = false,
+                    changeSourceProgress = null
+                )
+            }
+        }
+    }
+
+    private fun migratePreviewItem(oldBookUrl: String) {
+        val item = uiState.value.batchChangePreviewItems.firstOrNull {
+            it.oldBook.bookUrl == oldBookUrl
+        } ?: return
+        val candidate = item.selectedCandidate ?: return
+        execute {
+            val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook
+            changeBookSourceUseCase.changeTo(
+                oldBook = oldBook,
+                newBook = candidate.book,
+                chapters = candidate.chapters,
+                options = uiState.value.batchChangeOptions,
+            )
+        }.onSuccess { result ->
+            cacheChapters.remove(result.oldBookUrl)
+            cacheChapters[result.book.bookUrl] = hashSetOf()
+            removePreviewItem(oldBookUrl)
+            emitBookChanged(result.book.bookUrl)
+            _effects.tryEmit(CacheEffect.ShowMessage("迁移完成"))
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("迁移失败\n${it.localizedMessage}"))
+        }
+    }
+
+    private fun skipPreviewItem(oldBookUrl: String) {
+        _uiState.update { state ->
+            state.copy(
+                batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
+                    if (item.oldBook.bookUrl == oldBookUrl) {
+                        item.copy(status = BatchChangeSourcePreviewStatus.Skipped)
+                    } else {
+                        item
+                    }
+                }
+            )
+        }
+    }
+
+    private fun selectPreviewCandidate(oldBookUrl: String, candidateIndex: Int) {
+        _uiState.update { state ->
+            state.copy(
+                batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
+                    if (item.oldBook.bookUrl == oldBookUrl) {
+                        item.copy(
+                            selectedCandidateIndex = candidateIndex.coerceIn(
+                                0,
+                                (item.candidates.size - 1).coerceAtLeast(0)
+                            ),
+                            status = BatchChangeSourcePreviewStatus.Matched
+                        )
+                    } else {
+                        item
+                    }
+                }
+            )
+        }
+    }
+
+    private fun updatePreviewItem(
+        oldBookUrl: String,
+        source: BookSource,
+        book: Book,
+        chapters: List<BookChapter>,
+    ) {
+        _uiState.update { state ->
+            state.copy(
+                batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
+                    if (item.oldBook.bookUrl == oldBookUrl) {
+                        item.copy(
+                            candidates = listOf(BatchChangeSourceCandidate(source, book, chapters)) +
+                                    item.candidates,
+                            selectedCandidateIndex = 0,
+                            status = BatchChangeSourcePreviewStatus.Matched
+                        )
+                    } else {
+                        item
+                    }
+                }
+            )
+        }
+    }
+
+    private fun addPreviewItemToShelf(oldBookUrl: String) {
+        val item = uiState.value.batchChangePreviewItems.firstOrNull {
+            it.oldBook.bookUrl == oldBookUrl
+        } ?: return
+        val candidate = item.selectedCandidate ?: return
+        execute {
+            candidate.book.removeType(BookType.notShelf)
+            if (candidate.book.order == 0) {
+                candidate.book.order = bookDao.minOrder - 1
+            }
+            bookDao.insert(candidate.book)
+            bookChapterDao.insert(*candidate.chapters.toTypedArray())
+            candidate.book
+        }.onSuccess {
+            _effects.tryEmit(CacheEffect.ShowMessage("已添加到书架"))
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("添加书籍失败\n${it.localizedMessage}"))
+        }
+    }
+
+    private fun openBookInfoPreview(book: Book, inBookshelf: Boolean) {
+        execute {
+            if (!inBookshelf) {
+                appDb.searchBookDao.insert(book.toSearchBook())
+            }
+            book
+        }.onSuccess {
+            _effects.tryEmit(CacheEffect.OpenBookInfo(it.bookUrl, it.name, it.author))
+        }
+    }
+
+    private fun removePreviewItem(oldBookUrl: String) {
+        _uiState.update {
+            it.copy(
+                batchChangePreviewItems = it.batchChangePreviewItems.filterNot { item ->
+                    item.oldBook.bookUrl == oldBookUrl
+                },
+                cacheVersion = it.cacheVersion + 1
+            )
+        }
+    }
+
+    private fun migrateAllPreviewItems() {
+        val items = uiState.value.batchChangePreviewItems.filter { it.canMigrate }
+        if (items.isEmpty()) return
+        execute {
+            _uiState.update {
+                it.copy(isChangingSource = true, changeSourceProgress = "0 / ${items.size}")
+            }
+            items.forEachIndexed { index, item ->
+                _uiState.update {
+                    it.copy(changeSourceProgress = "${index + 1} / ${items.size}  ${item.oldBook.name}")
+                }
+                val candidate = item.selectedCandidate ?: return@forEachIndexed
+                val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook
+                changeBookSourceUseCase.changeTo(
+                    oldBook = oldBook,
+                    newBook = candidate.book,
+                    chapters = candidate.chapters,
+                    options = uiState.value.batchChangeOptions,
+                )
+            }
+        }.onSuccess {
+            cacheChapters.clear()
+            _uiState.update { it.copy(batchChangePreviewItems = emptyList()) }
+            _effects.tryEmit(CacheEffect.ShowMessage("批量迁移完成"))
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("批量迁移失败\n${it.localizedMessage}"))
+        }.onFinally {
+            _uiState.update {
+                it.copy(
+                    isChangingSource = false,
+                    changeSourceProgress = null,
+                    cacheVersion = it.cacheVersion + 1
+                )
+            }
+        }
+    }
+
     private fun clearCacheForBook(book: Book) {
         execute {
             clearBookCacheUseCase.execute(book.bookUrl)

+ 100 - 0
app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt

@@ -0,0 +1,100 @@
+package io.legado.app.ui.book.changesource
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
+import io.legado.app.ui.widget.components.checkBox.CheckboxItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+
+@Composable
+fun ChangeSourceMigrationOptionsSheet(
+    show: Boolean,
+    title: String,
+    subtitle: String? = null,
+    onDismissRequest: () -> Unit,
+    onConfirm: (ChangeSourceMigrationOptions) -> Unit,
+) {
+    var migrateReadingProgress by rememberSaveable(show) { mutableStateOf(true) }
+    var migrateGroup by rememberSaveable(show) { mutableStateOf(true) }
+    var migrateCover by rememberSaveable(show) { mutableStateOf(true) }
+    var migrateCategory by rememberSaveable(show) { mutableStateOf(true) }
+    var migrateRemark by rememberSaveable(show) { mutableStateOf(true) }
+    var migrateReadConfig by rememberSaveable(show) { mutableStateOf(true) }
+    var deleteDownloadedChapters by rememberSaveable(show) { mutableStateOf(false) }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = title,
+    ) {
+        Column(
+            modifier = Modifier.fillMaxWidth(),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            subtitle?.takeIf { it.isNotBlank() }?.let {
+                AppText(
+                    text = it,
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    modifier = Modifier.padding(bottom = 8.dp)
+                )
+            }
+            CheckboxItem("阅读进度", checked = migrateReadingProgress) {
+                migrateReadingProgress = it
+            }
+            if (migrateReadingProgress) {
+                AppText(
+                    text = "若新源总章节比进度更少,阅读进度将调整至最后一章。",
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    modifier = Modifier.padding(horizontal = 12.dp)
+                )
+            }
+            CheckboxItem("分组和排序", checked = migrateGroup) { migrateGroup = it }
+            CheckboxItem("自定义封面", checked = migrateCover) { migrateCover = it }
+            CheckboxItem("分类与标签", checked = migrateCategory) { migrateCategory = it }
+            CheckboxItem("备注和自定义简介", checked = migrateRemark) { migrateRemark = it }
+            CheckboxItem("阅读设置", checked = migrateReadConfig) { migrateReadConfig = it }
+            CheckboxItem("删除已下载章节", checked = deleteDownloadedChapters) {
+                deleteDownloadedChapters = it
+            }
+            Spacer(modifier = Modifier.height(8.dp))
+            ConfirmDismissButtonsRow(
+                onDismiss = onDismissRequest,
+                onConfirm = {
+                    onConfirm(
+                        ChangeSourceMigrationOptions(
+                            migrateChapters = true,
+                            migrateReadingProgress = migrateReadingProgress,
+                            migrateGroup = migrateGroup,
+                            migrateCover = migrateCover,
+                            migrateCategory = migrateCategory,
+                            migrateRemark = migrateRemark,
+                            migrateReadConfig = migrateReadConfig,
+                            deleteDownloadedChapters = deleteDownloadedChapters,
+                        )
+                    )
+                },
+                dismissText = stringResource(R.string.cancel),
+                confirmText = stringResource(android.R.string.ok),
+            )
+            Spacer(modifier = Modifier.height(12.dp))
+        }
+    }
+}

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -5,6 +5,7 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 
 data class BookInfoUiState(
     val book: Book? = null,
@@ -85,6 +86,7 @@ sealed interface BookInfoIntent {
         val source: BookSource,
         val book: Book,
         val toc: List<BookChapter>,
+        val options: ChangeSourceMigrationOptions,
     ) : BookInfoIntent
     data class AddSourceAsNewBook(
         val book: Book,

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -274,8 +274,8 @@ private fun BookInfoScreenContent(
                 show = currentSheet == BookInfoSheet.SourcePicker,
                 oldBook = book,
                 onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
-                onReplace = { source, newBook, toc ->
-                    onIntent(BookInfoIntent.ReplaceWithSource(source, newBook, toc))
+                onReplace = { source, newBook, toc, options ->
+                    onIntent(BookInfoIntent.ReplaceWithSource(source, newBook, toc, options))
                 },
                 onAddAsNew = { newBook, toc ->
                     onIntent(BookInfoIntent.AddSourceAsNewBook(newBook, toc))

+ 34 - 5
app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt

@@ -54,8 +54,11 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.help.book.isSameNameAuthor
 import io.legado.app.ui.book.changecover.ChangeCoverViewModel
 import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
+import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
 import io.legado.app.ui.book.group.GroupEditSheet
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.source.manage.BookSourceActivity
@@ -236,7 +239,7 @@ fun ChangeSourceSheet(
     show: Boolean,
     oldBook: Book,
     onDismissRequest: () -> Unit,
-    onReplace: (BookSource, Book, List<BookChapter>) -> Unit,
+    onReplace: (BookSource, Book, List<BookChapter>, ChangeSourceMigrationOptions) -> Unit,
     onAddAsNew: (Book, List<BookChapter>) -> Unit,
     viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
 ) {
@@ -253,6 +256,7 @@ fun ChangeSourceSheet(
     val loadWordCount = viewModel.loadWordCount
     var actionBook by remember { mutableStateOf<SearchBook?>(null) }
     var mismatchBook by remember { mutableStateOf<SearchBook?>(null) }
+    var pendingMigration by remember { mutableStateOf<PendingSourceMigration?>(null) }
     var loadingAction by remember { mutableStateOf(false) }
     var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
     var showFilterMenu by rememberSaveable { mutableStateOf(false) }
@@ -311,7 +315,7 @@ fun ChangeSourceSheet(
                             }
                         )
                         RoundDropdownMenuItem(
-                            text = "字数对比",
+                            text = "显示更多信息",
                             isSelected = loadWordCount,
                             onClick = {
                                 viewModel.onLoadWordCountChange(!loadWordCount)
@@ -464,7 +468,7 @@ fun ChangeSourceSheet(
                                 viewModel.del(item)
                                 if (oldBook.bookUrl == item.bookUrl) {
                                     viewModel.autoChangeSource(oldBook.type) { book, toc, source ->
-                                        onReplace(source, book, toc)
+                                        pendingMigration = PendingSourceMigration(source, book, toc)
                                     }
                                 }
                                 onDismiss()
@@ -483,8 +487,7 @@ fun ChangeSourceSheet(
         viewModel.getToc(book, { toc, source ->
             loadingAction = false
             if (replace) {
-                onReplace(source, book, toc)
-                onDismissRequest()
+                pendingMigration = PendingSourceMigration(source, book, toc)
             } else {
                 onAddAsNew(book, toc)
                 context.toastOnUi(context.getString(R.string.book_added_to_shelf))
@@ -527,4 +530,30 @@ fun ChangeSourceSheet(
             }
         }
     )
+    val migration = pendingMigration
+    ChangeSourceMigrationOptionsSheet(
+        show = migration != null,
+        title = "换源选项",
+        subtitle = migration?.let {
+            val sameNameAuthor = oldBook.isSameNameAuthor(it.book)
+            if (sameNameAuthor && oldBook.origin != it.book.origin) {
+                "检测到书名、作者相同但书源不同,可选择本次要迁移的数据。"
+            } else {
+                "选择本次替换当前书籍时要迁移的数据。"
+            }
+        },
+        onDismissRequest = { pendingMigration = null },
+        onConfirm = { options ->
+            val pending = pendingMigration ?: return@ChangeSourceMigrationOptionsSheet
+            onReplace(pending.source, pending.book, pending.toc, options)
+            pendingMigration = null
+            onDismissRequest()
+        }
+    )
 }
+
+private data class PendingSourceMigration(
+    val source: BookSource,
+    val book: Book,
+    val toc: List<BookChapter>,
+)

+ 14 - 7
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -16,6 +16,8 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.domain.usecase.ChangeBookSourceUseCase
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
@@ -65,6 +67,7 @@ class BookInfoViewModel(
     application: Application,
     private val remoteBookRepository: RemoteBookRepository,
     private val readRecordRepository: ReadRecordRepository,
+    private val changeBookSourceUseCase: ChangeBookSourceUseCase,
     private val clearBookCacheUseCase: ClearBookCacheUseCase
 ) : BaseViewModel(application) {
 
@@ -171,7 +174,7 @@ class BookInfoViewModel(
 
             is BookInfoIntent.ReplaceWithSource -> {
                 dismissSheet()
-                changeTo(intent.source, intent.book, intent.toc)
+                changeTo(intent.source, intent.book, intent.toc, intent.options)
             }
 
             is BookInfoIntent.AddSourceAsNewBook -> {
@@ -605,16 +608,20 @@ class BookInfoViewModel(
                 }
         }
     }
-    fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
+    fun changeTo(
+        source: BookSource,
+        book: Book,
+        toc: List<BookChapter>,
+        options: ChangeSourceMigrationOptions,
+    ) {
         changeSourceCoroutine?.cancel()
         changeSourceCoroutine = execute {
+            val oldBook = currentBook ?: return@execute book
             bookSource = source
-            currentBook?.migrateTo(book, toc)
             if (inBookshelf) {
-                book.removeType(BookType.updateError)
-                currentBook?.delete()
-                appDb.bookDao.insert(book)
-                appDb.bookChapterDao.insert(*toc.toTypedArray())
+                changeBookSourceUseCase.changeTo(oldBook, book, toc, options)
+            } else {
+                changeBookSourceUseCase.applyMigration(oldBook, book, toc, options)
             }
             book
         }.onSuccess {