[优化] 更牛逼的换源设置
This commit is contained in:
@@ -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)
|
||||
|
||||
+100
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user