Jelajahi Sumber

fix: 修复换源并增加书架冲突提示

HapeLee 4 bulan lalu
induk
melakukan
b7b60650c8

+ 87 - 0
app/src/androidTest/java/io/legado/app/data/dao/BookDaoTest.kt

@@ -0,0 +1,87 @@
+package io.legado.app.data.dao
+
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.legado.app.constant.BookType
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BookDaoTest {
+
+    private lateinit var database: AppDatabase
+
+    @Before
+    fun setUp() {
+        database = Room.inMemoryDatabaseBuilder(
+            ApplicationProvider.getApplicationContext(),
+            AppDatabase::class.java,
+        ).allowMainThreadQueries().build()
+    }
+
+    @After
+    fun tearDown() {
+        database.close()
+    }
+
+    @Test
+    fun replaceAllRemovesBooksUsingPreviousSourceUrls() {
+        val oldSourceBook = Book(
+            bookUrl = "https://old.example/book",
+            name = "Book",
+            author = "Author",
+            origin = "https://old.example",
+        )
+        val deviceOnlyBook = Book(
+            bookUrl = "https://device.example/book",
+            name = "Device book",
+            author = "Author",
+            origin = "https://device.example",
+        )
+        val restoredBook = Book(
+            bookUrl = "https://new.example/book",
+            name = "Book",
+            author = "Author",
+            origin = "https://new.example",
+        )
+        database.bookDao.insert(oldSourceBook, deviceOnlyBook)
+
+        database.bookDao.replaceAll(listOf(restoredBook))
+
+        assertEquals(listOf(restoredBook), database.bookDao.all)
+    }
+
+    @Test
+    fun getShelfBookConflictReturnsMostRecentlyReadShelfBook() {
+        val olderBook = Book(
+            bookUrl = "https://older.example/book",
+            name = "Book",
+            author = "Author",
+            durChapterTime = 100,
+        )
+        val newerBook = Book(
+            bookUrl = "https://newer.example/book",
+            name = "Book",
+            author = "Author",
+            durChapterTime = 200,
+        )
+        val previewBook = Book(
+            bookUrl = "https://preview.example/book",
+            name = "Book",
+            author = "Author",
+            durChapterTime = 300,
+            type = BookType.notShelf,
+        )
+        database.bookDao.insert(olderBook, newerBook, previewBook)
+
+        val conflict = database.bookDao.getShelfBookConflict("Book", "Author")
+
+        assertEquals(newerBook, conflict)
+    }
+}

