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

This commit is contained in:
HapeLee
2026-04-26 15:13:50 +08:00
parent a3674ac5d9
commit 2989fe0405
9 changed files with 1367 additions and 14 deletions
@@ -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()
@@ -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
}
}
}
@@ -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,
@@ -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)
@@ -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))
}
}
}
@@ -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,
@@ -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))
@@ -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>,
)
@@ -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 {