From 2989fe0405a55015a43f27d36dc826782ab8d436 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:44:25 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=9B=B4=E7=89=9B?= =?UTF-8?q?=E9=80=BC=E7=9A=84=E6=8D=A2=E6=BA=90=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/io/legado/app/di/appModule.kt | 3 + .../domain/usecase/ChangeBookSourceUseCase.kt | 295 +++++++++ .../legado/app/ui/book/cache/CacheScreen.kt | 570 ++++++++++++++++++ .../app/ui/book/cache/CacheViewModel.kt | 347 +++++++++++ .../ChangeSourceMigrationOptionsSheet.kt | 100 +++ .../app/ui/book/info/BookInfoContract.kt | 2 + .../legado/app/ui/book/info/BookInfoScreen.kt | 4 +- .../legado/app/ui/book/info/BookInfoSheets.kt | 39 +- .../app/ui/book/info/BookInfoViewModel.kt | 21 +- 9 files changed, 1367 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 1c9fa12f0..f5a489437 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/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() diff --git a/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt new file mode 100644 index 000000000..effc25fd1 --- /dev/null +++ b/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, +) + +data class BatchChangeBookSourceResult( + val changedCount: Int, + val failedCount: Int, + val skippedCount: Int, +) + +data class BatchChangeSourceCandidate( + val source: BookSource, + val book: Book, + val chapters: List, +) + +data class BatchChangeSourcePreviewItem( + val oldBook: Book, + val candidates: List = 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, + options: ChangeSourceMigrationOptions, + ): Book { + oldBook.applyMigrationTo(newBook, chapters, options) + newBook.removeType(BookType.updateError) + return newBook + } + + fun changeTo( + oldBook: Book, + newBook: Book, + chapters: List, + 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, + 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, + sources: List, + concurrency: Int, + onProgress: (current: Int, total: Int, bookName: String) -> Unit, + ): List { + 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() + 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, + 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>? { + 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, + 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 + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt index 69324beee..dc1fce079 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt +++ b/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>(emptyList()) } + var singleChangeSourceBook by remember { mutableStateOf(null) } + var manualSearchPreviewBook by remember { mutableStateOf(null) } + var otherSourcePreviewItem by remember { mutableStateOf(null) } var pendingMoveGroupBookUrl by remember { mutableStateOf(null) } var groupPickerCurrentGroupId by remember { mutableLongStateOf(0L) } var moreMenuBookUrl by remember { mutableStateOf(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 { + 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) -> Unit, +) { + val sources by appDb.bookSourceDao.flowEnabled() + .collectAsStateWithLifecycle(initialValue = emptyList()) + var searchKey by rememberSaveable(show) { mutableStateOf("") } + var selectedSources by remember(show) { mutableStateOf>(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, + 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, diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt index cad581fb4..c3ec40e17 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt +++ b/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 = emptyList(), val books: List = emptyList(), val isDownloadRunning: Boolean = false, + val isChangingSource: Boolean = false, + val changeSourceProgress: String? = null, + val changeSourceMessage: String? = null, + val changeSourceError: String? = null, + val batchChangePreviewItems: List = 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, val deleteOriginal: Boolean) : CacheIntent data class ClearCachesForBooks(val bookUrls: Set) : CacheIntent data class DownloadBooks(val bookUrls: Set, val downloadAllChapters: Boolean) : CacheIntent + data class ChangeBookSource( + val oldBookUrl: String, + val source: BookSource, + val book: Book, + val chapters: List, + val options: ChangeSourceMigrationOptions, + ) : CacheIntent + data class BatchChangeBookSource( + val bookUrls: Set, + val sources: List, + 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, + ) : 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, + 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, + sources: List, + 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, + ) { + _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) diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt new file mode 100644 index 000000000..edfba5a67 --- /dev/null +++ b/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)) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt index 5dd5c95df..6be5b43a5 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt +++ b/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, + val options: ChangeSourceMigrationOptions, ) : BookInfoIntent data class AddSourceAsNewBook( val book: Book, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index db9f1beda..1fad1b81a 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/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)) diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt index f2b561291..91278f28f 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt +++ b/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) -> Unit, + onReplace: (BookSource, Book, List, ChangeSourceMigrationOptions) -> Unit, onAddAsNew: (Book, List) -> Unit, viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"), ) { @@ -253,6 +256,7 @@ fun ChangeSourceSheet( val loadWordCount = viewModel.loadWordCount var actionBook by remember { mutableStateOf(null) } var mismatchBook by remember { mutableStateOf(null) } + var pendingMigration by remember { mutableStateOf(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, +) diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index 6210d7182..030e7d236 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/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) { + fun changeTo( + source: BookSource, + book: Book, + toc: List, + 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 {