+ 22 - 0
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -703,6 +703,17 @@ interface BookDao {
     @Query("SELECT * FROM books WHERE name = :name and author = :author")
     fun getBook(name: String, author: String): Book?
 
+    @Query(
+        """
+        SELECT * FROM books
+        WHERE name = :name AND author = :author
+            AND type & ${BookType.notShelf} = 0
+        ORDER BY durChapterTime DESC
+        LIMIT 1
+        """
+    )
+    fun getShelfBookConflict(name: String, author: String): Book?
+
     @Query("""select distinct bs.* from books, book_sources bs 
         where origin == bookSourceUrl and origin not like '${BookType.localTag}%' 
         and origin not like '${BookType.webDavTag}%'""")
@@ -762,12 +773,23 @@ interface BookDao {
     @Delete
     fun delete(vararg book: Book)
 
+    @Query("DELETE FROM books")
+    fun deleteAll()
+
     @Transaction
     fun replace(oldBook: Book, newBook: Book) {
         delete(oldBook)
         insert(newBook)
     }
 
+    @Transaction
+    fun replaceAll(books: List<Book>) {
+        deleteAll()
+        if (books.isNotEmpty()) {
+            insert(*books.toTypedArray())
+        }
+    }
+
     @Query("update books set durChapterPos = :pos where bookUrl = :bookUrl")
     fun upProgress(bookUrl: String, pos: Int)
 

+ 2 - 1
app/src/main/java/io/legado/app/di/appModule.kt

@@ -342,7 +342,8 @@ val appModule = module {
             readAloudSettingsRepository = get(),
             localPreferencesRepository = get(),
             highlightRuleRepository = get(),
-            uploadRepository = get()
+            uploadRepository = get(),
+            changeBookSourceUseCase = get(),
         )
     }
     viewModelOf(::ChangeCoverViewModel)

+ 15 - 5
app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt

@@ -1,7 +1,9 @@
 package io.legado.app.domain.usecase
 
+import androidx.room.withTransaction
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
+import io.legado.app.data.AppDatabase
 import io.legado.app.data.dao.BookChapterDao
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.entities.Book
@@ -66,6 +68,7 @@ enum class BatchChangeSourcePreviewStatus {
 }
 
 class ChangeBookSourceUseCase(
+    private val database: AppDatabase,
     private val bookDao: BookDao,
     private val bookChapterDao: BookChapterDao,
 ) {
@@ -81,7 +84,7 @@ class ChangeBookSourceUseCase(
         return newBook
     }
 
-    fun changeTo(
+    suspend fun changeTo(
         oldBook: Book,
         newBook: Book,
         chapters: List<BookChapter>,
@@ -94,11 +97,15 @@ class ChangeBookSourceUseCase(
         } else if (oldBook.bookUrl != newBook.bookUrl) {
             BookHelp.updateCacheFolder(oldBook, newBook)
         }
-        bookChapterDao.delByBook(oldBook.bookUrl)
-        bookDao.delete(oldBook)
-        bookDao.insert(newBook)
+        database.withTransaction {
+            bookChapterDao.delByBook(oldBook.bookUrl)
+            bookDao.delete(oldBook)
+            bookDao.insert(newBook)
+            if (options.migrateChapters) {
+                bookChapterDao.insert(*chapters.toTypedArray())
+            }
+        }
         if (options.migrateChapters) {
-            bookChapterDao.insert(*chapters.toTypedArray())
             ReadBook.onChapterListUpdated(newBook)
         }
         return ChangeBookSourceResult(oldBookUrl, newBook)
@@ -274,5 +281,8 @@ class ChangeBookSourceUseCase(
         if (options.migrateReadConfig) {
             newBook.readConfig = readConfig
         }
+        if (newBook.wordCount.isNullOrBlank()) {
+            newBook.wordCount = wordCount
+        }
     }
 }

+ 57 - 46
app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt

@@ -7,7 +7,6 @@ import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
-import io.legado.app.help.book.primaryStr
 import io.legado.app.help.book.releaseHtmlData
 import io.legado.app.ui.book.changesource.ChangeSourceConfig
 import io.legado.app.help.source.SourceHelp
@@ -27,8 +26,6 @@ import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.withTimeout
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicInteger
 
 sealed interface ChangeSourceSearchEvent {
     data class Started(val totalSources: Int) : ChangeSourceSearchEvent
@@ -39,7 +36,12 @@ sealed interface ChangeSourceSearchEvent {
         val sourceName: String,
     ) : ChangeSourceSearchEvent
 
-    data class Result(val searchBook: SearchBook) : ChangeSourceSearchEvent
+    data class Result(
+        val searchBook: SearchBook,
+        val book: Book,
+        val toc: List<BookChapter>?,
+    ) : ChangeSourceSearchEvent
+
     data class Finished(val isEmpty: Boolean) : ChangeSourceSearchEvent
 }
 
@@ -47,15 +49,6 @@ class ChangeSourceSearchUseCase(
     private val gateway: BookSearchGateway,
 ) {
     private val threadCount = OtherConfig.threadCount
-    private val contentProcessor by lazy {
-        // ContentProcessor needs the old book - will be set before search
-        null as ContentProcessor?
-    }
-
-    // Shared state for TOC cache
-    private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
-    private val bookMap = ConcurrentHashMap<String, Book>()
-    private val tocMapChapterCount = AtomicInteger(0)
 
     @OptIn(ExperimentalCoroutinesApi::class)
     fun search(
@@ -71,10 +64,6 @@ class ChangeSourceSearchUseCase(
             throw io.legado.app.exception.NoStackTraceException("启用书源为空")
         }
 
-        tocMap.clear()
-        bookMap.clear()
-        tocMapChapterCount.set(0)
-
         val totalSources = bookSourceParts.size
         emit(ChangeSourceSearchEvent.Started(totalSources))
 
@@ -102,9 +91,15 @@ class ChangeSourceSearchUseCase(
             }
             .collect { result ->
                 currentCoroutineContext().ensureActive()
-                result.books.forEach { searchBook ->
+                result.books.forEach { loadedBook ->
                     resultCount++
-                    emit(ChangeSourceSearchEvent.Result(searchBook))
+                    emit(
+                        ChangeSourceSearchEvent.Result(
+                            searchBook = loadedBook.searchBook,
+                            book = loadedBook.book,
+                            toc = loadedBook.toc,
+                        )
+                    )
                 }
                 processedSources++
                 emit(
@@ -122,7 +117,13 @@ class ChangeSourceSearchUseCase(
 
     private data class ChangeSourceResult(
         val source: BookSource,
-        val books: List<SearchBook>,
+        val books: List<LoadedSearchBook>,
+    )
+
+    private data class LoadedSearchBook(
+        val searchBook: SearchBook,
+        val book: Book,
+        val toc: List<BookChapter>? = null,
     )
 
     private suspend fun searchSource(
@@ -132,7 +133,7 @@ class ChangeSourceSearchUseCase(
         oldBook: Book,
         fromReadBookActivity: Boolean,
         contentProcessor: ContentProcessor,
-    ): List<SearchBook> {
+    ): List<LoadedSearchBook> {
         val checkAuthor = ChangeSourceConfig.checkAuthor
         val loadInfo = ChangeSourceConfig.loadInfo
         val loadToc = ChangeSourceConfig.loadToc
@@ -145,26 +146,32 @@ class ChangeSourceSearchUseCase(
             }
         )
 
-        val processedBooks = mutableListOf<SearchBook>()
+        val processedBooks = mutableListOf<LoadedSearchBook>()
         for (searchBook in resultBooks) {
             currentCoroutineContext().ensureActive()
             when {
                 loadInfo || loadToc || loadWordCount -> {
                     val book = searchBook.toBook()
-                    val wordCountSearchBook = loadBookInfo(
-                        source,
-                        book,
-                        loadToc,
-                        loadWordCount,
-                        oldBook,
-                        fromReadBookActivity,
-                        contentProcessor
+                    processedBooks.add(
+                        loadBookInfo(
+                            source,
+                            book,
+                            loadToc,
+                            loadWordCount,
+                            oldBook,
+                            fromReadBookActivity,
+                            contentProcessor
+                        )
                     )
-                    processedBooks.add(wordCountSearchBook ?: book.toSearchBook())
                 }
 
                 else -> {
-                    processedBooks.add(searchBook)
+                    processedBooks.add(
+                        LoadedSearchBook(
+                            searchBook = searchBook,
+                            book = searchBook.toBook(),
+                        )
+                    )
                 }
             }
         }
@@ -179,7 +186,7 @@ class ChangeSourceSearchUseCase(
         oldBook: Book,
         fromReadBookActivity: Boolean,
         contentProcessor: ContentProcessor,
-    ): SearchBook? {
+    ): LoadedSearchBook {
         if (book.tocUrl.isEmpty()) {
             WebBook.getBookInfoAwait(source, book)
         }
@@ -193,7 +200,10 @@ class ChangeSourceSearchUseCase(
                 contentProcessor
             )
         }
-        return null
+        return LoadedSearchBook(
+            searchBook = book.toSearchBook(),
+            book = book,
+        )
     }
 
     private suspend fun loadBookToc(
@@ -203,19 +213,14 @@ class ChangeSourceSearchUseCase(
         oldBook: Book,
         fromReadBookActivity: Boolean,
         contentProcessor: ContentProcessor,
-    ): SearchBook? {
+    ): LoadedSearchBook {
         val chapters = WebBook.getChapterListAwait(source, book).getOrThrow()
         for (chapter in chapters) {
             chapter.internString()
         }
-        if (tocMapChapterCount.get() < 30000) {
-            tocMapChapterCount.addAndGet(chapters.size)
-            tocMap[book.primaryStr()] = chapters
-        }
-        bookMap[book.primaryStr()] = book
         book.releaseHtmlData()
-        if (loadWordCount) {
-            return loadBookWordCount(
+        val searchBook = if (loadWordCount) {
+            loadBookWordCount(
                 source,
                 book,
                 chapters,
@@ -223,8 +228,14 @@ class ChangeSourceSearchUseCase(
                 fromReadBookActivity,
                 contentProcessor
             )
+        } else {
+            book.toSearchBook()
         }
-        return book.toSearchBook()
+        return LoadedSearchBook(
+            searchBook = searchBook,
+            book = book,
+            toc = chapters,
+        )
     }
 
     private suspend fun loadBookWordCount(
@@ -234,14 +245,14 @@ class ChangeSourceSearchUseCase(
         oldBook: Book,
         fromReadBookActivity: Boolean,
         contentProcessor: ContentProcessor,
-    ): SearchBook? {
-        if (chapters.isEmpty()) return null
+    ): SearchBook {
+        if (chapters.isEmpty()) return book.toSearchBook()
         val chapterIndex = if (fromReadBookActivity) {
             BookHelp.getDurChapter(oldBook, chapters)
         } else {
             chapters.lastIndex
         }
-        if (chapterIndex !in chapters.indices) return null
+        if (chapterIndex !in chapters.indices) return book.toSearchBook()
         val bookChapter = chapters[chapterIndex]
         var title = bookChapter.title.trim()
         if (title.length > 20) {

+ 3 - 15
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -116,23 +116,11 @@ object Restore : KoinComponent {
                 .forEach { book ->
                     book.coverUrl = LocalBook.getCoverPath(book)
                 }
-            val newBooks = arrayListOf<Book>()
             val ignoreLocalBook = BackupConfig.ignoreLocalBook
-            it.forEach { book ->
-                if (ignoreLocalBook && book.isLocal) {
-                    return@forEach
-                }
-                if (appDb.bookDao.has(book.bookUrl)) {
-                    try {
-                        appDb.bookDao.update(book)
-                    } catch (_: SQLiteConstraintException) {
-                        appDb.bookDao.insert(book)
-                    }
-                } else {
-                    newBooks.add(book)
-                }
+            val restoredBooks = it.filterNot { book ->
+                ignoreLocalBook && book.isLocal
             }
-            appDb.bookDao.insert(*newBooks.toTypedArray())
+            appDb.bookDao.replaceAll(restoredBooks)
         }
         fileToListT<Bookmark>(path, "bookmark.json")?.let {
             try {

+ 154 - 68
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt

@@ -2,6 +2,7 @@ package io.legado.app.ui.book.changesource
 
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+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
@@ -14,16 +15,19 @@ import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.help.book.isWebFile
 import io.legado.app.help.book.primaryStr
 import io.legado.app.ui.book.search.SearchScope
+import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 class ChangeBookSourceComposeViewModel(
     private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
     private val getChapterContentUseCase: GetChapterContentUseCase,
     private val searchRepository: SearchRepository,
+    private val bookDao: BookDao,
 ) : ViewModel() {
 
     // Public state for the sheet
@@ -61,7 +65,16 @@ class ChangeBookSourceComposeViewModel(
     var totalSourceCount: Int = 0
         private set
 
-    fun getBookFromMap(key: String): Book? = bookMap[key]?.toBook()
+    fun getBookFromMap(key: String): Book? = bookMap[key]
+
+    fun findShelfConflict(book: Book, onResult: (Book?) -> Unit) {
+        viewModelScope.launch {
+            val conflict = withContext(IO) {
+                bookDao.getShelfBookConflict(book.name, book.author)
+            }
+            onResult(conflict)
+        }
+    }
 
     // Options
     val checkAuthor: Boolean get() = ChangeSourceConfig.checkAuthor
@@ -77,13 +90,27 @@ class ChangeBookSourceComposeViewModel(
     private var bookAuthor: String = ""
     private var fromReadBookActivity: Boolean = false
     private var screenKey: String = ""
+    private var knownEnabledSourceUrls: Set<String>? = null
+    private var cachedTocChapterCount = 0
     private val searchResults = mutableListOf<SearchBook>()
-    private val bookMap = mutableMapOf<String, SearchBook>()
+    private val bookMap = mutableMapOf<String, Book>()
     private val tocMap = mutableMapOf<String, List<BookChapter>>()
 
     init {
         viewModelScope.launch {
-            searchRepository.enabledGroups.collect { /* handled by sheet */ }
+            searchRepository.enabledSources.collect { sources ->
+                val sourceUrls = sources.mapTo(linkedSetOf()) { it.bookSourceUrl }
+                val previousSourceUrls = knownEnabledSourceUrls
+                knownEnabledSourceUrls = sourceUrls
+                if (oldBook != null && previousSourceUrls != null) {
+                    val addedSources = sources.filter {
+                        it.bookSourceUrl !in previousSourceUrls
+                    }
+                    if (addedSources.isNotEmpty()) {
+                        startSearch(addedSources)
+                    }
+                }
+            }
         }
     }
 
@@ -94,12 +121,21 @@ class ChangeBookSourceComposeViewModel(
         this.fromReadBookActivity = fromReadBookActivity
         if (searchJob?.isActive != true) {
             viewModelScope.launch {
-                val dbBooks = getDbSearchBooks()
+                val dbBooks = withContext(IO) { getDbSearchBooks() }
                 if (dbBooks.isNotEmpty()) {
                     searchResults.clear()
                     searchResults.addAll(dbBooks)
-                    searchResults.forEach { bookMap[it.primaryStr()] = it }
+                    searchResults.forEach { bookMap[it.primaryStr()] = it.toBook() }
                     filterResults()
+                    val cachedOrigins = dbBooks.mapTo(hashSetOf()) { it.origin }
+                    val missingSources = withContext(IO) {
+                        SearchScope(ChangeSourceConfig.searchScope)
+                            .getBookSourceParts()
+                            .filter { it.bookSourceUrl !in cachedOrigins }
+                    }
+                    if (missingSources.isNotEmpty()) {
+                        startSearch(missingSources)
+                    }
                 } else {
                     startSearch()
                 }
@@ -129,64 +165,105 @@ class ChangeBookSourceComposeViewModel(
     }
 
     fun startSearch() {
+        startSearch(
+            scope = SearchScope(ChangeSourceConfig.searchScope),
+            replacedOrigins = null,
+        )
+    }
+
+    fun startSearch(origin: String) {
+        viewModelScope.launch {
+            val source = withContext(IO) {
+                io.legado.app.data.appDb.bookSourceDao.getBookSourcePart(origin)
+            } ?: return@launch
+            startSearch(listOf(source))
+        }
+    }
+
+    private fun startSearch(sources: List<BookSourcePart>) {
+        val scope = SearchScope("").apply { updateSources(sources) }
+        startSearch(
+            scope = scope,
+            replacedOrigins = sources.mapTo(hashSetOf()) { it.bookSourceUrl },
+        )
+    }
+
+    private fun startSearch(
+        scope: SearchScope,
+        replacedOrigins: Set<String>?,
+    ) {
         val book = oldBook ?: return
         stopSearch()
-        if (searchResults.isNotEmpty()) {
-            io.legado.app.data.appDb.searchBookDao.delete(*searchResults.toTypedArray())
+        val removedResults = if (replacedOrigins == null) {
+            searchResults.toList()
+        } else {
+            searchResults.filter { it.origin in replacedOrigins }
+        }
+        val removedKeys = removedResults.mapTo(hashSetOf()) { it.primaryStr() }
+        searchResults.removeAll(removedResults.toSet())
+        removedKeys.forEach {
+            bookMap.remove(it)
+            tocMap.remove(it)
+        }
+        if (replacedOrigins == null) {
+            cachedTocChapterCount = 0
         }
-        searchResults.clear()
-        bookMap.clear()
-        tocMap.clear()
-        val scope = SearchScope(ChangeSourceConfig.searchScope)
-        totalSourceCount = scope.getBookSourceParts().size
         _changeSourceProgress.value = 0 to ""
-        _searchDataFlow.value = emptyList()
+        filterResults()
 
         searchJob = viewModelScope.launch {
-            changeSourceSearchUseCase.search(
-                name = book.name,
-                author = book.author,
-                scope = scope,
-                oldBook = book,
-                fromReadBookActivity = fromReadBookActivity,
-            ).collect { event ->
-                when (event) {
-                    is ChangeSourceSearchEvent.Started -> {
-                        _isSearching.value = true
-                        totalSourceCount = event.totalSources
-                    }
-
-                    is ChangeSourceSearchEvent.Progress -> {
-                        totalSourceCount = event.totalSources
-                        _changeSourceProgress.value = event.processedSources to event.sourceName
-                    }
-
-                    is ChangeSourceSearchEvent.Result -> {
-                        searchResults.add(event.searchBook)
-                        bookMap[event.searchBook.primaryStr()] = event.searchBook
-                        // 持久化到 DB
-                        io.legado.app.data.appDb.searchBookDao.insert(event.searchBook)
-                        filterResults()
-                    }
-
-                    is ChangeSourceSearchEvent.Finished -> {
-                        _isSearching.value = false
+            if (removedResults.isNotEmpty()) {
+                withContext(IO) {
+                    io.legado.app.data.appDb.searchBookDao.delete(*removedResults.toTypedArray())
+                }
+            }
+            val pendingBooks = arrayListOf<SearchBook>()
+            try {
+                changeSourceSearchUseCase.search(
+                    name = book.name,
+                    author = book.author,
+                    scope = scope,
+                    oldBook = book,
+                    fromReadBookActivity = fromReadBookActivity,
+                ).collect { event ->
+                    when (event) {
+                        is ChangeSourceSearchEvent.Started -> {
+                            _isSearching.value = true
+                            totalSourceCount = event.totalSources
+                        }
+
+                        is ChangeSourceSearchEvent.Progress -> {
+                            totalSourceCount = event.totalSources
+                            _changeSourceProgress.value =
+                                event.processedSources to event.sourceName
+                            filterResults()
+                        }
+
+                        is ChangeSourceSearchEvent.Result -> {
+                            val key = event.searchBook.primaryStr()
+                            searchResults.add(event.searchBook)
+                            pendingBooks.add(event.searchBook)
+                            bookMap[key] = event.book
+                            event.toc?.let { toc ->
+                                if (cachedTocChapterCount < MAX_CACHED_CHAPTERS) {
+                                    cachedTocChapterCount += toc.size
+                                    tocMap[key] = toc
+                                }
+                            }
+                        }
+
+                        is ChangeSourceSearchEvent.Finished -> {
+                            filterResults()
+                            searchRepository.saveSearchBooks(pendingBooks)
+                        }
                     }
                 }
+            } finally {
+                _isSearching.value = false
             }
         }
     }
 
-    fun startSearch(origin: String) {
-        // Reload a single source
-        viewModelScope.launch {
-            changeSourceSearchUseCase.topSource(
-                searchResults.find { it.origin == origin } ?: return@launch
-            )
-            startSearch()
-        }
-    }
-
     fun stopSearch() {
         searchJob?.cancel()
         searchJob = null
@@ -206,13 +283,9 @@ class ChangeBookSourceComposeViewModel(
         }
     }
 
-    fun pause() {
-        // No-op for now
-    }
+    fun pause() = Unit
 
-    fun resume() {
-        // No-op for now
-    }
+    fun resume() = Unit
 
     private fun filterResults() {
         val filtered = if (screenKey.isEmpty()) {
@@ -247,18 +320,23 @@ class ChangeBookSourceComposeViewModel(
         onSuccess: (toc: List<BookChapter>, source: BookSource) -> Unit,
         onError: (e: Throwable) -> Unit,
     ) {
+        stopSearch()
         viewModelScope.launch {
             try {
                 val cachedToc = tocMap[book.primaryStr()]
                 if (cachedToc != null) {
-                    val source = io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
+                    val source = withContext(IO) {
+                        io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
+                    }
                     if (source != null) {
                         onSuccess(cachedToc, source)
                         return@launch
                     }
                 }
                 if (book.isWebFile) {
-                    val source = io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
+                    val source = withContext(IO) {
+                        io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
+                    }
                         ?: throw io.legado.app.exception.NoStackTraceException("书源不存在")
                     onSuccess(emptyList(), source)
                     return@launch
@@ -300,15 +378,19 @@ class ChangeBookSourceComposeViewModel(
     }
 
     fun refresh() {
-        searchResults.clear()
-        bookMap.clear()
-        val dbBooks = getDbSearchBooks()
-        if (dbBooks.isNotEmpty()) {
-            searchResults.addAll(dbBooks)
-            searchResults.forEach { bookMap[it.primaryStr()] = it }
-            filterResults()
-        } else {
-            startSearch()
+        viewModelScope.launch {
+            val dbBooks = withContext(IO) { getDbSearchBooks() }
+            searchResults.clear()
+            bookMap.clear()
+            tocMap.clear()
+            cachedTocChapterCount = 0
+            if (dbBooks.isNotEmpty()) {
+                searchResults.addAll(dbBooks)
+                searchResults.forEach { bookMap[it.primaryStr()] = it.toBook() }
+                filterResults()
+            } else {
+                startSearch()
+            }
         }
     }
 
@@ -429,4 +511,8 @@ class ChangeBookSourceComposeViewModel(
         )
         refresh()
     }
+
+    private companion object {
+        const val MAX_CACHED_CHAPTERS = 30_000
+    }
 }

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

@@ -140,6 +140,15 @@ sealed interface BookInfoIntent {
         val book: Book,
         val toc: List<BookChapter>,
     ) : BookInfoIntent
+
+    data class ReplaceConflictingBook(
+        val oldBook: Book,
+        val source: BookSource,
+        val book: Book,
+        val toc: List<BookChapter>,
+        val options: ChangeSourceMigrationOptions,
+    ) : BookInfoIntent
+
     data class SelectWebFile(
         val webFile: BookInfoWebFile,
         val openAfterImport: Boolean,

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

@@ -333,6 +333,17 @@ private fun BookInfoScreenContent(
                 onAddAsNew = { newBook, toc ->
                     onIntent(BookInfoIntent.AddSourceAsNewBook(newBook, toc))
                 },
+                onReplaceConflict = { oldBook, source, newBook, toc, options ->
+                    onIntent(
+                        BookInfoIntent.ReplaceConflictingBook(
+                            oldBook = oldBook,
+                            source = source,
+                            book = newBook,
+                            toc = toc,
+                            options = options,
+                        )
+                    )
+                },
             )
         }
         BookInfoSheet.ReadRecord -> BookReadRecordSheet(

+ 19 - 4
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -291,6 +291,17 @@ class BookInfoViewModel(
                 }
             }
 
+            is BookInfoIntent.ReplaceConflictingBook -> {
+                dismissSheet()
+                changeTo(
+                    source = intent.source,
+                    book = intent.book,
+                    toc = intent.toc,
+                    options = intent.options,
+                    replacedBook = intent.oldBook,
+                )
+            }
+
             is BookInfoIntent.SelectWebFile -> handleWebFileSelection(
                 intent.webFile,
                 intent.openAfterImport
@@ -787,19 +798,24 @@ class BookInfoViewModel(
         book: Book,
         toc: List<BookChapter>,
         options: ChangeSourceMigrationOptions,
+        replacedBook: Book? = null,
     ) {
+        val shouldPersist = replacedBook != null || inBookshelf
         changeSourceCoroutine?.cancel()
         changeSourceCoroutine = execute {
-            val oldBook = currentBook ?: return@execute book
-            bookSource = source
-            if (inBookshelf) {
+            val oldBook = replacedBook ?: currentBook ?: return@execute book
+            if (shouldPersist) {
                 changeBookSourceUseCase.changeTo(oldBook, book, toc, options)
             } else {
                 changeBookSourceUseCase.applyMigration(oldBook, book, toc, options)
             }
             book
         }.onSuccess {
+            bookSource = source
             currentBook = it
+            if (shouldPersist) {
+                inBookshelf = true
+            }
             currentChapterList = toc
             currentRelatedBooks = emptyList()
             currentGroupNames = null
@@ -807,7 +823,6 @@ class BookInfoViewModel(
             currentKindLabels = emptyList()
             syncUiState(isTocLoading = false)
             refreshMeta(it)
-        }.onFinally {
             postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
         }
     }

+ 14 - 9
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -37,6 +37,7 @@ import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.domain.model.ReadingProgress
+import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
 import io.legado.app.exception.NoStackTraceException
@@ -71,6 +72,7 @@ import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.provider.TextChapterLayout
+import io.legado.app.ui.book.changesource.ChangeSourceConfig
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
@@ -143,6 +145,7 @@ class ReadBookViewModel(
     private val localPreferencesRepository: LocalPreferencesRepository,
     private val highlightRuleRepository: HighlightRuleRepository,
     private val uploadRepository: UploadRepository,
+    private val changeBookSourceUseCase: ChangeBookSourceUseCase,
 ) : BaseViewModel(application), ReadBook.CallBack {
 
     // --- MVI State ---
@@ -2249,11 +2252,11 @@ class ReadBookViewModel(
         changeSourceCoroutine = execute {
             ReadBook.upMsg(context.getString(R.string.loading))
             applyChangeSource(book, toc)
+        }.onSuccess {
+            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
         }.onError {
             AppLog.put("换源失败\n$it", it, true)
             ReadBook.upMsg(null)
-        }.onFinally {
-            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
         }
     }
 
@@ -2268,11 +2271,11 @@ class ReadBookViewModel(
             }
             val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
             applyChangeSource(book, toc)
+        }.onSuccess {
+            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
         }.onError {
             AppLog.put("换源失败\n$it", it, true)
             ReadBook.upMsg(null)
-        }.onFinally {
-            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
         }
     }
 
@@ -2280,11 +2283,13 @@ class ReadBookViewModel(
         if (toc.isEmpty()) {
             throw NoStackTraceException("换源目录为空")
         }
-        ReadBook.book?.migrateTo(book, toc)
-        book.removeType(BookType.updateError)
-        ReadBook.book?.delete()
-        appDb.bookDao.insert(book)
-        appDb.bookChapterDao.insert(*toc.toTypedArray())
+        val oldBook = ReadBook.book ?: throw NoStackTraceException("书籍不存在")
+        changeBookSourceUseCase.changeTo(
+            oldBook = oldBook,
+            newBook = book,
+            chapters = toc,
+            options = ChangeSourceConfig.getMigrationOptions(),
+        )
         ReadBook.resetData(book)
         ReadBook.upMsg(null)
         ReadBook.loadContent(resetPageOffset = true)

+ 107 - 2
app/src/main/java/io/legado/app/ui/widget/components/changeSource/ChangeSourceSheet.kt

@@ -21,6 +21,7 @@ import androidx.compose.material.icons.outlined.Settings
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -64,6 +65,14 @@ import io.legado.app.utils.startActivity
 import io.legado.app.utils.toastOnUi
 import org.koin.androidx.compose.koinViewModel
 
+@Stable
+private data class PendingShelfConflict(
+    val existingBook: Book,
+    val source: BookSource,
+    val newBook: Book,
+    val toc: List<BookChapter>,
+)
+
 @Composable
 fun ChangeSourceSheet(
     show: Boolean,
@@ -75,6 +84,13 @@ fun ChangeSourceSheet(
     onReplace: (BookSource, Book, List<BookChapter>, ChangeSourceMigrationOptions) -> Unit,
     onReplaceBook: ((Book) -> Unit)? = null,
     onAddAsNew: (Book, List<BookChapter>) -> Unit,
+    onReplaceConflict: ((
+        Book,
+        BookSource,
+        Book,
+        List<BookChapter>,
+        ChangeSourceMigrationOptions,
+    ) -> Unit)? = null,
     viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
 ) {
     val context = LocalContext.current
@@ -92,6 +108,7 @@ fun ChangeSourceSheet(
     val loadWordCount = viewModel.loadWordCount
     var actionBook by remember { mutableStateOf<SearchBook?>(null) }
     var mismatchBook by remember { mutableStateOf<SearchBook?>(null) }
+    var shelfConflict by remember { mutableStateOf<PendingShelfConflict?>(null) }
     var showMigrationOptions by remember { mutableStateOf(false) }
     var loadingAction by remember { mutableStateOf(false) }
     var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
@@ -144,17 +161,34 @@ fun ChangeSourceSheet(
         viewModel.getToc(
             book,
             onSuccess = { toc, source ->
-                loadingAction = false
                 if (replace) {
+                    loadingAction = false
                     onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions())
                     if (!dismissBeforeLoading) {
                         onDismissRequest()
                     }
+                } else if (onReplaceConflict != null) {
+                    viewModel.findShelfConflict(book) { existingBook ->
+                        loadingAction = false
+                        if (existingBook == null) {
+                            onAddAsNew(book, toc)
+                            context.toastOnUi(bookAddedToShelfText)
+                        } else {
+                            shelfConflict = PendingShelfConflict(
+                                existingBook = existingBook,
+                                source = source,
+                                newBook = book,
+                                toc = toc,
+                            )
+                        }
+                        actionBook = null
+                    }
                 } else {
+                    loadingAction = false
                     onAddAsNew(book, toc)
                     context.toastOnUi(bookAddedToShelfText)
+                    actionBook = null
                 }
-                actionBook = null
             },
             onError = {
                 loadingAction = false
@@ -407,6 +441,77 @@ fun ChangeSourceSheet(
             onConfirm = { performAction(it, true) }
         )
     }
+    AppAlertDialog(
+        data = shelfConflict,
+        onDismissRequest = { shelfConflict = null },
+        title = stringResource(R.string.bookshelf_book_conflict_title),
+        text = stringResource(R.string.bookshelf_book_conflict_message),
+        confirmText = stringResource(R.string.replace_current_book),
+        onConfirm = { conflict ->
+            shelfConflict = null
+            onReplaceConflict?.invoke(
+                conflict.existingBook,
+                conflict.source,
+                conflict.newBook,
+                conflict.toc,
+                ChangeSourceConfig.getMigrationOptions(),
+            )
+            onDismissRequest()
+        },
+        dismissText = stringResource(R.string.add_as_new_book),
+        onDismiss = {
+            shelfConflict?.let { conflict ->
+                onAddAsNew(conflict.newBook, conflict.toc)
+                context.toastOnUi(bookAddedToShelfText)
+            }
+            shelfConflict = null
+        },
+        content = { conflict ->
+            val existingSource = conflict.existingBook.originName.ifBlank {
+                conflict.existingBook.origin
+            }
+            Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+                AppText(
+                    text = "${stringResource(R.string.book_name)}:${conflict.existingBook.name}",
+                    style = LegadoTheme.typography.bodyMedium,
+                )
+                AppText(
+                    text = "${stringResource(R.string.author)}:${conflict.existingBook.author}",
+                    style = LegadoTheme.typography.bodyMedium,
+                )
+                AppText(
+                    text = stringResource(R.string.existing_book_source, existingSource),
+                    style = LegadoTheme.typography.bodyMedium,
+                )
+                AppText(
+                    text = stringResource(
+                        R.string.all_chapter_num,
+                        conflict.existingBook.totalChapterNum,
+                    ),
+                    style = LegadoTheme.typography.bodyMedium,
+                )
+                AppText(
+                    text = stringResource(
+                        R.string.latest_chapter_info,
+                        conflict.existingBook.latestChapterTitle
+                            ?.takeIf { it.isNotBlank() }
+                            ?: stringResource(R.string.no_last_chapter),
+                    ),
+                    style = LegadoTheme.typography.bodyMedium,
+                )
+                AppText(
+                    text = stringResource(
+                        R.string.new_book_source,
+                        conflict.source.bookSourceName.ifBlank {
+                            conflict.source.bookSourceUrl
+                        },
+                    ),
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = LegadoTheme.colorScheme.primary,
+                )
+            }
+        },
+    )
     AppAlertDialog(
         show = loadingAction,
         onDismissRequest = {},

+ 5 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1615,6 +1615,11 @@
     <string name="change_source_option_title">请选择操作</string>
     <string name="replace_current_book">替换本书</string>
     <string name="add_as_new_book">新增书籍</string>
+    <string name="bookshelf_book_conflict_title">书架中已有同名书籍</string>
+    <string name="bookshelf_book_conflict_message">检测到书名和作者相同的书籍。可替换现有书并保留阅读数据,或仍然新增一本。</string>
+    <string name="existing_book_source">现有书源:%1$s</string>
+    <string name="new_book_source">将添加书源:%1$s</string>
+    <string name="latest_chapter_info">最新章节:%1$s</string>
     <string name="quick_add_to_shelf">快速添加到书架</string>
     <string name="book_added_to_shelf">书籍已添加到书架</string>
     <string name="seed_photo_summary">使用图片生成主题颜色</string>

+ 5 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1935,6 +1935,11 @@
     <string name="release_to_refresh">釋放刷新</string>
     <string name="reload">重新載入</string>
     <string name="replace_current_book">替換此書</string>
+    <string name="bookshelf_book_conflict_title">書架中已有同名書籍</string>
+    <string name="bookshelf_book_conflict_message">偵測到書名和作者相同的書籍。可替換現有書並保留閱讀資料,或仍然新增一本。</string>
+    <string name="existing_book_source">現有書源:%1$s</string>
+    <string name="new_book_source">將加入書源:%1$s</string>
+    <string name="latest_chapter_info">最新章節:%1$s</string>
     <string name="replace_purify_badge">計數替換與淨化規則</string>
     <string name="replace_rule_title_setting">配置替換規則</string>
     <string name="replace_with">替換為</string>

+ 5 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1756,6 +1756,11 @@
     <string name="change_source_option_title">選擇操作</string>
     <string name="replace_current_book">取代此書</string>
     <string name="add_as_new_book">新增為新書</string>
+    <string name="bookshelf_book_conflict_title">書架中已有同名書籍</string>
+    <string name="bookshelf_book_conflict_message">偵測到書名和作者相同的書籍。可取代現有書並保留閱讀資料,或仍然新增一本。</string>
+    <string name="existing_book_source">現有書源:%1$s</string>
+    <string name="new_book_source">將加入書源:%1$s</string>
+    <string name="latest_chapter_info">最新章節:%1$s</string>
     <string name="quick_add_to_shelf">快速放入書架</string>
     <string name="book_added_to_shelf">已放入書架</string>
     <string name="seed_photo_summary">從圖片生成主題色彩</string>

+ 5 - 0
app/src/main/res/values/strings.xml

@@ -1650,6 +1650,11 @@
     <string name="change_source_option_title">Choose an Action</string>
     <string name="replace_current_book">Replace This Book</string>
     <string name="add_as_new_book">Add as New Book</string>
+    <string name="bookshelf_book_conflict_title">Book Already on Bookshelf</string>
+    <string name="bookshelf_book_conflict_message">A book with the same title and author already exists. Replace it while keeping its reading data, or add another copy.</string>
+    <string name="existing_book_source">Existing source: %1$s</string>
+    <string name="new_book_source">New source: %1$s</string>
+    <string name="latest_chapter_info">Latest chapter: %1$s</string>
     <string name="quick_add_to_shelf">Quick Add to Bookshelf</string>
     <string name="book_added_to_shelf">Book added to bookshelf</string>
     <string name="seed_photo_summary">Generate theme colors from an image</string>

+ 35 - 0
app/src/test/java/io/legado/app/domain/usecase/ChangeSourceSearchEventTest.kt

@@ -0,0 +1,35 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class ChangeSourceSearchEventTest {
+
+    @Test
+    fun `result keeps parsed book and toc for source replacement`() {
+        val book = Book(
+            bookUrl = "https://example.com/book",
+            name = "Book",
+            author = "Author",
+            origin = "https://example.com",
+        )
+        val toc = listOf(
+            BookChapter(
+                bookUrl = book.bookUrl,
+                url = "https://example.com/chapter/1",
+                title = "Chapter 1",
+            )
+        )
+
+        val event = ChangeSourceSearchEvent.Result(
+            searchBook = book.toSearchBook(),
+            book = book,
+            toc = toc,
+        )
+
+        assertSame(book, event.book)
+        assertSame(toc, event.toc)
+    }
+}