Jelajahi Sumber

feat: 优化换源与新书冲突

HapeLee 1 bulan lalu
induk
melakukan
be4c339caf
45 mengubah file dengan 7176 tambahan dan 245 penghapusan
  1. 247 0
      .codebuddy/plans/书架加书查重与冲突处理_4ebfaa66.md
  2. 4801 0
      app/schemas/io.legado.app.data.AppDatabase/106.json
  3. 9 12
      app/src/androidTest/java/io/legado/app/data/dao/BookDaoTest.kt
  4. 4 2
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  5. 27 11
      app/src/main/java/io/legado/app/data/dao/BookDao.kt
  6. 28 2
      app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt
  7. 20 0
      app/src/main/java/io/legado/app/data/entities/ShelfBookSummary.kt
  8. 6 1
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordIdentity.kt
  9. 18 1
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt
  10. 3 2
      app/src/main/java/io/legado/app/data/repository/BookRepository.kt
  11. 172 13
      app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt
  12. 1 0
      app/src/main/java/io/legado/app/data/repository/manga/MangaReaderDataRepository.kt
  13. 4 0
      app/src/main/java/io/legado/app/di/appModule.kt
  14. 23 0
      app/src/main/java/io/legado/app/domain/model/BookMatchKey.kt
  15. 37 0
      app/src/main/java/io/legado/app/domain/model/BookshelfConflict.kt
  16. 16 1
      app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt
  17. 110 0
      app/src/main/java/io/legado/app/domain/usecase/BookMigration.kt
  18. 10 64
      app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt
  19. 70 0
      app/src/main/java/io/legado/app/domain/usecase/FindBookshelfConflictUseCase.kt
  20. 139 0
      app/src/main/java/io/legado/app/domain/usecase/ResolveBookshelfConflictUseCase.kt
  21. 6 5
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  22. 24 9
      app/src/main/java/io/legado/app/model/ReadBook.kt
  23. 16 5
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt
  24. 6 2
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt
  25. 126 0
      app/src/main/java/io/legado/app/ui/book/conflict/BookshelfConflictController.kt
  26. 16 0
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt
  27. 18 2
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  28. 63 7
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  29. 17 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  30. 14 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  31. 161 22
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  32. 5 1
      app/src/main/java/io/legado/app/ui/book/read/ReadRecordAliasDelegate.kt
  33. 16 0
      app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt
  34. 16 2
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  35. 61 5
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  36. 10 0
      app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt
  37. 4 1
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt
  38. 73 6
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt
  39. 60 69
      app/src/main/java/io/legado/app/ui/widget/components/changeSource/ChangeSourceSheet.kt
  40. 243 0
      app/src/main/java/io/legado/app/ui/widget/components/conflict/BookshelfConflictSheet.kt
  41. 12 0
      app/src/main/res/values-zh-rCN/strings.xml
  42. 12 0
      app/src/main/res/values/strings.xml
  43. 122 0
      app/src/test/java/io/legado/app/data/repository/ReadRecordRepositoryTest.kt
  44. 133 0
      app/src/test/java/io/legado/app/domain/usecase/FindBookshelfConflictUseCaseTest.kt
  45. 197 0
      app/src/test/java/io/legado/app/domain/usecase/ResolveBookshelfConflictUseCaseTest.kt

+ 247 - 0
.codebuddy/plans/书架加书查重与冲突处理_4ebfaa66.md

@@ -0,0 +1,247 @@
+---
+name: 书架加书查重与冲突处理
+overview: 为「加入书架」路径增加书名+作者查重:命中时在搜索/发现/首页推荐弹出统一的冲突 Sheet(展示已有作品封面/书名/作者/书源,可点击跳转书籍详情,提供「共存」「迁移」两个动作并复刻批量换源的可选数据项);同时抽出共享的冲突解决用例与 Sheet 组件供换源、批量换源复用。共存时为 ReadRecord/Detail/Session 增加书籍归属维度,保证同名作品各自计时、作品维度展示为总和,杜绝时长叠加翻倍。
+todos:
+  - id: read-record-ownership
+    content: 用 [skill:lsp-code-analysis] 与 [subagent:code-explorer] 梳理阅读会话读写路径,给 readRecordSession 增加 bookUrl 归属列并完成 106 迁移与写入链路改造
+    status: completed
+  - id: extract-shared-contract
+    content: 抽出共享契约:ChangeSourceMigrationOptions 文件化、BookshelfConflict 模型、FindBookshelfConflictUseCase、共享字段搬运扩展
+    status: completed
+  - id: conflict-use-case
+    content: 实现 ResolveBookshelfConflictUseCase 的共存与迁移两个方向,迁移时平移阅读会话并由会话重建汇总,杜绝时长翻倍
+    status: completed
+    dependencies:
+      - read-record-ownership
+      - extract-shared-contract
+  - id: read-time-guards
+    content: 为 ReadRecordAliasDelegate 增加多副本护栏,并补充姓名变化时旧阅读记录重挂处理
+    status: completed
+    dependencies:
+      - conflict-use-case
+  - id: conflict-sheet
+    content: 新建共享 BookshelfConflictSheet 与 BookshelfConflictViewModel,并替换 ChangeSourceSheet 中的旧 AlertDialog(外部签名不变)
+    status: completed
+    dependencies:
+      - extract-shared-contract
+  - id: wire-add-paths
+    content: 改造 AddToBookshelfUseCase 返回冲突结果并接入搜索、发现、首页三条加入书架路径
+    status: completed
+    dependencies:
+      - conflict-sheet
+  - id: verify-and-baseline
+    content: 补充查重与迁移时长单测,执行编译、单测、lint 与架构门禁验证并输出未验证风险
+    status: completed
+    dependencies:
+      - wire-add-paths
+      - read-time-guards
+---
+
+## 产品概述
+
+在「加入书架」链路引入重名/同作者书籍的冲突检测与可视化处理。当检测到书架中已有疑似同一作品时,弹出底部
+Sheet:上方展示新加入书籍信息,中部以卡片列表展示书架已有作品的封面、书名、作者名、书源名(点击可跳转到该书架作品),底部提供「共存」「迁移」两种处理方向,并支持在进入任一方向前勾选要携带的数据项。阅读记录支持按书籍副本隔离计时:共存时两本书各自累计,作品维度展示为两者之和;迁移时把旧书籍的阅读记录平移给新书籍,绝不重复累加。
+
+## 核心功能
+
+- 加入书架时的重名检测(覆盖搜索页、发现页、首页推荐三条路径)
+- 疑似重复判定:书名规范化后相同 + 作者规范化后相同;任一方作者为空时仅按书名判定为疑似
+- 冲突 Sheet:封面 + 书名 + 作者 + 书源名卡片列表,点击跳转书架已有作品
+- 「共存」:新书入架(置顶排序),按勾选项从已有作品复制数据到新书
+- 「迁移」:新书替换已有作品(等价于换源),按勾选项携带数据
+- 可选数据项复用批量换源的换源选项:阅读进度、分组和排序、自定义封面、分类与标签、备注和自定义简介、阅读设置(共存场景隐藏「删除已下载章节」)
+- 阅读时长防叠加:阅读会话归属到具体书籍副本(bookUrl),各自独立计时;迁移为平移/重建而非 sum
+  合并;打开书籍时的「未知作者自动合并」加护栏
+
+## 技术栈
+
+沿用现有工程:Kotlin + Jetpack Compose + Material 3(`LegadoTheme`)+ Room + Koin +
+StateFlow/SharedFlow + Navigation 3。新 UI 全部 Compose(不新增 XML/Fragment),复用项目现有组件
+`AppModalBottomSheet`、`SelectionItemCard`、`AppText`、`CheckboxItem`、`ConfirmDismissButtonsRow`、
+`MediumTonalButton`、封面加载组件,`AppText/Coil` 封面渲染沿用 `SelectionItemCard` 与 `CoverFileCache`
+既有路径。
+
+## 实施思路
+
+### 1. 现状与痛点(已核实)
+
+| 位置                                                               | 现状                                                               | 问题                                  |
+|------------------------------------------------------------------|------------------------------------------------------------------|-------------------------------------|
+| `domain/usecase/AddToBookshelfUseCase.kt`                        | 直接 `toBook()` + `insert`,无查重                                     | 同名同作者书会静默重复入架                       |
+| `ui/widget/components/changeSource/ChangeSourceSheet.kt:471-541` | 已有冲突检测与 `AppAlertDialog`(纯文本、两个按钮)                               | 无封面、不能跳转原书、不能选携带数据                  |
+| `domain/usecase/ChangeBookSourceUseCase.kt`                      | `ChangeSourceMigrationOptions` + `applyMigrationTo` + `changeTo` | 是「携带字段」的唯一实现,但被锁在类内私有扩展里,无法被共存/加书复用 |
+| `data/entities/readRecord/ReadRecord.kt`                         | `primaryKeys = [deviceId, bookName, bookAuthor]`                 | 同名同作者必然共用一行 → 时长叠加根因                |
+| `ReadRecordRepository.updateReadRecord:217`                      | `existing.readTime + durationDelta` 累加                           | 只有同一会话被重复写入或两行被 sum 合并时才会翻倍         |
+
+### 2. 关键决策与取舍
+
+**决策一:阅读时长以「会话(Session)」为权威账本,按书籍副本归属**
+
+- `readRecordSession` 的 PK 是自增 id,**加列不需重建主键**(只需 `ALTER TABLE ADD COLUMN` +
+  建索引),是三张表里唯一零破坏性改写点;仓库注释已明确「以阅读时段记录为权威重建汇总与每日明细」,与该设计天然一致。
+- 由此派生出两个口径:**单本时长 = 该书 `bookUrl` 的会话求和**;**作品时长(readRecord /
+  readRecordDetail)= 该书名下所有会话求和**。这正好同时满足「各自独立计时」和「作品维度展示为两者之和」,且不需要改
+  `readRecord` / `readRecordDetail` 的主键,避免波及 `Restore`、`BackupConfig`、`ReadRecordViewModel`
+  等大量既有调用。
+- 取舍:书名+作者维度的每日明细仍按作品聚合,不在 Scripture 上再拆副本列;后续若有「分副本日历」需求再增列为宜。
+
+**决策二:迁移时「平移 + 由会话重建」,禁用 sum 合并**
+
+- 已有 `mergeIndependentReadRecordsInto` 是 sum 语义,用于「合并两条互不重叠的历史记录」;把它用在迁移上会导致同一段历史被计两次。
+- 迁移改为:`updateSession(bookUrl: old → new)` 平移会话(幂等),再走 `updateReadRecordTotal`
+  (由会话重算 + 保留 legacy 时长)重建汇总/明细。
+- 若迁移后书名/作者发生变化,复用现有 `mergeIndependentReadRecordsInto` 对**(name, author) 键**
+  做重挂(此时两条键确实代表不同历史,非重叠),避免旧记录成为孤儿。
+
+**决策三:共享契约 + 共享 Sheet,外部签名保持不变**
+
+- `ChangeSourceMigrationOptions` 提升为迁移/共存共用的配置类型(移至独立文件,**包与类名不变**
+  ),三种场景共用一份 UI(`ChangeSourceMigrationOptionsSheet`)与一份选项解析逻辑。
+- `ChangeSourceSheet` 现有的 5 个调用方(`BookInfoScreen:474`、`BookshelfManageScreen:778/844`、
+  `ReadBookScreen:677`、`MangaReaderScreen:316`)签名一律不改,只把内部旧 AlertDialog 换成共享 Sheet。
+- `ChangeBookSourceUseCase` 内部私有扩展 `Book.applyMigrationTo` 提取为共享扩展,供共存/迁移/批量换源复用;
+  `changeTo` 保持对外行为不变(删旧插新 + `BookHelp.updateCacheFolder` +
+  `ReadBook.onChapterListUpdated`)。
+
+**决策四:冲突处理的 UI 状态宿主复用既有 Compose ViewModel 模式**
+
+- 参照 `ChangeBookSourceComposeViewModel`(同一 emitter 模式:私有 `MutableStateFlow` + 单一
+  `onIntent` + `MutableSharedFlow` 效果),新建独立的 `BookshelfConflictViewModel`,由共享 Sheet 通过
+  `koinViewModel(key = "conflict-${newBook.bookUrl}")` 持有。三个宿主 ViewModel 只需多转发一个
+  Effect/Intent,不复制状态机。
+
+### 3. 架构设计
+
+```mermaid
+flowchart TD
+    subgraph UI["UI 层(Compose)"]
+        A[SearchScreen] --> S[BookshelfConflictSheet]
+        B[ExploreShowScreen] --> S
+        C[HomepageScreen] --> S
+        D[ChangeSourceSheet 现有调用方保持不变] --> S
+        S --> VM[BookshelfConflictViewModel<br/>UiState / Intent / Effect]
+        S --> OS[ChangeSourceMigrationOptionsSheet<br/>复用换源选项]
+        VM --> U2[ResolveBookshelfConflictUseCase]
+    end
+    subgraph Domain["领域层"]
+        U1[FindBookshelfConflictUseCase<br/>归一化查重]
+        U2[ResolveBookshelfConflictUseCase<br/>coexist / migrate]
+        U3[AddToBookshelfUseCase<br/>返回 Added / Conflict]
+        MO[ChangeSourceMigrationOptions]
+        U1 --> Repo[BookRepository]
+        U2 --> CS[ChangeBookSourceUseCase.applyMigration/changeTo]
+        U2 --> RR[ReadRecordRepository]
+    end
+    subgraph Data["数据层"]
+        Repo --> BookDao
+        RR --> ReadRecordSession.bookUrl
+    end
+```
+
+### 4. 目录结构
+
+```text
+app/src/main/java/io/legado/app/
+├── data/
+│   ├── AppDatabase.kt                                   # [MODIFY] version 105 → 106,新增 AutoMigration(105→106)
+│   ├── DatabaseMigrations.kt                            # [MODIFY] 会话表加列/建索引;可复用 L429 的表重建写法
+│   ├── entities/readRecord/ReadRecordSession.kt         # [MODIFY] 新增 bookUrl 列 + owner 索引,并入 stableFingerprint 以外的查询身份
+│   ├── dao/ReadRecordDao.kt                             # [MODIFY] 新增按 bookUrl 查询/按所属书籍统计、平移会话的 DAO 方法
+│   ├── repository/ReadRecordRepository.kt               # [MODIFY] saveReadSession 带 bookUrl;新增 reassignSessions/rebuild;调整 getLatestReadRecords 等聚合口径
+│   ├── repository/BookRepository.kt                     # [MODIFY] 新增按书名查询(走索引),给查重用例提供候选集
+│   └── dao/BookDao.kt                                   # [MODIFY] 新增 name 索引与按名查询
+├── model/ReadBook.kt                                    # [MODIFY] L850/L927/L955 构造与保存会话时携带 currentBook.bookUrl
+├── data/repository/manga/MangaReaderDataRepository.kt   # [MODIFY] L200 会话写入携带 bookUrl
+├── domain/
+│   ├── model/BookshelfConflict.kt                       # [NEW] @Stable 冲突模型:ConflictBookSummary / BookshelfConflict
+│   ├── usecase/BookMigrationOptions.kt                  # [NEW] 从 ChangeBookSourceUseCase.kt 移出 ChangeSourceMigrationOptions,全限定名不变
+│   ├── usecase/FindBookshelfConflictUseCase.kt          # [NEW] 归一化查重(复用 ReadRecordIdentity),返回候选列表
+│   ├── usecase/ResolveBookshelfConflictUseCase.kt       # [NEW] coexist / migrate 两个方向 + 阅读记录平移
+│   ├── usecase/AddToBookshelfUseCase.kt                 # [MODIFY] 返回 sealed result(Added / Conflict)
+│   ├── usecase/ChangeBookSourceUseCase.kt               # [MODIFY] 私有 applyMigrationTo 提取为共享扩展;changeTo 内增加会话平移钩子(对外签名不变)
+│   └── repository/...                                   # [不改动]
+├── ui/
+│   ├── widget/components/conflict/
+│   │   ├── BookshelfConflictSheet.kt                    # [NEW] 共享冲突 Sheet:封面/书名/作者/书源卡片 + 共存/迁移 + 选项入口 + 跳转
+│   │   └── BookshelfConflictViewModel.kt                # [NEW] UDF/MVI 状态宿主(继承 ViewModel,单 onIntent,Effect 走 SharedFlow)
+│   ├── widget/components/changeSource/ChangeSourceSheet.kt           # [MODIFY] 旧 AppAlertDialog(L471-541) 替换为共享 Sheet
+│   ├── book/changesource/ChangeSourceMigrationOptionsSheet.kt        # [MODIFY] 可选是否展示「删除已下载章节」
+│   ├── book/search/{SearchViewModel,SearchScreen,SearchContract}.kt  # [MODIFY] Effect + 渲染 Sheet + 跳转书架作品
+│   ├── book/explore/{ExploreShowViewModel,ExploreShowScreen}.kt      # [MODIFY] 同上
+│   └── main/homepage/{HomepageViewModel,HomepageScreen}.kt           # [MODIFY] 同上
+├── ui/book/read/ReadRecordAliasDelegate.kt              # [MODIFY] 护栏:同 (name, author) 存在多本时才要求用户确认,不自动合并
+└── res/values/strings.xml                               # [MODIFY] 新增冲突 Sheet 文案
+```
+
+## 关键代码结构
+
+```
+// domain/model/BookshelfConflict.kt
+@Stable
+data class ConflictBookSummary(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val coverUrl: String?,
+    val customCoverUrl: String?,
+    val sourceName: String,      // originName 回退 origin
+    val totalChapterNum: Int,
+    val latestChapterTitle: String?,
+)
+
+@Stable
+data class BookshelfConflict(
+    val incoming: ConflictBookSummary,
+    val candidates: ImmutableList<ConflictBookSummary>,
+)
+
+// domain/usecase/AddToBookshelfUseCase.kt
+sealed interface AddToBookshelfResult {
+    data object Added : AddToBookshelfResult
+    data class Conflict(val conflict: BookshelfConflict) : AddToBookshelfResult
+}
+
+// domain/usecase/ResolveBookshelfConflictUseCase.kt
+class ResolveBookshelfConflictUseCase(...) {
+    /** 共存:按 options 把已有作品的字段复制到新书,新书以最小 order 入架;阅读时长不复制。 */
+    suspend fun coexist(existingBookUrl: String, newBook: Book, options: ChangeSourceMigrationOptions): Result
+
+    /** 迁移:等价于换源(删旧插新),并把旧书的阅读会话平移到新书后由会话重建汇总。 */
+    suspend fun migrate(existingBookUrl: String, newBook: Book, chapters: List<BookChapter>, options: ChangeSourceMigrationOptions): Result
+}
+```
+
+## 实施要点(防回归)
+
+- **性能**:查重走 `BookDao` 的 name 索引(先按书名拉候选,再用 `ReadRecordIdentity`
+  在内存中折叠空白后比对),避免全表扫描;多候选时按 `durChapterTime` 倒序。
+- **一致性**:共存/迁移/会话平移必须在 `AppDatabase.withTransaction` 内完成(`changeTo`
+  已有事务,会话平移需并入同一事务或紧邻其后并保证失败回滚)。
+- **文案与降级**:`ChangeSourceMigrationOptionsSheet` 新增 `showDeleteDownloaded: Boolean = true`
+  ,共存场景传 false,其余调用方默认行为不变。
+- **日志**:沿用 `AppLog.put`(参考 ChangeSourceSheet L208 的写法),不打印正文与完整 bson/JSON。
+- **兼容性**:`ChangeSourceMigrationOptions` 只搬家不改字段,旧偏好/导入导出格式不受影响;
+  `readRecordSession.bookUrl` 默认空串,历史会话视为「未归属」,按作品聚合口径参与统计(保证旧时长不丢)。
+- **边界**:看书 Tip `showAddToShelfAlert`(`OtherSettings.showAddToShelfAlert`
+  )保持现有语义不变,不在此次改动中重定义;本地导入、URL 批量导入(`AddBookUseCase`
+  )按用户确认范围本次不接入,仅保留其静默迁移行为作为对照。
+- **验证**:`.\gradlew.bat :app:compileAppDebugKotlin`;
+  `.\gradlew.bat testAppDebugUnitTest lintAppDebug verifyConfigArchitecture assembleAppDebug --continue --no-configuration-cache`
+  ;文本改动 `git diff --check`。需覆盖的场景:同名同作者共存后两书独立计时且作品维度为和、迁移后总时长不翻倍、迁移后书名变化时记录不孤立、展厅机组不少于
+  1 本时不自动合并未知作者记录。
+
+## Agent Extensions
+
+### Skill
+
+- **lsp-code-analysis**
+- Purpose:改造 `readRecordSession` 前,用调用层级/引用分析确认 `saveReadSession`、`updateReadRecord`、
+  `ReadRecordDao` 会话相关查询的全部读写路径,评估加列的爆炸半径。
+- Expected outcome:输出完整的读写清单(含 Restore / BackupConfig / 备份恢复果树),作为迁移影响面证据。
+
+### SubAgent
+
+- **code-explorer**
+- Purpose:确认 `AddToBookshelfUseCase` 三个调用 ViewModel 的现有 Effect/Intent 结构、
+  `SearchIntent.OpenBookshelfBook` 的导航实现,以及 `Book.applyMigrationTo` 提取为共享扩展后的所有潜在重复实现。
+- Expected outcome:给出接线点清单与可复用的既有导航/老状态发放机制,避免引入平行实现。

+ 4801 - 0
app/schemas/io.legado.app.data.AppDatabase/106.json

@@ -0,0 +1,4801 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 106,
+    "identityHash": "32d43c5895ab5ed65af6758355df1dda",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, `listIntro` TEXT, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "listIntro",
+            "columnName": "listIntro",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          },
+          {
+            "name": "index_books_durChapterTime",
+            "unique": false,
+            "columnNames": [
+              "durChapterTime"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_groups",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, `isPrivate` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`groupId`))",
+        "fields": [
+          {
+            "fieldPath": "groupId",
+            "columnName": "groupId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cover",
+            "columnName": "cover",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enableRefresh",
+            "columnName": "enableRefresh",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "show",
+            "columnName": "show",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "bookSort",
+            "columnName": "bookSort",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "isPrivate",
+            "columnName": "isPrivate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "groupId"
+          ]
+        }
+      },
+      {
+        "tableName": "book_sources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookSourceUrl",
+            "columnName": "bookSourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceName",
+            "columnName": "bookSourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceGroup",
+            "columnName": "bookSourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceType",
+            "columnName": "bookSourceType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrlPattern",
+            "columnName": "bookUrlPattern",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "enabledExplore",
+            "columnName": "enabledExplore",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceComment",
+            "columnName": "bookSourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "weight",
+            "columnName": "weight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exploreUrl",
+            "columnName": "exploreUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "exploreScreen",
+            "columnName": "exploreScreen",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleExplore",
+            "columnName": "ruleExplore",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleSearch",
+            "columnName": "ruleSearch",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleBookInfo",
+            "columnName": "ruleBookInfo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleToc",
+            "columnName": "ruleToc",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleReview",
+            "columnName": "ruleReview",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "eventListener",
+            "columnName": "eventListener",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customButton",
+            "columnName": "customButton",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "homepageModules",
+            "columnName": "homepageModules",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookSourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_sources_bookSourceUrl",
+            "unique": false,
+            "columnNames": [
+              "bookSourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapters",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `tocLevel` INTEGER NOT NULL DEFAULT 0, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVolume",
+            "columnName": "isVolume",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tocLevel",
+            "columnName": "tocLevel",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "index",
+            "columnName": "index",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVip",
+            "columnName": "isVip",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isPay",
+            "columnName": "isPay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resourceUrl",
+            "columnName": "resourceUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "startFragmentId",
+            "columnName": "startFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "endFragmentId",
+            "columnName": "endFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "reviewImg",
+            "columnName": "reviewImg",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url",
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapters_bookUrl",
+            "unique": false,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_chapters_bookUrl_index",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "index"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookUrl"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "replace_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "replacement",
+            "columnName": "replacement",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "scope",
+            "columnName": "scope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "scopeTitle",
+            "columnName": "scopeTitle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "scopeContent",
+            "columnName": "scopeContent",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "excludeScope",
+            "columnName": "excludeScope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "isRegex",
+            "columnName": "isRegex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "timeoutMillisecond",
+            "columnName": "timeoutMillisecond",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3000"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_replace_rules_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ]
+      },
+      {
+        "tableName": "searchBooks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterWordCountText",
+            "columnName": "chapterWordCountText",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chapterWordCount",
+            "columnName": "chapterWordCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_searchBooks_bookUrl",
+            "unique": true,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_searchBooks_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "book_sources",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "origin"
+            ],
+            "referencedColumns": [
+              "bookSourceUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "search_keywords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))",
+        "fields": [
+          {
+            "fieldPath": "word",
+            "columnName": "word",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "usage",
+            "columnName": "usage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastUseTime",
+            "columnName": "lastUseTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "word"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_keywords_word",
+            "unique": true,
+            "columnNames": [
+              "word"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cookies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cookie",
+            "columnName": "cookie",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cookies_url",
+            "unique": true,
+            "columnNames": [
+              "url"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssSources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceName",
+            "columnName": "sourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceIcon",
+            "columnName": "sourceIcon",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceGroup",
+            "columnName": "sourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceComment",
+            "columnName": "sourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortUrl",
+            "columnName": "sortUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "singleUrl",
+            "columnName": "singleUrl",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "articleStyle",
+            "columnName": "articleStyle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "ruleArticles",
+            "columnName": "ruleArticles",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleNextPage",
+            "columnName": "ruleNextPage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleTitle",
+            "columnName": "ruleTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "rulePubDate",
+            "columnName": "rulePubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleDescription",
+            "columnName": "ruleDescription",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleImage",
+            "columnName": "ruleImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleLink",
+            "columnName": "ruleLink",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentWhitelist",
+            "columnName": "contentWhitelist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentBlacklist",
+            "columnName": "contentBlacklist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "shouldOverrideUrlLoading",
+            "columnName": "shouldOverrideUrlLoading",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "style",
+            "columnName": "style",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enableJs",
+            "columnName": "enableJs",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "loadWithBaseUrl",
+            "columnName": "loadWithBaseUrl",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "injectJs",
+            "columnName": "injectJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "preloadJs",
+            "columnName": "preloadJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startHtml",
+            "columnName": "startHtml",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startStyle",
+            "columnName": "startStyle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startJs",
+            "columnName": "startJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "showWebLog",
+            "columnName": "showWebLog",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "preload",
+            "columnName": "preload",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "cacheFirst",
+            "columnName": "cacheFirst",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "redirectPolicy",
+            "columnName": "redirectPolicy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'ASK_CROSS_ORIGIN'"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "sourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssSources_sourceUrl",
+            "unique": false,
+            "columnNames": [
+              "sourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "bookmarks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `bookUrl` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
+        "fields": [
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPos",
+            "columnName": "chapterPos",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookText",
+            "columnName": "bookText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "time"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_bookmarks_bookName_bookAuthor",
+            "unique": false,
+            "columnNames": [
+              "bookName",
+              "bookAuthor"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssArticles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "date",
+            "columnName": "date",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "readWords",
+            "columnName": "readWords",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "firstReadTime",
+            "columnName": "firstReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastReadTime",
+            "columnName": "lastReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor",
+            "date"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordSession",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `bookUrl` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chapterRule` TEXT NOT NULL, `volumeRule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterRule",
+            "columnName": "chapterRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "volumeRule",
+            "columnName": "volumeRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `speed` INTEGER, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "speed",
+            "columnName": "speed",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "search_content_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "query",
+            "columnName": "query",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_content_history_bookName_bookAuthor_query",
+            "unique": true,
+            "columnNames": [
+              "bookName",
+              "bookAuthor",
+              "query"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)"
+          }
+        ]
+      },
+      {
+        "tableName": "homepage_modules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "moduleKey",
+            "columnName": "moduleKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "args",
+            "columnName": "args",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "layoutConfig",
+            "columnName": "layoutConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customSetId",
+            "columnName": "customSetId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isUserCreated",
+            "columnName": "isUserCreated",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customTitle",
+            "columnName": "customTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customSetTitle",
+            "columnName": "customSetTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceJsonHash",
+            "columnName": "sourceJsonHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncedAt",
+            "columnName": "syncedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "homepage_custom_sets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "highlightRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, `fontWeight` INTEGER NOT NULL, `isItalic` INTEGER NOT NULL, `fontSizeOffset` INTEGER NOT NULL, `npLeft` REAL NOT NULL, `npRight` REAL NOT NULL, `npTop` REAL NOT NULL, `npBottom` REAL NOT NULL, `manualNineSlice` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sampleText",
+            "columnName": "sampleText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "targetScope",
+            "columnName": "targetScope",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "textColor",
+            "columnName": "textColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "bgColor",
+            "columnName": "bgColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineMode",
+            "columnName": "underlineMode",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineColor",
+            "columnName": "underlineColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineWidth",
+            "columnName": "underlineWidth",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineOffset",
+            "columnName": "underlineOffset",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineSvgPath",
+            "columnName": "underlineSvgPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImage",
+            "columnName": "bgImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImageFit",
+            "columnName": "bgImageFit",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bgImageScale",
+            "columnName": "bgImageScale",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "configName",
+            "columnName": "configName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontPath",
+            "columnName": "fontPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontWeight",
+            "columnName": "fontWeight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isItalic",
+            "columnName": "isItalic",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fontSizeOffset",
+            "columnName": "fontSizeOffset",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npLeft",
+            "columnName": "npLeft",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npRight",
+            "columnName": "npRight",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npTop",
+            "columnName": "npTop",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npBottom",
+            "columnName": "npBottom",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "manualNineSlice",
+            "columnName": "manualNineSlice",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_provider_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `modelsUrl` TEXT, `apiKey` TEXT NOT NULL, `authType` TEXT NOT NULL, `secretRef` TEXT, `headersJson` TEXT, `chatPath` TEXT, `responsesPath` TEXT, `messagesPath` TEXT, `modelsPath` TEXT, `customHeadersJson` TEXT, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "protocol",
+            "columnName": "protocol",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelsUrl",
+            "columnName": "modelsUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authType",
+            "columnName": "authType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "secretRef",
+            "columnName": "secretRef",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "headersJson",
+            "columnName": "headersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chatPath",
+            "columnName": "chatPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "responsesPath",
+            "columnName": "responsesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "messagesPath",
+            "columnName": "messagesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "modelsPath",
+            "columnName": "modelsPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customHeadersJson",
+            "columnName": "customHeadersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_model_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `providerId` TEXT NOT NULL, `displayName` TEXT NOT NULL, `modelId` TEXT NOT NULL, `contextWindow` INTEGER NOT NULL, `maxOutputTokens` INTEGER NOT NULL, `capabilities` TEXT NOT NULL, `defaultParamsJson` TEXT, `enabled` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "providerId",
+            "columnName": "providerId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelId",
+            "columnName": "modelId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contextWindow",
+            "columnName": "contextWindow",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "maxOutputTokens",
+            "columnName": "maxOutputTokens",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "capabilities",
+            "columnName": "capabilities",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultParamsJson",
+            "columnName": "defaultParamsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_model_profiles_providerId",
+            "unique": false,
+            "columnNames": [
+              "providerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_model_profiles_providerId` ON `${TABLE_NAME}` (`providerId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_task_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `promptTemplate` TEXT NOT NULL, `paramsJson` TEXT, `chunkPolicyJson` TEXT, `enabled` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptTemplate",
+            "columnName": "promptTemplate",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramsJson",
+            "columnName": "paramsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chunkPolicyJson",
+            "columnName": "chunkPolicyJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isDefault",
+            "columnName": "isDefault",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_task_presets_taskType",
+            "unique": false,
+            "columnNames": [
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_taskType` ON `${TABLE_NAME}` (`taskType`)"
+          },
+          {
+            "name": "index_ai_task_presets_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_modelProfileId` ON `${TABLE_NAME}` (`modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_artifacts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `contentHash` TEXT NOT NULL, `promptHash` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `status` INTEGER NOT NULL, `output` TEXT, `errorMessage` TEXT, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptHash",
+            "columnName": "promptHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "output",
+            "columnName": "output",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "errorMessage",
+            "columnName": "errorMessage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_artifacts_bookUrl_chapterIndex_taskType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_bookUrl_chapterIndex_taskType` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `taskType`)"
+          },
+          {
+            "name": "index_ai_artifacts_contentHash_promptHash_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "contentHash",
+              "promptHash",
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_contentHash_promptHash_modelProfileId` ON `${TABLE_NAME}` (`contentHash`, `promptHash`, `modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_chat_conversations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `reasoningLevel` TEXT NOT NULL, `modelProfileId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reasoningLevel",
+            "columnName": "reasoningLevel",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_chat_messages",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `role` TEXT NOT NULL, `partsJson` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `branchIndex` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `parentMessageId` TEXT, `thinkingDuration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "partsJson",
+            "columnName": "partsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "branchIndex",
+            "columnName": "branchIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isSelected",
+            "columnName": "isSelected",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentMessageId",
+            "columnName": "parentMessageId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "thinkingDuration",
+            "columnName": "thinkingDuration",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_chat_messages_conversationId_createdAt",
+            "unique": false,
+            "columnNames": [
+              "conversationId",
+              "createdAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_chat_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_memory",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "conversationId",
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_memory_conversationId",
+            "unique": false,
+            "columnNames": [
+              "conversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_memory_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "highlight_tag_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `pattern` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "tag_group_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "book_content_processes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `kind` TEXT NOT NULL, `stage` TEXT NOT NULL, `target` TEXT NOT NULL, `anchorJson` TEXT NOT NULL, `actionJson` TEXT NOT NULL, `styleJson` TEXT, `source` TEXT NOT NULL, `aiArtifactId` TEXT, `sourceContentHash` TEXT, `enabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `status` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "stage",
+            "columnName": "stage",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "target",
+            "columnName": "target",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actionJson",
+            "columnName": "actionJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aiArtifactId",
+            "columnName": "aiArtifactId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceContentHash",
+            "columnName": "sourceContentHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "enabled",
+              "sortOrder"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `enabled`, `sortOrder`)"
+          },
+          {
+            "name": "index_book_content_processes_bookUrl_kind",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "kind"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_kind` ON `${TABLE_NAME}` (`bookUrl`, `kind`)"
+          },
+          {
+            "name": "index_book_content_processes_aiArtifactId",
+            "unique": false,
+            "columnNames": [
+              "aiArtifactId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_aiArtifactId` ON `${TABLE_NAME}` (`aiArtifactId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_prompt_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `instruction` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `builtIn` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "instruction",
+            "columnName": "instruction",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "builtIn",
+            "columnName": "builtIn",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_prompt_presets_taskType_enabled_sortNumber",
+            "unique": false,
+            "columnNames": [
+              "taskType",
+              "enabled",
+              "sortNumber"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_prompt_presets_taskType_enabled_sortNumber` ON `${TABLE_NAME}` (`taskType`, `enabled`, `sortNumber`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `aliasesJson` TEXT NOT NULL, `avatarUri` TEXT, `tagsJson` TEXT NOT NULL, `role` TEXT NOT NULL, `voiceGender` TEXT NOT NULL DEFAULT 'unknown', `voiceAgeBand` TEXT NOT NULL DEFAULT 'unknown', `personality` TEXT NOT NULL, `summary` TEXT NOT NULL, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aliasesJson",
+            "columnName": "aliasesJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatarUri",
+            "columnName": "avatarUri",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tagsJson",
+            "columnName": "tagsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceGender",
+            "columnName": "voiceGender",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "voiceAgeBand",
+            "columnName": "voiceAgeBand",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "personality",
+            "columnName": "personality",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_profiles_bookUrl_name",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "name"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_name` ON `${TABLE_NAME}` (`bookUrl`, `name`)"
+          },
+          {
+            "name": "index_book_character_profiles_bookUrl_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_events",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `characterId` TEXT NOT NULL, `chapterIndex` INTEGER, `chapterTitle` TEXT NOT NULL, `eventTimeText` TEXT NOT NULL, `content` TEXT NOT NULL, `importance` INTEGER NOT NULL, `sourceTextHash` TEXT, `evidenceJson` TEXT NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "chapterTitle",
+            "columnName": "chapterTitle",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "eventTimeText",
+            "columnName": "eventTimeText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "importance",
+            "columnName": "importance",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceTextHash",
+            "columnName": "sourceTextHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_events_bookUrl_characterId_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "characterId",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_characterId_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `characterId`, `chapterIndex`)"
+          },
+          {
+            "name": "index_book_character_events_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_relations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `fromCharacterId` TEXT NOT NULL, `toCharacterId` TEXT NOT NULL, `relationType` TEXT NOT NULL, `summary` TEXT NOT NULL, `attitude` TEXT NOT NULL, `evidenceJson` TEXT NOT NULL, `chapterIndex` INTEGER, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fromCharacterId",
+            "columnName": "fromCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "toCharacterId",
+            "columnName": "toCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "relationType",
+            "columnName": "relationType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attitude",
+            "columnName": "attitude",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId_toCharacterId",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`, `toCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_toCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `toCharacterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_knowledge_entries",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `keywordsJson` TEXT NOT NULL, `content` TEXT NOT NULL, `scopeStartChapter` INTEGER, `scopeEndChapter` INTEGER, `priority` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `evidenceJson` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "keywordsJson",
+            "columnName": "keywordsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "scopeStartChapter",
+            "columnName": "scopeStartChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "scopeEndChapter",
+            "columnName": "scopeEndChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "priority",
+            "columnName": "priority",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_knowledge_entries_bookUrl_type_priority",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "type",
+              "priority"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_type_priority` ON `${TABLE_NAME}` (`bookUrl`, `type`, `priority`)"
+          },
+          {
+            "name": "index_book_knowledge_entries_bookUrl_title",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "title"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_title` ON `${TABLE_NAME}` (`bookUrl`, `title`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_outline_nodes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `parentId` TEXT, `nodeType` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT NOT NULL, `startChapterIndex` INTEGER, `endChapterIndex` INTEGER, `order` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentId",
+            "columnName": "parentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "nodeType",
+            "columnName": "nodeType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "startChapterIndex",
+            "columnName": "startChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "endChapterIndex",
+            "columnName": "endChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "nodeType",
+              "startChapterIndex",
+              "endChapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `nodeType`, `startChapterIndex`, `endChapterIndex`)"
+          },
+          {
+            "name": "index_book_outline_nodes_bookUrl_parentId_order",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "parentId",
+              "order"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_parentId_order` ON `${TABLE_NAME}` (`bookUrl`, `parentId`, `order`)"
+          }
+        ]
+      },
+      {
+        "tableName": "read_aloud_voices",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `engineType` TEXT NOT NULL, `engineId` TEXT NOT NULL DEFAULT '', `speakerId` TEXT NOT NULL DEFAULT '', `displayName` TEXT NOT NULL, `traitsJson` TEXT NOT NULL DEFAULT '[]', `emotionCatalogJson` TEXT NOT NULL DEFAULT '[]', `managedBy` TEXT NOT NULL DEFAULT 'user', `enabled` INTEGER NOT NULL DEFAULT 1, `available` INTEGER NOT NULL DEFAULT 1, `revision` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineType",
+            "columnName": "engineType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineId",
+            "columnName": "engineId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "speakerId",
+            "columnName": "speakerId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "traitsJson",
+            "columnName": "traitsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "emotionCatalogJson",
+            "columnName": "emotionCatalogJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "managedBy",
+            "columnName": "managedBy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "available",
+            "columnName": "available",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "revision",
+            "columnName": "revision",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_read_aloud_voices_engineType_engineId_speakerId",
+            "unique": true,
+            "columnNames": [
+              "engineType",
+              "engineId",
+              "speakerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_read_aloud_voices_engineType_engineId_speakerId` ON `${TABLE_NAME}` (`engineType`, `engineId`, `speakerId`)"
+          },
+          {
+            "name": "index_read_aloud_voices_enabled_displayName",
+            "unique": false,
+            "columnNames": [
+              "enabled",
+              "displayName"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_read_aloud_voices_enabled_displayName` ON `${TABLE_NAME}` (`enabled`, `displayName`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_voice_bindings",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `subjectType` TEXT NOT NULL, `subjectId` TEXT NOT NULL, `voiceId` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `source` TEXT NOT NULL DEFAULT 'user', `confidence` REAL NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`, `subjectType`, `subjectId`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectType",
+            "columnName": "subjectType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectId",
+            "columnName": "subjectId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceId",
+            "columnName": "voiceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "locked",
+            "columnName": "locked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl",
+            "subjectType",
+            "subjectId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_voice_bindings_bookUrl_subjectType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "subjectType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_bookUrl_subjectType` ON `${TABLE_NAME}` (`bookUrl`, `subjectType`)"
+          },
+          {
+            "name": "index_book_voice_bindings_voiceId",
+            "unique": false,
+            "columnNames": [
+              "voiceId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_voiceId` ON `${TABLE_NAME}` (`voiceId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_analysis",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` TEXT NOT NULL, `resolverVersion` TEXT NOT NULL, `characterRevision` TEXT NOT NULL DEFAULT '', `status` TEXT NOT NULL DEFAULT 'pending', `error` TEXT NOT NULL DEFAULT '', `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resolverVersion",
+            "columnName": "resolverVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterRevision",
+            "columnName": "characterRevision",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'pending'"
+          },
+          {
+            "fieldPath": "error",
+            "columnName": "error",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "contentHash",
+              "resolverVersion"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `contentHash`, `resolverVersion`)"
+          },
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_segments",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `analysisId` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `paragraphIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `chapterPosition` INTEGER NOT NULL, `text` TEXT NOT NULL, `roleType` TEXT NOT NULL, `characterId` TEXT, `characterName` TEXT NOT NULL DEFAULT '', `emotion` TEXT NOT NULL DEFAULT '', `confidence` REAL NOT NULL DEFAULT 0, `source` TEXT NOT NULL, `userLocked` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "analysisId",
+            "columnName": "analysisId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paragraphIndex",
+            "columnName": "paragraphIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPosition",
+            "columnName": "chapterPosition",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "text",
+            "columnName": "text",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "roleType",
+            "columnName": "roleType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "characterName",
+            "columnName": "characterName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "emotion",
+            "columnName": "emotion",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "userLocked",
+            "columnName": "userLocked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_segments_analysisId_paragraphIndex_start_end",
+            "unique": true,
+            "columnNames": [
+              "analysisId",
+              "paragraphIndex",
+              "start",
+              "end"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_segments_analysisId_paragraphIndex_start_end` ON `${TABLE_NAME}` (`analysisId`, `paragraphIndex`, `start`, `end`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "paragraphIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `paragraphIndex`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_characterId",
+            "unique": false,
+            "columnNames": [
+              "characterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_characterId` ON `${TABLE_NAME}` (`characterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cloud_tts_engines",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `provider` TEXT NOT NULL, `baseUrl` TEXT NOT NULL DEFAULT '', `apiKey` TEXT NOT NULL DEFAULT '', `secretKey` TEXT NOT NULL DEFAULT '', `region` TEXT NOT NULL DEFAULT '', `appId` TEXT NOT NULL DEFAULT '', `model` TEXT NOT NULL DEFAULT '', `optionsJson` TEXT NOT NULL DEFAULT '{}', `enabled` INTEGER NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "provider",
+            "columnName": "provider",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "secretKey",
+            "columnName": "secretKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "region",
+            "columnName": "region",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "appId",
+            "columnName": "appId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "model",
+            "columnName": "model",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "optionsJson",
+            "columnName": "optionsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'{}'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cloud_tts_engines_provider_enabled",
+            "unique": false,
+            "columnNames": [
+              "provider",
+              "enabled"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_cloud_tts_engines_provider_enabled` ON `${TABLE_NAME}` (`provider`, `enabled`)"
+          }
+        ]
+      },
+      {
+        "tableName": "exact_chapter_page_counts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` INTEGER NOT NULL, `layoutSignature` INTEGER NOT NULL, `engineVersion` INTEGER NOT NULL, `pageCount` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookId`, `chapterId`, `layoutSignature`), FOREIGN KEY(`bookId`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookId",
+            "columnName": "bookId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterId",
+            "columnName": "chapterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "layoutSignature",
+            "columnName": "layoutSignature",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineVersion",
+            "columnName": "engineVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pageCount",
+            "columnName": "pageCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookId",
+            "chapterId",
+            "layoutSignature"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_exact_chapter_page_counts_bookId_layoutSignature",
+            "unique": false,
+            "columnNames": [
+              "bookId",
+              "layoutSignature"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_exact_chapter_page_counts_bookId_layoutSignature` ON `${TABLE_NAME}` (`bookId`, `layoutSignature`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookId"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "book_marks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL DEFAULT '', `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER, `anchorJson` TEXT NOT NULL, `styleJson` TEXT, `note` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "note",
+            "columnName": "note",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_marks_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_marks_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      }
+    ],
+    "views": [
+      {
+        "viewName": "book_sources_part",
+        "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n    (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n    (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n    from book_sources"
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '32d43c5895ab5ed65af6758355df1dda')"
+    ]
+  }
+}

+ 9 - 12
app/src/androidTest/java/io/legado/app/data/dao/BookDaoTest.kt

@@ -58,19 +58,13 @@ class BookDaoTest {
     }
 
     @Test
-    fun getShelfBookConflictReturnsMostRecentlyReadShelfBook() {
-        val olderBook = Book(
-            bookUrl = "https://older.example/book",
+    fun getShelfBookSummariesSkipsNotShelfBooks() {
+        val shelfBook = Book(
+            bookUrl = "https://shelf.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",
@@ -78,10 +72,13 @@ class BookDaoTest {
             durChapterTime = 300,
             type = BookType.notShelf,
         )
-        database.bookDao.insert(olderBook, newerBook, previewBook)
+        database.bookDao.insert(shelfBook, previewBook)
 
-        val conflict = database.bookDao.getShelfBookConflict("Book", "Author")
+        val summaries = database.bookDao.getShelfBookSummaries()
 
-        assertEquals(newerBook, conflict)
+        assertEquals(
+            listOf("https://shelf.example/book"),
+            summaries.map { it.bookUrl },
+        )
     }
 }

+ 4 - 2
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -112,7 +112,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 105,
+    version = 106,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -192,7 +192,9 @@ val appDb by lazy {
         AutoMigration(from = 101, to = 102),
         // httpTTS 新增可空列 speed(源级语速)
         AutoMigration(from = 103, to = 104),
-        AutoMigration(from = 104, to = 105)
+        AutoMigration(from = 104, to = 105),
+        // readRecordSession 新增 bookUrl 归属列:同名作者作品共存时按书籍副本分别计时
+        AutoMigration(from = 105, to = 106)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {

+ 27 - 11
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -11,6 +11,7 @@ import io.legado.app.constant.BookType
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.ShelfBookSummary
 import io.legado.app.domain.model.CacheableBook
 import io.legado.app.help.book.isNotShelf
 import io.legado.app.ui.main.bookshelf.BookShelfItem
@@ -714,23 +715,38 @@ interface BookDao {
     )
     fun getCacheableBooks(bookUrls: Set<String>): List<CacheableBook>
 
+    /**
+     * 书架作品的轻量快照,供加入书架查重使用。
+     *
+     * 这是一次全表行扫描(未下架的作品),省掉的只是 `intro` / `variable` 等重字段,不是扫描行数。
+     * 规范化后的重名判定(全角半角、空白折叠)必须在 Kotlin 侧做,因此无法再用 `name` 等值条件
+     * 预筛;书架量级下这个代价可以接受,真要到需要预筛的规模,应先把规范化结果落成持久列。
+     */
+    @Query(
+        """
+        SELECT bookUrl, name, author, coverUrl, customCoverUrl, origin, originName,
+               totalChapterNum, latestChapterTitle, durChapterTime
+        FROM books
+        WHERE type & ${BookType.notShelf} = 0
+        """
+    )
+    fun getShelfBookSummaries(): List<ShelfBookSummary>
+
+    /**
+     * books 表的轻量失效信号(含未上架的书)。
+     *
+     * 只用于让依赖「书架里有几本同名作品」的 Flow 能在书架增删、改名时重新计算,
+     * 调用方不关心具体数值。
+     */
+    @Query("SELECT COUNT(*) FROM books")
+    fun flowBookCount(): Flow<Int>
+
     @Query("SELECT * FROM books WHERE bookUrl = :bookUrl")
     fun flowGetBook(bookUrl: String): Flow<Book?>
 
     @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}%'""")

+ 28 - 2
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -273,7 +273,7 @@ interface ReadRecordDao {
         DELETE FROM readRecordSession
         WHERE id NOT IN (
             SELECT MIN(id) FROM readRecordSession
-            GROUP BY bookName, bookAuthor, startTime, endTime, words
+            GROUP BY bookName, bookAuthor, bookUrl, startTime, endTime, words
         )
         """
     )
@@ -286,7 +286,7 @@ interface ReadRecordDao {
         WHERE id NOT IN (
             SELECT MIN(id) FROM readRecordSession
             WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor
-            GROUP BY bookName, bookAuthor, startTime, endTime, words
+            GROUP BY bookName, bookAuthor, bookUrl, startTime, endTime, words
         )
         AND deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor
         """
@@ -299,6 +299,7 @@ interface ReadRecordDao {
         WHERE deviceId = :deviceId
         AND bookName = :bookName
         AND bookAuthor = :bookAuthor
+        AND bookUrl = :bookUrl
         AND startTime = :startTime
         AND endTime = :endTime
         AND words = :words
@@ -309,11 +310,36 @@ interface ReadRecordDao {
         deviceId: String,
         bookName: String,
         bookAuthor: String,
+        bookUrl: String,
         startTime: Long,
         endTime: Long,
         words: Long
     ): ReadRecordSession?
 
+    /** 按书籍副本取全部阅读时段,用于副本归属迁移前的快照。 */
+    @Query("SELECT * FROM readRecordSession WHERE bookUrl = :bookUrl ORDER BY startTime ASC")
+    suspend fun getSessionsByBookUrl(bookUrl: String): List<ReadRecordSession>
+
+    /**
+     * 把某个书籍副本的阅读时段整体交给另一个副本。
+     *
+     * 迁移/换源后旧书被删除,会话必须改挂新副本,否则新副本会从零开始计时、
+     * 旧时长变成孤儿;若新书书名作者发生变化,会话的聚合键同步改写。
+     */
+    @Query(
+        """
+        UPDATE readRecordSession
+        SET bookUrl = :newBookUrl, bookName = :newBookName, bookAuthor = :newBookAuthor
+        WHERE bookUrl = :oldBookUrl
+        """
+    )
+    suspend fun reassignSessionOwnership(
+        oldBookUrl: String,
+        newBookUrl: String,
+        newBookName: String,
+        newBookAuthor: String,
+    )
+
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY startTime DESC")
     fun getSessionsByBookFlow(deviceId: String, bookName: String, bookAuthor: String): Flow<List<ReadRecordSession>>
 

+ 20 - 0
app/src/main/java/io/legado/app/data/entities/ShelfBookSummary.kt

@@ -0,0 +1,20 @@
+package io.legado.app.data.entities
+
+/**
+ * 书架作品的轻量投影。
+ *
+ * 加入书架查重只需要「是否重名」与「展示已有作品」两件事,不需要 books 表的正文/简介/
+ * 变量等重字段;换掉 `SELECT *` 可以避免每次点「加入书架」都把整本书的实体读进内存。
+ */
+data class ShelfBookSummary(
+    val bookUrl: String = "",
+    val name: String = "",
+    val author: String = "",
+    val coverUrl: String? = null,
+    val customCoverUrl: String? = null,
+    val origin: String = "",
+    val originName: String = "",
+    val totalChapterNum: Int = 0,
+    val latestChapterTitle: String? = null,
+    val durChapterTime: Long = 0L,
+)

+ 6 - 1
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordIdentity.kt

@@ -1,6 +1,11 @@
 package io.legado.app.data.entities.readRecord
 
-/** 阅读记录聚合与恢复共同使用的规范化身份工具。 */
+/**
+ * 规范化身份工具。
+ *
+ * 阅读记录的聚合与恢复、加入书架的重复检测都依赖它:同一本书在不同书源下可能带
+ * 非零宽空格、全半角或首尾空白差异,聚合/查重前必须先把键收敛到同一种写法。
+ */
 object ReadRecordIdentity {
     /** 规范化书名:折叠连续空白并去除首尾空白。 */
     fun bookName(value: String): String = normalize(value)

+ 18 - 1
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt

@@ -14,6 +14,19 @@ data class ReadRecordSession(
     @ColumnInfo(defaultValue = "")
     val bookAuthor: String = "",
 
+    /**
+     * 会话所属的书籍副本。
+     *
+     * 书架允许书名作者相同的作品共存,而 [readRecord] 汇总表以 (书名, 作者) 为主键,
+     * 因此同一份汇总记录下可能挂着多个副本。必须按副本记账,才能让「每个副本各自计时」,
+     * 避免出现后加入的副本直接继承、叠加已有作品时长的情况。
+     *
+     * 空串表示「未归属」,只会是本次迁移之前写入的历史会话;它们按作品维度参与统计,
+     * 保证旧时长不丢失,但不计入任何单个副本。
+     */
+    @ColumnInfo(defaultValue = "")
+    val bookUrl: String = "",
+
     // 一次阅读的开始/结束
     val startTime: Long = 0,
     val endTime: Long = 0,
@@ -23,6 +36,10 @@ data class ReadRecordSession(
 ) {
     /** 跨设备稳定身份,不依赖 Room 自动生成的数据库行 ID。 */
     val stableFingerprint: String
-        get() = listOf(deviceId, bookName, bookAuthor, startTime, endTime, words)
+        get() = listOf(deviceId, bookName, bookAuthor, bookUrl, startTime, endTime, words)
             .joinToString("\u0001")
+
+    /** 判断会话是否属于指定书籍副本;副本、书名、作者三者都必须一致。 */
+    fun matchesBook(url: String, name: String, author: String): Boolean =
+        bookUrl == url && bookName == name && bookAuthor == author
 }

+ 3 - 2
app/src/main/java/io/legado/app/data/repository/BookRepository.kt

@@ -6,6 +6,7 @@ import io.legado.app.data.dao.BookDao
 import io.legado.app.data.dao.GroupBookCount
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.ShelfBookSummary
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
@@ -56,9 +57,9 @@ class BookRepository(
         }
     }
 
-    suspend fun getShelfBookConflict(name: String, author: String): Book? {
+    suspend fun getShelfBookSummaries(): List<ShelfBookSummary> {
         return withContext(Dispatchers.IO) {
-            bookDao.getShelfBookConflict(name, author)
+            bookDao.getShelfBookSummaries()
         }
     }
 

+ 172 - 13
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -3,19 +3,25 @@ package io.legado.app.data.repository
 import androidx.room.withTransaction
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.dao.ReadRecordDao
+import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordAliasAction
 import io.legado.app.data.entities.readRecord.ReadRecordAliasDecision
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
-import io.legado.app.data.entities.readRecord.ReadRecordSession
-import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.data.entities.readRecord.ReadRecordIdentity
 import io.legado.app.data.entities.readRecord.ReadRecordRepairReport
+import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordTimeTotals
+import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.domain.model.BookMatchKey
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
 import kotlinx.datetime.Instant
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toLocalDateTime
@@ -100,9 +106,10 @@ class ReadRecordRepository(
 
     fun getAllSessions(): Flow<List<ReadRecordSession>> {
         // UI 展示的是跨设备合并后的时间线;去重键不包含自增 id,避免同步副本重复计时。
+        // 保留书籍副本维度,避免同名作品的不同副本的会话被误判为重复而丢弃。
         return dao.getAllSessions().map { sessions ->
             sessions.distinctBy {
-                listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words)
+                listOf(it.bookName, it.bookAuthor, it.bookUrl, it.startTime, it.endTime, it.words)
             }
         }
     }
@@ -112,29 +119,179 @@ class ReadRecordRepository(
         return dao.getAllSessions().map { sessions ->
             sessions.asSequence()
                 .filter { it.bookName == bookName && it.bookAuthor == bookAuthor }
-                .distinctBy { listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words) }
+                .distinctBy {
+                    listOf(
+                        it.bookName,
+                        it.bookAuthor,
+                        it.bookUrl,
+                        it.startTime,
+                        it.endTime,
+                        it.words
+                    )
+                }
                 .toList()
         }
     }
 
-    fun getBookTimelineDays(bookName: String, bookAuthor: String): Flow<List<ReadRecordTimelineDay>> {
-        return getBookSessions(bookName, bookAuthor).map { sessions ->
-            sessions.groupBy { it.startTime.toDateString() }
-                .toSortedMap(compareByDescending { it })
-                .map { (date, daySessions) ->
-                    ReadRecordTimelineDay(
-                        date = date,
-                        sessions = daySessions.sortedByDescending { it.startTime }
-                    )
+    /**
+     * 单个书籍副本的阅读时段。
+     *
+     * 书架允许同名作者作品共存,[getBookSessions] 返回的是作品维度(所有副本),而这里只取
+     * 挂在指定 bookUrl 下的会话。
+     *
+     * 未归属会话(`bookUrl` 为空)是升级前写入的历史,本身不携带副本信息,按下面的规则处理:
+     * - 作品在书架里只有这一个副本时,计入该副本。否则老用户升级后只要读一次,新会话就有归属,
+     *   历史会话会从详情页凭空消失;
+     * - 有多个副本时不计入任何副本,避免同一段历史在两个副本视图里各显示一次。
+     * 作品维度口径([getBookReadTime])始终包含未归属会话,不受这里影响。
+     */
+    fun getBookCopySessions(
+        bookUrl: String,
+        bookName: String,
+        bookAuthor: String,
+    ): Flow<List<ReadRecordSession>> {
+        if (bookUrl.isBlank()) return getBookSessions(bookName, bookAuthor)
+        // 同时监听 books 表:副本的增删或改名必须让这里的「是否还有别的副本」重新判定,
+        // 否则共存出第二个副本后,原书详情页会一直显示已不属于它的历史时长。
+        return combine(dao.getAllSessions(), database.bookDao.flowBookCount()) { sessions, _ ->
+            val scoped = sessions.filter { it.bookName == bookName && it.bookAuthor == bookAuthor }
+            val owned = scoped.filter { it.bookUrl == bookUrl }
+            val unowned = scoped.filter { it.bookUrl.isBlank() }
+            val includeUnowned = unowned.isNotEmpty() &&
+                    countShelfCopies(bookName, bookAuthor) <= 1
+            (if (includeUnowned) owned + unowned else owned)
+                .distinctBy {
+                    listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words)
                 }
         }
+            .flowOn(Dispatchers.IO)
     }
 
+    /**
+     * 书架中与 (bookName, bookAuthor) 视为同一作品的作品数。
+     *
+     * 走 [BookMatchKey] 的比对口径,与加入书架查重完全一致 —— 阅读记录的副本归属判断也依赖它,
+     * 两处结论不能互相矛盾。这里连同 [getBookCopySessions] 一起构成「这部作品到底有几个副本」
+     * 的唯一实现。
+     */
+    suspend fun countShelfCopies(bookName: String, bookAuthor: String): Int =
+        withContext(Dispatchers.IO) {
+            val name = BookMatchKey.of(bookName)
+            if (name.isBlank()) return@withContext 0
+            val author = BookMatchKey.of(bookAuthor)
+            database.bookDao.getShelfBookSummaries()
+                .count { candidate ->
+                    BookMatchKey.of(candidate.name) == name &&
+                            BookMatchKey.authorCompatible(author, BookMatchKey.of(candidate.author))
+                }
+        }
+
+    /** 单个书籍副本的累计阅读时长(毫秒),口径与 [getBookCopySessions] 一致。 */
+    fun getBookCopyReadTime(
+        bookUrl: String,
+        bookName: String,
+        bookAuthor: String,
+    ): Flow<Long> = getBookCopySessions(bookUrl, bookName, bookAuthor)
+        .map { sessions -> sessions.sumOf { it.endTime - it.startTime } }
+
+    /** 单个书籍副本按天聚合的阅读时间线。 */
+    fun getBookCopyTimelineDays(
+        bookUrl: String,
+        bookName: String,
+        bookAuthor: String,
+    ): Flow<List<ReadRecordTimelineDay>> = getBookCopySessions(bookUrl, bookName, bookAuthor)
+        .map { sessions -> sessions.toTimelineDays() }
+
+    /** 作品维度的累计时长,包含同名作者共存的所有副本,用于阅读统计总览。 */
     fun getBookReadTime(bookName: String, bookAuthor: String): Flow<Long> {
         // 统计所有设备的汇总时长,与跨设备时间线保持一致。
         return dao.getReadTimeFlow(bookName, bookAuthor).map { it ?: 0L }
     }
 
+    /**
+     * 书籍副本合并/迁移后,把原副本名下的阅读时段整体改挂到目标副本。
+     *
+     * 只为「同一段历史换了一个副本」服务:会话是平移与重算,绝不做 sum 合并,
+     * 否则同一段阅读会被计入两次。汇总与每日明细均以会话为权威重算,未被会话覆盖的
+     * 历史时长按原 key 保留。
+     *
+     * 调用方必须已经处于事务中(换源会先删旧书再插新书,阅读会话必须和它同生共死)。
+     */
+    internal suspend fun reassignBookReadSessions(oldBook: Book, newBook: Book) {
+        if (oldBook.bookUrl == newBook.bookUrl) return
+        val oldName = ReadRecordIdentity.bookName(oldBook.name)
+        val oldAuthor = ReadRecordIdentity.author(oldBook.author)
+        val newName = ReadRecordIdentity.bookName(newBook.name)
+        val newAuthor = ReadRecordIdentity.author(newBook.author)
+        val deviceIds = dao.getSessionsByBookUrl(oldBook.bookUrl)
+            .mapTo(linkedSetOf()) { it.deviceId }
+        // 书名作者不变时,会话改挂只换了副本归属,作品维度的聚合完全不变,无需重算明细。
+        val sameIdentity = oldName == newName && oldAuthor == newAuthor
+        // 明细里已计入旧会话的时间只能靠改挂前的快照还原,因此必须在 UPDATE 前取数。
+        val oldKeySessions =
+            deviceIds.associateWith { dao.getSessionsByBook(it, oldName, oldAuthor) }
+        val oldKeyDetails = deviceIds.associateWith { dao.getDetailsByBook(it, oldName, oldAuthor) }
+        val newKeySessions =
+            deviceIds.associateWith { dao.getSessionsByBook(it, newName, newAuthor) }
+        val newKeyDetails = deviceIds.associateWith { dao.getDetailsByBook(it, newName, newAuthor) }
+        // 汇总里的「历史时长」(没有会话支撑的部分)也属于对应 key,改挂前先算出来,
+        // 否则旧 key 会把自己已经不属于它的会话时间当成历史时长保留下来,导致同一段时长被算两次。
+        val oldKeyRecords = deviceIds.associateWith { dao.getReadRecord(it, oldName, oldAuthor) }
+        val newKeyRecords = deviceIds.associateWith { dao.getReadRecord(it, newName, newAuthor) }
+        dao.reassignSessionOwnership(
+            oldBookUrl = oldBook.bookUrl,
+            newBookUrl = newBook.bookUrl,
+            newBookName = newName,
+            newBookAuthor = newAuthor,
+        )
+        if (deviceIds.isEmpty() || sameIdentity) return
+        deviceIds.forEach { deviceId ->
+            dao.deleteDuplicateSessionsByBook(deviceId, newName, newAuthor)
+            rebuildDetailsAfterSessionRepair(
+                deviceId,
+                oldName,
+                oldAuthor,
+                oldKeySessions.getValue(deviceId),
+                oldKeyDetails.getValue(deviceId),
+            )
+            rebuildDetailsAfterSessionRepair(
+                deviceId,
+                newName,
+                newAuthor,
+                newKeySessions.getValue(deviceId),
+                newKeyDetails.getValue(deviceId),
+            )
+            updateReadRecordTotal(
+                deviceId,
+                oldName,
+                oldAuthor,
+                ReadRecordTimeTotals.legacy(
+                    oldKeyRecords.getValue(deviceId)?.readTime ?: 0L,
+                    oldKeySessions.getValue(deviceId).sumOf { it.endTime - it.startTime },
+                ),
+            )
+            updateReadRecordTotal(
+                deviceId,
+                newName,
+                newAuthor,
+                ReadRecordTimeTotals.legacy(
+                    newKeyRecords.getValue(deviceId)?.readTime ?: 0L,
+                    newKeySessions.getValue(deviceId).sumOf { it.endTime - it.startTime },
+                ),
+            )
+        }
+    }
+
+    private fun List<ReadRecordSession>.toTimelineDays(): List<ReadRecordTimelineDay> =
+        groupBy { it.startTime.toDateString() }
+            .toSortedMap(compareByDescending { it })
+            .map { (date, daySessions) ->
+                ReadRecordTimelineDay(
+                    date = date,
+                    sessions = daySessions.sortedByDescending { it.startTime }
+                )
+            }
+
     suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
         return if (targetRecord.deviceId.isBlank()) {
             dao.getMergeCandidatesAcrossDevices(targetRecord.bookName, targetRecord.bookAuthor)
@@ -189,6 +346,7 @@ class ReadRecordRepository(
                 normalizedSession.deviceId,
                 normalizedSession.bookName,
                 normalizedSession.bookAuthor,
+                normalizedSession.bookUrl,
                 normalizedSession.startTime,
                 normalizedSession.endTime,
                 normalizedSession.words
@@ -669,6 +827,7 @@ class ReadRecordRepository(
                         normalized.deviceId,
                         normalized.bookName,
                         normalized.bookAuthor,
+                        normalized.bookUrl,
                         normalized.startTime,
                         normalized.endTime,
                         normalized.words,

+ 1 - 0
app/src/main/java/io/legado/app/data/repository/manga/MangaReaderDataRepository.kt

@@ -201,6 +201,7 @@ class MangaReaderDataRepository(
                 ReadRecordSession(
                     bookName = book.name,
                     bookAuthor = book.author,
+                    bookUrl = book.bookUrl,
                     startTime = start,
                     endTime = end,
                 )

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

@@ -184,6 +184,7 @@ import io.legado.app.domain.usecase.DeleteBooksUseCase
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
 import io.legado.app.domain.usecase.ExportBookshelfUseCase
+import io.legado.app.domain.usecase.FindBookshelfConflictUseCase
 import io.legado.app.domain.usecase.GenerateBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
@@ -197,6 +198,7 @@ import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.RelocateMarkingTargetUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.ResolveBookshelfConflictUseCase
 import io.legado.app.domain.usecase.ResolveLocalSpeakersUseCase
 import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
 import io.legado.app.domain.usecase.SaveMarkingUseCase
@@ -443,6 +445,8 @@ val appModule = module {
     singleOf(::RefreshTocUseCase)
     singleOf(::AddBookUseCase)
     singleOf(::AddToBookshelfUseCase)
+    singleOf(::FindBookshelfConflictUseCase)
+    singleOf(::ResolveBookshelfConflictUseCase)
     singleOf(::ImportBookshelfUseCase)
     singleOf(::ExportBookshelfUseCase)
     factory { GetReadRecordOverviewUseCase() }

+ 23 - 0
app/src/main/java/io/legado/app/domain/model/BookMatchKey.kt

@@ -0,0 +1,23 @@
+package io.legado.app.domain.model
+
+import io.legado.app.data.entities.readRecord.ReadRecordIdentity
+import java.text.Normalizer
+
+/**
+ * 「是否算同一部作品」的比对键。
+ *
+ * 加入书架查重、阅读记录归属确认都依赖这个判断,必须共用同一份实现:如果一边按规范化比较、
+ * 另一边按原始字符串比较,就会出现「查重说有两本、归属确认却以为只有一本」这种自相矛盾的结论。
+ *
+ * 规则:先做 NFKC(不同书源经常给出全角/半角混排的书名),再沿用 [ReadRecordIdentity] 折叠空白。
+ * 这里只用于比对,不改阅读记录本身的聚合键。
+ */
+object BookMatchKey {
+
+    fun of(value: String): String =
+        ReadRecordIdentity.bookName(Normalizer.normalize(value, Normalizer.Form.NFKC))
+
+    /** 任一方作者为空时视为可能同一部作品(旧记录的作者常缺失)。 */
+    fun authorCompatible(left: String, right: String): Boolean =
+        left.isBlank() || right.isBlank() || left == right
+}

+ 37 - 0
app/src/main/java/io/legado/app/domain/model/BookshelfConflict.kt

@@ -0,0 +1,37 @@
+package io.legado.app.domain.model
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+
+/**
+ * 冲突 Sheet 里展示的一本书(待加入的书或书架已有作品)。
+ *
+ * 只携带展示与判定所需的最小字段,避免在 UI 状态里持有完整 Book 实体。
+ */
+@Stable
+data class ConflictBookSummary(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val coverUrl: String?,
+    val customCoverUrl: String?,
+    val origin: String,
+    val sourceName: String,
+    val totalChapterNum: Int = 0,
+    val latestChapterTitle: String? = null,
+) {
+    val displayCover: String?
+        get() = customCoverUrl?.takeIf { it.isNotBlank() } ?: coverUrl?.takeIf { it.isNotBlank() }
+}
+
+/**
+ * 加入书架时发现的疑似重复。
+ *
+ * @param incoming 正在加入、尚未入架的作品
+ * @param candidates 书架中疑似同一作品的已有作品,按最近阅读时间倒序
+ */
+@Stable
+data class BookshelfConflict(
+    val incoming: ConflictBookSummary,
+    val candidates: ImmutableList<ConflictBookSummary>,
+)

+ 16 - 1
app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt

@@ -3,18 +3,33 @@ package io.legado.app.domain.usecase
 import io.legado.app.constant.BookType
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.repository.BookRepository
+import io.legado.app.domain.model.BookshelfConflict
 import io.legado.app.help.book.removeType
 
+sealed interface AddToBookshelfResult {
+    /** 已直接加入书架。 */
+    data object Added : AddToBookshelfResult
+
+    /** 发现疑似同一作品,需要用户先选择共存还是迁移。 */
+    data class Conflict(val conflict: BookshelfConflict) : AddToBookshelfResult
+}
+
 class AddToBookshelfUseCase(
     private val bookRepository: BookRepository,
+    private val findBookshelfConflictUseCase: FindBookshelfConflictUseCase,
 ) {
 
-    suspend fun execute(book: SearchBook) {
+    suspend fun execute(book: SearchBook): AddToBookshelfResult {
         val b = book.toBook()
         b.removeType(BookType.notShelf)
+        val conflict = findBookshelfConflictUseCase.execute(b)
+        if (conflict != null) {
+            return AddToBookshelfResult.Conflict(conflict)
+        }
         if (b.order == 0) {
             b.order = bookRepository.getMinOrder() - 1
         }
         bookRepository.insert(b)
+        return AddToBookshelfResult.Added
     }
 }

+ 110 - 0
app/src/main/java/io/legado/app/domain/usecase/BookMigration.kt

@@ -0,0 +1,110 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.constant.BookType
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.book.removeType
+
+/**
+ * 把 this(源书籍)携带的数据搬到 [target]。
+ *
+ * 换源、批量换源、加入书架时的迁移共用同一份实现,保证「同一组开关在三个入口下含义一致」。
+ * 只搬运 [options] 勾选的部分,未勾选的字段由 [target] 自身保持(通常是新源刚解析出来的值)。
+ *
+ * @param chapters 目标书籍的目录,用于按章节重算阅读进度
+ * @param options 用户在换源/共存选项中勾选的数据项
+ */
+internal fun Book.migrateInto(
+    target: Book,
+    chapters: List<BookChapter>,
+    options: ChangeSourceMigrationOptions,
+    defaultReplaceEnabled: Boolean,
+    chineseConverterType: Int,
+) {
+    target.totalChapterNum = chapters.size
+    if (options.migrateReadingProgress && chapters.isNotEmpty()) {
+        target.durChapterIndex = BookHelp
+            .getDurChapter(durChapterIndex, durChapterTitle, chapters, totalChapterNum)
+            .coerceIn(0, chapters.lastIndex)
+        target.durChapterTitle = chapters[target.durChapterIndex].getDisplayTitle(
+            ContentProcessor.get(target.name, target.origin).getTitleReplaceRules(),
+            getUseReplaceRule(defaultReplaceEnabled),
+            chineseConverterType = chineseConverterType,
+        )
+        target.durChapterPos = durChapterPos
+        target.durChapterTime = durChapterTime
+    } else {
+        target.durChapterIndex = 0
+        target.durChapterTitle = chapters.firstOrNull()?.getDisplayTitle(
+            ContentProcessor.get(target.name, target.origin).getTitleReplaceRules(),
+            getUseReplaceRule(defaultReplaceEnabled),
+            chineseConverterType = chineseConverterType,
+        )
+        target.durChapterPos = 0
+        target.durChapterTime = System.currentTimeMillis()
+    }
+    if (options.migrateGroup) {
+        target.group = group
+        target.order = order
+    }
+    if (options.migrateCover) {
+        target.customCoverUrl = customCoverUrl
+    }
+    if (options.migrateCategory) {
+        target.customTag = customTag
+    }
+    if (options.migrateRemark) {
+        target.customIntro = customIntro
+        target.remark = remark
+    }
+    target.canUpdate = canUpdate
+    if (config.fixedType) {
+        target.type = type
+    }
+    if (options.migrateReadConfig) {
+        target.readConfig = readConfig
+    }
+    if (target.wordCount.isNullOrBlank()) {
+        target.wordCount = wordCount
+    }
+    target.removeType(BookType.updateError)
+}
+
+/**
+ * 把源书籍的数据搬到 [target],且不改变 [target] 自身元数据。
+ *
+ * 用于「共存」:两本书都留在书架里,因此搬运只能是从已有作品复制到新书,不能反过来改旧书,
+ * 也不重算阅读进度所依赖的章节数(新书还没有旧书的目录对应关系)。
+ */
+internal fun Book.copyMigratableFieldsTo(
+    target: Book,
+    options: ChangeSourceMigrationOptions,
+) {
+    if (options.migrateGroup) {
+        target.group = group
+    }
+    if (options.migrateCover) {
+        target.customCoverUrl = customCoverUrl
+    }
+    if (options.migrateCategory) {
+        target.customTag = customTag
+    }
+    if (options.migrateRemark) {
+        target.customIntro = customIntro
+        target.remark = remark
+    }
+    if (options.migrateReadConfig) {
+        target.readConfig = readConfig
+    }
+    if (options.migrateReadingProgress) {
+        target.durChapterIndex = durChapterIndex
+        target.durChapterTitle = durChapterTitle
+        target.durChapterPos = durChapterPos
+        target.durChapterTime = durChapterTime
+    }
+    if (target.wordCount.isNullOrBlank()) {
+        target.wordCount = wordCount
+    }
+}

+ 10 - 64
app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt

@@ -2,19 +2,17 @@ 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
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 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
@@ -75,6 +73,7 @@ class ChangeBookSourceUseCase(
     private val bookChapterDao: BookChapterDao,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val readSettingsGateway: ReadSettingsGateway,
+    private val readRecordRepository: ReadRecordRepository,
 ) {
 
     fun applyMigration(
@@ -83,14 +82,13 @@ class ChangeBookSourceUseCase(
         chapters: List<BookChapter>,
         options: ChangeSourceMigrationOptions,
     ): Book {
-        oldBook.applyMigrationTo(
-            newBook,
-            chapters,
-            options,
-            otherSettingsGateway.currentSettings.replaceEnableDefault,
-            readSettingsGateway.currentSettings.chineseConverterType,
+        oldBook.migrateInto(
+            target = newBook,
+            chapters = chapters,
+            options = options,
+            defaultReplaceEnabled = otherSettingsGateway.currentSettings.replaceEnableDefault,
+            chineseConverterType = readSettingsGateway.currentSettings.chineseConverterType,
         )
-        newBook.removeType(BookType.updateError)
         return newBook
     }
 
@@ -114,6 +112,8 @@ class ChangeBookSourceUseCase(
             if (options.migrateChapters) {
                 bookChapterDao.insert(*chapters.toTypedArray())
             }
+            // 旧书已从书架删除,它的阅读会话必须跟着改挂到新副本,否则新副本的时长会从零开始。
+            readRecordRepository.reassignBookReadSessions(oldBook, newBook)
         }
         if (options.migrateChapters) {
             ReadBook.onChapterListUpdated(newBook)
@@ -245,58 +245,4 @@ class ChangeBookSourceUseCase(
         return chapters
     }
 
-    private fun Book.applyMigrationTo(
-        newBook: Book,
-        chapters: List<BookChapter>,
-        options: ChangeSourceMigrationOptions,
-        defaultReplaceEnabled: Boolean,
-        chineseConverterType: Int,
-    ) {
-        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(defaultReplaceEnabled),
-                chineseConverterType = chineseConverterType,
-            )
-            newBook.durChapterPos = durChapterPos
-            newBook.durChapterTime = durChapterTime
-        } else {
-            newBook.durChapterIndex = 0
-            newBook.durChapterTitle = chapters.firstOrNull()?.getDisplayTitle(
-                ContentProcessor.get(newBook.name, newBook.origin).getTitleReplaceRules(),
-                getUseReplaceRule(defaultReplaceEnabled),
-                chineseConverterType = chineseConverterType,
-            )
-            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
-        }
-        if (newBook.wordCount.isNullOrBlank()) {
-            newBook.wordCount = wordCount
-        }
-    }
 }

+ 70 - 0
app/src/main/java/io/legado/app/domain/usecase/FindBookshelfConflictUseCase.kt

@@ -0,0 +1,70 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.ShelfBookSummary
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.domain.model.BookMatchKey
+import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.ConflictBookSummary
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * 加入书架前的重复检测。
+ *
+ * 判定规则:
+ * - 书名经 [ReadRecordIdentity.bookName] 规范化后相同;
+ * - 作者经 [ReadRecordIdentity.author] 规范化后相同;任一方作者为空时视为疑似,仅按书名判定;
+ * - 已下架的作品(带 notShelf 标记)不算冲突。
+ */
+class FindBookshelfConflictUseCase(
+    private val bookRepository: BookRepository,
+) {
+
+    suspend fun execute(incoming: Book): BookshelfConflict? = withContext(Dispatchers.IO) {
+        val name = bookKey(incoming.name)
+        val author = bookKey(incoming.author)
+        if (name.isBlank()) return@withContext null
+        val candidates = bookRepository.getShelfBookSummaries()
+            .filter { it.bookUrl != incoming.bookUrl }
+            .filter { bookKey(it.name) == name }
+            .filter { authorCompatible(author, bookKey(it.author)) }
+            .sortedByDescending { it.durChapterTime }
+            .map { it.toConflictSummary() }
+        if (candidates.isEmpty()) return@withContext null
+        BookshelfConflict(
+            incoming = incoming.toConflictSummary(),
+            candidates = candidates.toImmutableList(),
+        )
+    }
+
+    private fun bookKey(value: String): String = BookMatchKey.of(value)
+
+    private fun authorCompatible(incoming: String, existing: String): Boolean =
+        BookMatchKey.authorCompatible(incoming, existing)
+
+    private fun Book.toConflictSummary() = ConflictBookSummary(
+        bookUrl = bookUrl,
+        name = name,
+        author = author,
+        coverUrl = coverUrl,
+        customCoverUrl = customCoverUrl,
+        origin = origin,
+        sourceName = originName.ifBlank { origin },
+        totalChapterNum = totalChapterNum,
+        latestChapterTitle = latestChapterTitle,
+    )
+
+    private fun ShelfBookSummary.toConflictSummary() = ConflictBookSummary(
+        bookUrl = bookUrl,
+        name = name,
+        author = author,
+        coverUrl = coverUrl,
+        customCoverUrl = customCoverUrl,
+        origin = origin,
+        sourceName = originName.ifBlank { origin },
+        totalChapterNum = totalChapterNum,
+        latestChapterTitle = latestChapterTitle,
+    )
+}

+ 139 - 0
app/src/main/java/io/legado/app/domain/usecase/ResolveBookshelfConflictUseCase.kt

@@ -0,0 +1,139 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.help.book.removeType
+import io.legado.app.model.webBook.WebBook
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * 处理「加入书架时发现疑似同一作品」的两个方向。
+ *
+ * 与换源共用 [ChangeBookSourceUseCase],保证同一组携带选项在换源、批量换源、
+ * 加入书架冲突三个入口下含义一致。
+ *
+ * 阅读时长的处理是这个用例最容易被写错的地方:
+ * - 共存:两个副本都留在书架,阅读会话按各自的 bookUrl 记账,互不相干,
+ *   因此这里只搬运用户在选项里勾选的书籍字段,**不搬运任何阅读记录**;
+ * - 迁移:等价于换源,旧书被删除,它的阅读会话整体平移到新副本(见
+ *   [ChangeBookSourceUseCase.changeTo]),是平移而不是累加,不会产生双倍时长。
+ */
+/**
+ * 迁移所需的目录拿不到。
+ *
+ * 迁移是破坏性操作(删旧书 + 重建目录 + 可能重置进度),因此目录不可用时必须中止而不是降级,
+ * 由调用方向用户提示后让用户重试。
+ */
+class BookTocUnavailableException(
+    val bookName: String,
+    cause: Throwable? = null,
+) : IllegalStateException("获取目录失败:$bookName", cause)
+
+class ResolveBookshelfConflictUseCase(
+    private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository,
+    private val changeBookSourceUseCase: ChangeBookSourceUseCase,
+) {
+
+    /**
+     * 共存:新书入架,按 [options] 从已有作品复制勾选的数据。
+     *
+     * @param existingBookUrl 书架已有作品的 bookUrl
+     * @param newBook 待加入的书(尚未入库)
+     * @param chapters 调用方已加载的目录;为空时由本用例按书源拉取
+     */
+    suspend fun coexist(
+        existingBookUrl: String,
+        newBook: Book,
+        options: ChangeSourceMigrationOptions,
+        chapters: List<BookChapter>? = null,
+    ): Book = withContext(Dispatchers.IO) {
+        val existing = bookRepository.getBook(existingBookUrl)
+        // 共存既不删旧书、也不重置进度,目录只是「新书暂时没有章节」;拉不到就降级继续,
+        // 不要因为一次网络抖动挡住用户本来就很轻的共存选择。
+        val resolvedChapters = chapters ?: runCatching { loadChapters(newBook) }
+            .onFailure { AppLog.put("共存时获取目录失败,暂不写入目录", it, true) }
+            .getOrDefault(emptyList())
+        prepareNewBook(newBook)
+        if (existing == null) {
+            insertBook(newBook, resolvedChapters)
+            return@withContext newBook
+        }
+        existing.copyMigratableFieldsTo(newBook, options)
+        insertBook(newBook, resolvedChapters)
+        newBook
+    }
+
+    /**
+     * 迁移:用新书替换书架已有作品,语义等同于换源。
+     *
+     * @param chapters 调用方已加载的目录;为空时由本用例按书源拉取
+     * @return 替换后留在书架的那本书
+     */
+    suspend fun migrate(
+        existingBookUrl: String,
+        newBook: Book,
+        options: ChangeSourceMigrationOptions,
+        chapters: List<BookChapter>? = null,
+    ): Book = withContext(Dispatchers.IO) {
+        val existing = bookRepository.getBook(existingBookUrl)
+        val resolvedChapters = chapters ?: loadChapters(newBook)
+        // 没有目录就替换:changeTo 会删掉旧书、又不插入任何章节,且进度会被重置到第一章。
+        // 宁可中止让用户重试,也不能用一次网络抖动换掉整本书的进度。
+        if (resolvedChapters.isEmpty()) {
+            throw BookTocUnavailableException(newBook.name)
+        }
+        prepareNewBook(newBook)
+        if (existing == null) {
+            insertBook(newBook, resolvedChapters)
+            return@withContext newBook
+        }
+        changeBookSourceUseCase.changeTo(
+            oldBook = existing,
+            newBook = newBook,
+            chapters = resolvedChapters,
+            options = options,
+        )
+        newBook
+    }
+
+    private fun prepareNewBook(newBook: Book) {
+        newBook.removeType(BookType.notShelf)
+    }
+
+    /**
+     * 载入待加入书籍的目录。
+     *
+     * 目录是迁移的必要条件而不是可选条件:`migrateInto` 在目录为空时会按「未勾选迁移进度」
+     * 处理,把阅读进度重置到第一章;而迁移又会先删掉旧书。目录静默失败就等于用户丢了进度。
+     * 因此这里失败即抛异常,由调用方提示并中止本次迁移;本地书没有书源,返回空目录。
+     */
+    private suspend fun loadChapters(newBook: Book): List<BookChapter> {
+        val source = bookSourceRepository.getBookSource(newBook.origin) ?: return emptyList()
+        if (newBook.tocUrl.isBlank()) {
+            runCatching { WebBook.getBookInfoAwait(source, newBook) }
+                .onFailure { AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true) }
+        }
+        return WebBook.getChapterListAwait(source, newBook)
+            .onFailure {
+                AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
+                throw BookTocUnavailableException(newBook.name, it)
+            }
+            .getOrThrow()
+    }
+
+    private suspend fun insertBook(newBook: Book, chapters: List<BookChapter>) {
+        if (newBook.order == 0) {
+            newBook.order = bookRepository.getMinOrder() - 1
+        }
+        bookRepository.insert(newBook)
+        if (chapters.isNotEmpty()) {
+            bookRepository.insertChapters(*chapters.toTypedArray())
+        }
+    }
+}

+ 6 - 5
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -4,9 +4,9 @@ import android.content.Context
 import android.database.sqlite.SQLiteConstraintException
 import android.net.Uri
 import android.os.Environment
-import androidx.room.withTransaction
 import androidx.core.net.toUri
 import androidx.documentfile.provider.DocumentFile
+import androidx.room.withTransaction
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.AppLog
@@ -14,9 +14,9 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.Bookmark
-import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HighlightTagRule
@@ -34,13 +34,11 @@ import io.legado.app.data.entities.TagGroupRule
 import io.legado.app.data.entities.TxtTocRule
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
-import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordIdentity
+import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.ReadStyleGateway
-import io.legado.app.ui.book.read.ConfigUpdateAction
-import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.book.isLocal
@@ -52,6 +50,8 @@ import io.legado.app.help.config.SettingsWriter
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.model.BookCover
 import io.legado.app.model.localBook.LocalBook
+import io.legado.app.ui.book.read.ConfigUpdateAction
+import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.utils.ACache
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.GSON
@@ -476,6 +476,7 @@ object Restore : KoinComponent {
             localSession.deviceId,
             localSession.bookName,
             localSession.bookAuthor,
+            localSession.bookUrl,
             localSession.startTime,
             localSession.endTime,
             localSession.words

+ 24 - 9
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -837,10 +837,16 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun initReadTime() {
         synchronized(this) {
-            val currentBookName = book?.name ?: return
-            val currentBookAuthor = book?.author ?: ""
+            val currentBook = book ?: return
+            val currentBookUrl = currentBook.bookUrl
+            val currentBookName = currentBook.name
+            val currentBookAuthor = currentBook.author
             if (currentActiveSession != null &&
-                (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
+                currentActiveSession!!.matchesBook(
+                    currentBookUrl,
+                    currentBookName,
+                    currentBookAuthor
+                ).not()
             ) {
                 commitReadSession()
             }
@@ -851,6 +857,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                     deviceId = "",
                     bookName = currentBookName,
                     bookAuthor = currentBookAuthor,
+                    bookUrl = currentBookUrl,
                     startTime = readStartTime,
                     endTime = readStartTime,
                     words = durChapterIndex.toLong()
@@ -861,14 +868,19 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun upReadTime() {
         synchronized(this) {
+            val currentBook = book ?: return
             val currentLength = currentReadLength
-            val currentBookName = book?.name ?: return
-            val currentBookAuthor = book?.author ?: ""
+            val currentBookUrl = currentBook.bookUrl
+            val currentBookName = currentBook.name
+            val currentBookAuthor = currentBook.author
             val endTime = System.currentTimeMillis()
 
             if (currentActiveSession == null ||
-                currentActiveSession!!.bookName != currentBookName ||
-                currentActiveSession!!.bookAuthor != currentBookAuthor
+                currentActiveSession!!.matchesBook(
+                    currentBookUrl,
+                    currentBookName,
+                    currentBookAuthor
+                ).not()
             ) {
                 initReadTime()
                 return
@@ -933,8 +945,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         synchronized(this) {
             val current = currentActiveSession
             if (current != null &&
-                current.bookName == sessionToSave.bookName &&
-                current.bookAuthor == sessionToSave.bookAuthor
+                current.matchesBook(
+                    sessionToSave.bookUrl,
+                    sessionToSave.bookName,
+                    sessionToSave.bookAuthor
+                )
             ) {
                 currentActiveSession = current.copy(
                     startTime = sessionToSave.endTime,

+ 16 - 5
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt

@@ -11,13 +11,15 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
-import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
+import io.legado.app.domain.model.BookshelfConflict
 import io.legado.app.domain.model.settings.ChangeSourceSettings
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.domain.usecase.ChangeSourceSearchEvent
 import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
-import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.domain.usecase.FindBookshelfConflictUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.help.book.isWebFile
 import io.legado.app.help.book.primaryStr
@@ -29,8 +31,8 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.stateIn
@@ -49,6 +51,7 @@ class ChangeBookSourceComposeViewModel(
     private val getChapterContentUseCase: GetChapterContentUseCase,
     private val searchRepository: SearchRepository,
     private val bookRepository: BookRepository,
+    private val findBookshelfConflictUseCase: FindBookshelfConflictUseCase,
     private val changeSourceSettingsGateway: ChangeSourceSettingsGateway,
 ) : ViewModel() {
 
@@ -106,13 +109,21 @@ class ChangeBookSourceComposeViewModel(
 
     fun getBookFromMap(key: String): Book? = bookMap[key]
 
-    fun findShelfConflict(book: Book, onResult: (Book?) -> Unit) {
+    fun findShelfConflict(book: Book, onResult: (BookshelfConflict?) -> Unit) {
         viewModelScope.launch(IO) {
-            val conflict = bookRepository.getShelfBookConflict(book.name, book.author)
+            val conflict = findBookshelfConflictUseCase.execute(book)
             onMain { onResult(conflict) }
         }
     }
 
+    /** 用户选中某本书架作品后取回完整实体,用于按换源语义执行迁移。 */
+    fun loadShelfBook(bookUrl: String, onResult: (Book?) -> Unit) {
+        viewModelScope.launch(IO) {
+            val book = bookRepository.getBook(bookUrl)
+            onMain { onResult(book) }
+        }
+    }
+
     // Options
     val checkAuthor: Boolean get() = settings.value.checkAuthor
     val loadInfo: Boolean get() = settings.value.loadInfo

+ 6 - 2
app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceMigrationOptionsSheet.kt

@@ -28,6 +28,8 @@ fun ChangeSourceMigrationOptionsSheet(
     title: String,
     subtitle: String? = null,
     initialOptions: ChangeSourceMigrationOptions = ChangeSourceMigrationOptions(),
+    /** 是否展示「删除已下载章节」。共存场景两本书都留在书架,删缓存没有意义。 */
+    showDeleteDownloaded: Boolean = true,
     onDismissRequest: () -> Unit,
     onConfirm: (ChangeSourceMigrationOptions) -> Unit,
 ) {
@@ -86,8 +88,10 @@ fun ChangeSourceMigrationOptionsSheet(
             CheckboxItem("分类与标签", checked = migrateCategory) { migrateCategory = it }
             CheckboxItem("备注和自定义简介", checked = migrateRemark) { migrateRemark = it }
             CheckboxItem("阅读设置", checked = migrateReadConfig) { migrateReadConfig = it }
-            CheckboxItem("删除已下载章节", checked = deleteDownloadedChapters) {
-                deleteDownloadedChapters = it
+            if (showDeleteDownloaded) {
+                CheckboxItem("删除已下载章节", checked = deleteDownloadedChapters) {
+                    deleteDownloadedChapters = it
+                }
             }
             Spacer(modifier = Modifier.height(8.dp))
             ConfirmDismissButtonsRow(

+ 126 - 0
app/src/main/java/io/legado/app/ui/book/conflict/BookshelfConflictController.kt

@@ -0,0 +1,126 @@
+package io.legado.app.ui.book.conflict
+
+import androidx.annotation.StringRes
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.usecase.AddToBookshelfResult
+import io.legado.app.domain.usecase.AddToBookshelfUseCase
+import io.legado.app.domain.usecase.BookTocUnavailableException
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.domain.usecase.ResolveBookshelfConflictUseCase
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * 「加入书架」冲突流程的状态宿主,搜索、发现、首页推荐三个入口共用。
+ *
+ * 它编排用例并持有状态,因此放在功能目录下,与 `ui/widget/components/` 的纯展示组件分开;
+ * 屏幕层只渲染 `BookshelfConflictSheet` 并把用户选择转给 [coexist] / [migrate]。
+ *
+ * 没有 Android 生命周期依赖,由宿主 ViewModel 传入自己的 [scope]。
+ */
+class BookshelfConflictController(
+    private val scope: CoroutineScope,
+    private val addToBookshelfUseCase: AddToBookshelfUseCase,
+    private val resolveBookshelfConflictUseCase: ResolveBookshelfConflictUseCase,
+) {
+
+    private val _conflict = MutableStateFlow<BookshelfConflict?>(null)
+    val conflict = _conflict.asStateFlow()
+
+    private val _isResolving = MutableStateFlow(false)
+    val isResolving = _isResolving.asStateFlow()
+
+    private val _effects = MutableSharedFlow<Effect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    private var pendingBook: SearchBook? = null
+
+    fun addToShelf(searchBook: SearchBook) {
+        scope.launch(Dispatchers.IO) {
+            when (val result = addToBookshelfUseCase.execute(searchBook)) {
+                AddToBookshelfResult.Added ->
+                    _effects.emit(Effect.ShowMessage(R.string.book_added_to_shelf))
+
+                is AddToBookshelfResult.Conflict -> {
+                    pendingBook = searchBook
+                    _conflict.value = result.conflict
+                }
+            }
+        }
+    }
+
+    fun coexist(existingBookUrl: String, options: ChangeSourceMigrationOptions) {
+        resolve(existingBookUrl, options, coexist = true)
+    }
+
+    fun migrate(existingBookUrl: String, options: ChangeSourceMigrationOptions) {
+        resolve(existingBookUrl, options, coexist = false)
+    }
+
+    fun dismiss() {
+        _conflict.value = null
+        pendingBook = null
+    }
+
+    private fun resolve(
+        existingBookUrl: String,
+        options: ChangeSourceMigrationOptions,
+        coexist: Boolean,
+    ) {
+        val searchBook = pendingBook ?: return
+        scope.launch(Dispatchers.IO) {
+            _isResolving.value = true
+            val result = runCatching {
+                if (coexist) {
+                    resolveBookshelfConflictUseCase.coexist(
+                        existingBookUrl = existingBookUrl,
+                        newBook = searchBook.toBook(),
+                        options = options,
+                    )
+                } else {
+                    resolveBookshelfConflictUseCase.migrate(
+                        existingBookUrl = existingBookUrl,
+                        newBook = searchBook.toBook(),
+                        options = options,
+                    )
+                }
+            }
+            _isResolving.value = false
+            _conflict.value = null
+            pendingBook = null
+            result
+                .onSuccess {
+                    _effects.emit(
+                        Effect.ShowMessage(
+                            if (coexist) R.string.bookshelf_conflict_coexist_done
+                            else R.string.bookshelf_conflict_migrate_done
+                        )
+                    )
+                }
+                .onFailure {
+                    AppLog.put("处理书架冲突出错\n${it.localizedMessage}", it, true)
+                    _effects.emit(
+                        Effect.ShowMessage(
+                            if (it is BookTocUnavailableException) {
+                                R.string.bookshelf_conflict_toc_failed
+                            } else {
+                                R.string.bookshelf_conflict_resolve_failed
+                            }
+                        )
+                    )
+                }
+        }
+    }
+
+    sealed interface Effect {
+        data class ShowMessage(@StringRes val messageRes: Int) : Effect
+    }
+}

+ 16 - 0
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt

@@ -4,6 +4,9 @@ import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.ConflictBookSummary
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 
@@ -21,6 +24,8 @@ data class ExploreShowUiState(
     val errorMsg: String? = null,
     val sheet: ExploreShowSheet = ExploreShowSheet.None,
     val filterStateId: Int = 0,
+    val bookshelfConflict: BookshelfConflict? = null,
+    val isResolvingBookshelfConflict: Boolean = false,
 )
 
 @Stable
@@ -51,6 +56,17 @@ sealed interface ExploreShowIntent {
     data object DismissSheet : ExploreShowIntent
     data class OpenBook(val book: SearchBook, val sharedCoverKey: String?) : ExploreShowIntent
     data class AddToShelf(val book: SearchBook) : ExploreShowIntent
+    data object DismissBookshelfConflict : ExploreShowIntent
+    data class OpenBookshelfConflictBook(val summary: ConflictBookSummary) : ExploreShowIntent
+    data class CoexistWithBookshelfConflict(
+        val existingBookUrl: String,
+        val options: ChangeSourceMigrationOptions,
+    ) : ExploreShowIntent
+
+    data class MigrateBookshelfConflict(
+        val existingBookUrl: String,
+        val options: ChangeSourceMigrationOptions,
+    ) : ExploreShowIntent
 }
 
 sealed interface ExploreShowEffect {

+ 18 - 2
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -48,8 +48,8 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import io.legado.app.data.entities.SearchBook
 import io.legado.app.R
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.theme.LegadoTheme
@@ -63,6 +63,7 @@ import io.legado.app.ui.widget.components.book.SearchBookGridItem
 import io.legado.app.ui.widget.components.book.SearchBookListItem
 import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.conflict.BookshelfConflictSheet
 import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
@@ -70,7 +71,9 @@ import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.toastOnUi
 import org.koin.androidx.compose.koinViewModel
+import splitties.init.appCtx
 
 private enum class BookFilterState(val id: Int) {
     SHOW_ALL(0),
@@ -113,7 +116,7 @@ fun ExploreShowRouteScreen(
                     effect.sharedCoverKey,
                 )
 
-                is ExploreShowEffect.ShowMessage -> {}
+                is ExploreShowEffect.ShowMessage -> appCtx.toastOnUi(effect.message)
             }
         }
     }
@@ -266,6 +269,19 @@ fun ExploreShowScreen(
         }
     )
 
+    BookshelfConflictSheet(
+        conflict = state.bookshelfConflict,
+        isResolving = state.isResolvingBookshelfConflict,
+        onDismissRequest = { onIntent(ExploreShowIntent.DismissBookshelfConflict) },
+        onOpenExistingBook = { onIntent(ExploreShowIntent.OpenBookshelfConflictBook(it)) },
+        onCoexist = { existingBookUrl, options ->
+            onIntent(ExploreShowIntent.CoexistWithBookshelfConflict(existingBookUrl, options))
+        },
+        onMigrate = { existingBookUrl, options ->
+            onIntent(ExploreShowIntent.MigrateBookshelfConflict(existingBookUrl, options))
+        },
+    )
+
     AppScaffold(
         modifier = Modifier
             .nestedScroll(scrollBehavior.nestedScrollConnection),

+ 63 - 7
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt

@@ -1,21 +1,24 @@
 package io.legado.app.ui.book.explore
 
+import android.content.res.Configuration
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.repository.ExploreRepository
+import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookShelfKey
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.ResolveBookshelfConflictUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
-import io.legado.app.domain.gateway.CoverSettingsGateway
-import android.content.res.Configuration
-import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.ui.book.conflict.BookshelfConflictController
 import io.legado.app.utils.stackTraceStr
 import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -23,7 +26,6 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import splitties.init.appCtx
 
@@ -52,6 +54,7 @@ class ExploreShowViewModel(
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
+    private val resolveBookshelfConflictUseCase: ResolveBookshelfConflictUseCase,
     private val localPreferencesRepository: SettingsRepository,
     private val coverSettingsGateway: CoverSettingsGateway,
 ) : ViewModel() {
@@ -90,8 +93,15 @@ class ExploreShowViewModel(
     private val _effects = MutableSharedFlow<ExploreShowEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
 
+    private val conflictController = BookshelfConflictController(
+        scope = viewModelScope,
+        addToBookshelfUseCase = addToBookshelfUseCase,
+        resolveBookshelfConflictUseCase = resolveBookshelfConflictUseCase,
+    )
+
     init {
         observeBookshelf()
+        observeBookshelfConflict()
         combineUiState()
         loadLayoutMode()
         loadGridCount()
@@ -119,8 +129,54 @@ class ExploreShowViewModel(
                 )
             )
 
-            is ExploreShowIntent.AddToShelf -> viewModelScope.launch {
-                addToBookshelfUseCase.execute(intent.book)
+            is ExploreShowIntent.AddToShelf -> conflictController.addToShelf(intent.book)
+
+            ExploreShowIntent.DismissBookshelfConflict -> conflictController.dismiss()
+
+            is ExploreShowIntent.OpenBookshelfConflictBook -> {
+                // 先收起 Sheet 再导航,否则返回本页时 Sheet 会重新显示并吃掉一次返回键。
+                conflictController.dismiss()
+                emitEffect(
+                    ExploreShowEffect.OpenBookInfo(
+                        name = intent.summary.name,
+                        author = intent.summary.author,
+                        bookUrl = intent.summary.bookUrl,
+                        origin = intent.summary.origin,
+                        coverPath = intent.summary.displayCover,
+                        sharedCoverKey = null,
+                    )
+                )
+            }
+
+            is ExploreShowIntent.CoexistWithBookshelfConflict -> conflictController.coexist(
+                intent.existingBookUrl,
+                intent.options,
+            )
+
+            is ExploreShowIntent.MigrateBookshelfConflict -> conflictController.migrate(
+                intent.existingBookUrl,
+                intent.options,
+            )
+        }
+    }
+
+    private fun observeBookshelfConflict() {
+        viewModelScope.launch {
+            conflictController.conflict.collect { conflict ->
+                _uiState.update { it.copy(bookshelfConflict = conflict) }
+            }
+        }
+        viewModelScope.launch {
+            conflictController.isResolving.collect { resolving ->
+                _uiState.update { it.copy(isResolvingBookshelfConflict = resolving) }
+            }
+        }
+        viewModelScope.launch {
+            conflictController.effects.collect { effect ->
+                when (effect) {
+                    is BookshelfConflictController.Effect.ShowMessage ->
+                        _effects.emit(ExploreShowEffect.ShowMessage(appCtx.getString(effect.messageRes)))
+                }
             }
         }
     }

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

@@ -7,6 +7,8 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.ConflictBookSummary
 import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.ui.widget.components.variable.VariableEditorUiState
 import kotlinx.collections.immutable.ImmutableList
@@ -52,6 +54,9 @@ data class BookInfoUiState(
     val defaultCover: String = "",
     val defaultCoverDark: String = "",
     val showMangaUi: Boolean = true,
+    /** 加入书架时发现的疑似重复;非空时由冲突 Sheet 决定共存还是迁移。 */
+    val shelfConflict: BookshelfConflict? = null,
+    val isResolvingShelfConflict: Boolean = false,
 )
 
 @Stable
@@ -186,6 +191,18 @@ sealed interface BookInfoIntent {
         val toc: List<BookChapter>,
     ) : BookInfoIntent
 
+    data object DismissShelfConflict : BookInfoIntent
+    data class OpenShelfConflictBook(val summary: ConflictBookSummary) : BookInfoIntent
+    data class CoexistWithShelfConflict(
+        val existingBookUrl: String,
+        val options: ChangeSourceMigrationOptions,
+    ) : BookInfoIntent
+
+    data class MigrateShelfConflict(
+        val existingBookUrl: String,
+        val options: ChangeSourceMigrationOptions,
+    ) : BookInfoIntent
+
     data class ReplaceConflictingBook(
         val oldBook: Book,
         val source: BookSource,

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

@@ -133,6 +133,7 @@ import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.HighlightTagRow
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
+import io.legado.app.ui.widget.components.conflict.BookshelfConflictSheet
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
@@ -530,6 +531,19 @@ private fun BookInfoScreenContent(
         )
     }
 
+    BookshelfConflictSheet(
+        conflict = state.shelfConflict,
+        isResolving = state.isResolvingShelfConflict,
+        onDismissRequest = { onIntent(BookInfoIntent.DismissShelfConflict) },
+        onOpenExistingBook = { onIntent(BookInfoIntent.OpenShelfConflictBook(it)) },
+        onCoexist = { existingBookUrl, options ->
+            onIntent(BookInfoIntent.CoexistWithShelfConflict(existingBookUrl, options))
+        },
+        onMigrate = { existingBookUrl, options ->
+            onIntent(BookInfoIntent.MigrateShelfConflict(existingBookUrl, options))
+        },
+    )
+
     BookInfoDialogs(state = state, onIntent = onIntent)
 }
 

+ 161 - 22
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -31,12 +31,16 @@ import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.model.BookshelfConflict
 import io.legado.app.domain.model.settings.CoverSettings
 import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.domain.usecase.BookTocUnavailableException
 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.FindBookshelfConflictUseCase
+import io.legado.app.domain.usecase.ResolveBookshelfConflictUseCase
 import io.legado.app.exception.NoBooksDirException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
@@ -103,6 +107,8 @@ class BookInfoViewModel(
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
     private val bookGroupRepository: BookGroupRepository,
     private val bookRepository: BookRepository,
+    private val findBookshelfConflictUseCase: FindBookshelfConflictUseCase,
+    private val resolveBookshelfConflictUseCase: ResolveBookshelfConflictUseCase,
     private val bookSourceRepository: BookSourceRepository,
     private val searchRepository: SearchRepository,
     private val highlightTagRuleRepository: HighlightTagRuleRepository,
@@ -187,6 +193,9 @@ class BookInfoViewModel(
     private var observingReadRecordKey: String? = null
     private var chapterChanged = false
 
+    /** 命中重复、等待用户在冲突 Sheet 上选择共存还是迁移的那本书。 */
+    private var pendingShelfBook: Book? = null
+
     var inBookshelf = false
         private set
     var bookSource: BookSource? = null
@@ -287,6 +296,12 @@ class BookInfoViewModel(
 
             BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker)
             BookInfoIntent.ChangeSourceClick -> currentBook?.uiCopy()
+                ?.apply {
+                    // 详情页的 currentBook 多来自书源解析结果,并不带 notShelf 标记,而它才是
+                    // 「未上架」的唯一事实来源。这里按 inBookshelf 如实补位,换源 Sheet 才能
+                    // 知道不需要询问「新增还是替换」。
+                    if (!inBookshelf) addType(BookType.notShelf)
+                }
                 ?.let { setSheet(BookInfoSheet.SourcePicker(it)) }
             BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord)
             BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
@@ -324,6 +339,39 @@ class BookInfoViewModel(
                 }
             }
 
+            BookInfoIntent.DismissShelfConflict -> {
+                pendingShelfBook = null
+                _screenState.update { it.copy(shelfConflict = null) }
+            }
+
+            is BookInfoIntent.OpenShelfConflictBook -> {
+                // 先收起冲突 Sheet 再导航:详情页之间跳转会复用同一份冲突状态,
+                // 不收起来的话新页面一进来就显示 Sheet,并把第一次返回键吃掉。
+                pendingShelfBook = null
+                _screenState.update { it.copy(shelfConflict = null) }
+                emitEffect(
+                    BookInfoEffect.NavigateToBookInfo(
+                        name = intent.summary.name,
+                        author = intent.summary.author,
+                        bookUrl = intent.summary.bookUrl,
+                        origin = intent.summary.origin,
+                        coverPath = intent.summary.displayCover,
+                    )
+                )
+            }
+
+            is BookInfoIntent.CoexistWithShelfConflict -> resolveShelfConflict(
+                existingBookUrl = intent.existingBookUrl,
+                options = intent.options,
+                coexist = true,
+            )
+
+            is BookInfoIntent.MigrateShelfConflict -> resolveShelfConflict(
+                existingBookUrl = intent.existingBookUrl,
+                options = intent.options,
+                coexist = false,
+            )
+
             is BookInfoIntent.ReplaceConflictingBook -> {
                 dismissSheet()
                 changeTo(
@@ -717,32 +765,122 @@ class BookInfoViewModel(
     fun addToBookshelf(success: (() -> Unit)? = null) {
         val book = currentBook ?: return
         execute {
-            book.removeType(BookType.notShelf)
-            if (book.order == 0) {
-                book.order = bookRepository.getMinOrder() - 1
+            // 书架允许同名同作者在架,入架前必须先查重:命中就交给冲突 Sheet 让用户选共存还是迁移,
+            // 绝不静默插一份,也绝不把已有作品的阅读进度悄悄挪到这本新书上。
+            val conflict = findBookshelfConflictUseCase.execute(book)
+            if (conflict != null) {
+                conflict
+            } else {
+                prepareBookForShelf(book)
+                book.save()
+                SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, book)
+                bookRepository.insertChapters(*currentChapterList.toTypedArray())
+                book
             }
-            bookRepository.getBook(book.name, book.author)?.let {
-                book.durChapterIndex = it.durChapterIndex
-                book.durChapterPos = it.durChapterPos
-                book.durChapterTitle = it.durChapterTitle
+        }.onSuccess { result ->
+            when (result) {
+                is BookshelfConflict -> {
+                    pendingShelfBook = book
+                    _screenState.update { state ->
+                        state.copy(shelfConflict = result, isResolvingShelfConflict = false)
+                    }
+                }
+
+                else -> {
+                    (result as? Book)?.let { added ->
+                        applyBookOnShelf(added)
+                        success?.invoke()
+                    }
+                }
             }
-            if (ReadBook.isCurrentBook(book)) {
-                ReadBook.replaceCurrentBook(book)
-            } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
-                AudioPlay.book = book
+        }
+    }
+
+    /** 入架前的通用处理:去掉「未上架」标记并保证排序值落在书架最前。 */
+    private suspend fun prepareBookForShelf(book: Book) {
+        book.removeType(BookType.notShelf)
+        if (book.order == 0) {
+            book.order = bookRepository.getMinOrder() - 1
+        }
+    }
+
+    /** 书籍成功进入书架后,详情页需要同步的宿主状态。 */
+    private fun applyBookOnShelf(book: Book) {
+        if (ReadBook.isCurrentBook(book)) {
+            ReadBook.replaceCurrentBook(book)
+        } else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
+            AudioPlay.book = book
+        }
+        currentBook = book
+        inBookshelf = true
+        syncUiState()
+    }
+
+    /**
+     * 共存 / 迁移的落地执行。
+     *
+     * 两条路径都复用 [ResolveBookshelfConflictUseCase](与搜索页加入书架同一套语义与选项),
+     * 完成后只补做详情页特有的宿主同步。目录优先用详情页已经加载好的,避免再发一次请求。
+     */
+    private fun resolveShelfConflict(
+        existingBookUrl: String,
+        options: ChangeSourceMigrationOptions,
+        coexist: Boolean,
+    ) {
+        val book = pendingShelfBook ?: return
+        val chapters = currentChapterList.takeIf { it.isNotEmpty() }
+        _screenState.update { it.copy(isResolvingShelfConflict = true) }
+        execute {
+            if (coexist) {
+                resolveBookshelfConflictUseCase.coexist(
+                    existingBookUrl = existingBookUrl,
+                    newBook = book,
+                    options = options,
+                    chapters = chapters,
+                )
+            } else {
+                resolveBookshelfConflictUseCase.migrate(
+                    existingBookUrl = existingBookUrl,
+                    newBook = book,
+                    options = options,
+                    chapters = chapters,
+                )
             }
-            book.save()
-            SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, book)
-            bookRepository.insertChapters(*currentChapterList.toTypedArray())
             book
-        }.onSuccess {
-            currentBook = it
-            inBookshelf = true
-            syncUiState()
-            success?.invoke()
+        }.onSuccess { resolved ->
+            // 迁移后书架上的旧书已被删除,补一次回调让书源收到入架事件。
+            SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, resolved)
+            if (chapters != null) {
+                currentChapterList = chapters
+            }
+            pendingShelfBook = null
+            _screenState.update { it.copy(shelfConflict = null, isResolvingShelfConflict = false) }
+            applyBookOnShelf(resolved)
+            showMessage(
+                if (coexist) R.string.bookshelf_conflict_coexist_done
+                else R.string.bookshelf_conflict_migrate_done
+            )
+        }.onError {
+            AppLog.put("处理书架冲突出错", it)
+            pendingShelfBook = null
+            _screenState.update { it.copy(shelfConflict = null, isResolvingShelfConflict = false) }
+            showMessage(
+                if (it is BookTocUnavailableException) {
+                    R.string.bookshelf_conflict_toc_failed
+                } else {
+                    R.string.bookshelf_conflict_resolve_failed
+                }
+            )
         }
     }
 
+    /**
+     * 「另存新书」入架。
+     *
+     * 这里**有意不做查重**:该入口只由换源 Sheet 的「新增书籍」选项触发,而换源 Sheet 在
+     * 走这条分支之前已经调用过 `FindBookshelfConflictUseCase`,命中冲突时会改为弹冲突 Sheet
+     * (见 [resolveShelfConflict]),不会落到这里。重复检测放在一处,避免同一动作被问两次。
+     */
     fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
         execute {
             book.removeType(BookType.notShelf)
@@ -1386,14 +1524,15 @@ class BookInfoViewModel(
             clearReadRecordObserve()
             return
         }
-        val key = "${book.name}|||${book.author}"
+        // 阅读统计按书籍副本统计:书架允许同名作者作品共存,不能让一个副本继承另一个副本的时长。
+        val key = "${book.name}|||${book.author}|||${book.bookUrl}"
         if (observingReadRecordKey == key && readRecordObserveJob?.isActive == true) return
         observingReadRecordKey = key
         readRecordObserveJob?.cancel()
         readRecordObserveJob = viewModelScope.launch {
             combine(
-                readRecordRepository.getBookReadTime(book.name, book.author),
-                readRecordRepository.getBookTimelineDays(book.name, book.author)
+                readRecordRepository.getBookCopyReadTime(book.bookUrl, book.name, book.author),
+                readRecordRepository.getBookCopyTimelineDays(book.bookUrl, book.name, book.author)
             ) { totalTime, timelineDays ->
                 totalTime to timelineDays
             }.collectLatest { (totalTime, timelineDays) ->

+ 5 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadRecordAliasDelegate.kt

@@ -37,7 +37,11 @@ class ReadRecordAliasDelegate(
             .split('\n')
             .mapNotNull { ReadRecordAliasDecision.decode(it, key) }
             .firstOrNull()
-        if (decision != null) {
+        // 书架允许同书名同作者的作品共存。出现多个副本时,「作者为空」的旧记录无法唯一归属
+        // 到当前这一本,此时不许沿用「总是合并」的历史决定,必须重新让用户确认。
+        // 副本数走与加入书架查重同一套规范化口径,否则全角/半角差异的同名副本会被漏数。
+        val ambiguous = readRecordRepository.countShelfCopies(book.name, book.author) > 1
+        if (decision != null && !ambiguous) {
             if (decision == ReadRecordAliasAction.MERGE) merge(book, sources)
             return
         }

+ 16 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -5,8 +5,11 @@ import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.ConflictBookSummary
 import io.legado.app.domain.model.ContentQualityStage
 import io.legado.app.domain.model.MatchMode
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableMap
@@ -85,6 +88,8 @@ data class SearchUiState(
     val expandedSourceSavedScrollIndex: Int = 0,
     val expandedSourceSavedScrollOffset: Int = 0,
     val contentQuality: ContentQualityUiState = ContentQualityUiState(),
+    val bookshelfConflict: BookshelfConflict? = null,
+    val isResolvingBookshelfConflict: Boolean = false,
 )
 
 data class SearchEmptyScopeAction(
@@ -139,6 +144,17 @@ sealed interface SearchIntent {
     data object OpenSourceManage : SearchIntent
     data class SaveScrollState(val index: Int, val offset: Int) : SearchIntent
     data class SaveExpandedSourceScrollState(val index: Int, val offset: Int) : SearchIntent
+    data object DismissBookshelfConflict : SearchIntent
+    data class OpenBookshelfConflictBook(val summary: ConflictBookSummary) : SearchIntent
+    data class CoexistWithBookshelfConflict(
+        val existingBookUrl: String,
+        val options: ChangeSourceMigrationOptions,
+    ) : SearchIntent
+
+    data class MigrateBookshelfConflict(
+        val existingBookUrl: String,
+        val options: ChangeSourceMigrationOptions,
+    ) : SearchIntent
 }
 
 sealed interface SearchEffect {

+ 16 - 2
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -28,8 +28,8 @@ import androidx.compose.material.icons.filled.Layers
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.TextField
 import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
@@ -78,13 +78,14 @@ import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
+import io.legado.app.ui.widget.components.conflict.BookshelfConflictSheet
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
-import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
 import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
@@ -789,6 +790,19 @@ fun SearchScreen(
         },
     )
 
+    BookshelfConflictSheet(
+        conflict = state.bookshelfConflict,
+        isResolving = state.isResolvingBookshelfConflict,
+        onDismissRequest = { onIntent(SearchIntent.DismissBookshelfConflict) },
+        onOpenExistingBook = { onIntent(SearchIntent.OpenBookshelfConflictBook(it)) },
+        onCoexist = { existingBookUrl, options ->
+            onIntent(SearchIntent.CoexistWithBookshelfConflict(existingBookUrl, options))
+        },
+        onMigrate = { existingBookUrl, options ->
+            onIntent(SearchIntent.MigrateBookshelfConflict(existingBookUrl, options))
+        },
+    )
+
     ExpandedSourceSheet(
         show = state.showExpandedSource,
         sourceName = state.expandedSourceName ?: "",

+ 61 - 5
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -2,12 +2,12 @@ package io.legado.app.ui.book.search
 
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
-import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.SearchRepository
+import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.ContentQualityConfig
 import io.legado.app.domain.model.ContentQualityProgress
@@ -19,8 +19,10 @@ import io.legado.app.domain.usecase.BookShelfKey
 import io.legado.app.domain.usecase.CheckBookContentQualityUseCase
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.ResolveBookshelfConflictUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.SearchRunEvent
+import io.legado.app.ui.book.conflict.BookshelfConflictController
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentSetOf
 import kotlinx.collections.immutable.toImmutableList
@@ -45,6 +47,7 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import splitties.init.appCtx
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class SearchViewModel(
@@ -53,6 +56,7 @@ class SearchViewModel(
     private val searchBooksUseCase: SearchBooksUseCase,
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
+    private val resolveBookshelfConflictUseCase: ResolveBookshelfConflictUseCase,
     private val checkBookContentQualityUseCase: CheckBookContentQualityUseCase,
     private val localPreferencesRepository: SettingsRepository,
     private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
@@ -89,6 +93,12 @@ class SearchViewModel(
     private val _effects = MutableSharedFlow<SearchEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
 
+    private val conflictController = BookshelfConflictController(
+        scope = viewModelScope,
+        addToBookshelfUseCase = addToBookshelfUseCase,
+        resolveBookshelfConflictUseCase = resolveBookshelfConflictUseCase,
+    )
+
     private val queryFlow = MutableStateFlow("")
     private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
     private var persistedSearchScopeRaw = ""
@@ -111,6 +121,7 @@ class SearchViewModel(
 
     init {
         syncScopeState()
+        observeBookshelfConflict()
         observeSearchScope()
         observeEnabledGroups()
         observeEnabledSources()
@@ -124,8 +135,27 @@ class SearchViewModel(
     }
 
     fun onAddToShelf(book: SearchBook) {
+        conflictController.addToShelf(book)
+    }
+
+    private fun observeBookshelfConflict() {
         viewModelScope.launch {
-            addToBookshelfUseCase.execute(book)
+            conflictController.conflict.collect { conflict ->
+                _uiState.update { it.copy(bookshelfConflict = conflict) }
+            }
+        }
+        viewModelScope.launch {
+            conflictController.isResolving.collect { resolving ->
+                _uiState.update { it.copy(isResolvingBookshelfConflict = resolving) }
+            }
+        }
+        viewModelScope.launch {
+            conflictController.effects.collect { effect ->
+                when (effect) {
+                    is BookshelfConflictController.Effect.ShowMessage ->
+                        _effects.emit(SearchEffect.ShowMessage(appCtx.getString(effect.messageRes)))
+                }
+            }
         }
     }
 
@@ -170,10 +200,36 @@ class SearchViewModel(
                 )
             }
 
-            is SearchIntent.AddToShelf -> viewModelScope.launch {
-                addToBookshelfUseCase.execute(intent.book)
+            is SearchIntent.AddToShelf -> conflictController.addToShelf(intent.book)
+
+            SearchIntent.DismissBookshelfConflict -> conflictController.dismiss()
+
+            is SearchIntent.OpenBookshelfConflictBook -> {
+                // 先收起 Sheet 再导航:目标页与返回后的本页都会复用这份冲突状态,
+                // 不收起来的话返回时 Sheet 会被重新显示并吃掉一次返回键。
+                conflictController.dismiss()
+                emitEffect(
+                    SearchEffect.OpenBookInfo(
+                        name = intent.summary.name,
+                        author = intent.summary.author,
+                        bookUrl = intent.summary.bookUrl,
+                        origin = intent.summary.origin,
+                        coverPath = intent.summary.displayCover,
+                        sharedCoverKey = null,
+                    )
+                )
             }
 
+            is SearchIntent.CoexistWithBookshelfConflict -> conflictController.coexist(
+                intent.existingBookUrl,
+                intent.options,
+            )
+
+            is SearchIntent.MigrateBookshelfConflict -> conflictController.migrate(
+                intent.existingBookUrl,
+                intent.options,
+            )
+
             is SearchIntent.OpenBookshelfBook -> {
                 emitEffect(
                     SearchEffect.OpenBookInfo(

+ 10 - 0
app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt

@@ -108,6 +108,7 @@ import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.conflict.BookshelfConflictSheet
 import io.legado.app.ui.widget.components.divider.PillDivider
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcons
@@ -369,6 +370,15 @@ fun HomeRouteScreen(
             homepageViewModel.onAddToShelf(book)
         },
     )
+
+    BookshelfConflictSheet(
+        conflict = homepageState.bookshelfConflict,
+        isResolving = homepageState.isResolvingBookshelfConflict,
+        onDismissRequest = homepageViewModel::dismissBookshelfConflict,
+        onOpenExistingBook = homepageViewModel::openBookshelfConflictBook,
+        onCoexist = homepageViewModel::coexistWithBookshelfConflict,
+        onMigrate = homepageViewModel::migrateBookshelfConflict,
+    )
 }
 
 @OptIn(

+ 4 - 1
app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.BookshelfConflict
 import io.legado.app.domain.model.HomepageModuleType
 import io.legado.app.domain.model.ModuleDef
 import kotlinx.collections.immutable.ImmutableList
@@ -20,7 +21,9 @@ data class HomepageUiState(
     val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
     val isManageMode: Boolean = false,
     val isRefreshing: Boolean = false,
-    val manageState: HomepageManageUiState = HomepageManageUiState()
+    val manageState: HomepageManageUiState = HomepageManageUiState(),
+    val bookshelfConflict: BookshelfConflict? = null,
+    val isResolvingBookshelfConflict: Boolean = false,
 )
 
 @Stable

+ 73 - 6
app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt

@@ -12,16 +12,21 @@ import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.gateway.HomepageModulesGateway
 import io.legado.app.domain.gateway.HomepageSettingsGateway
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.ConflictBookSummary
 import io.legado.app.domain.model.CustomSetItem
 import io.legado.app.domain.model.HomepageModuleType
 import io.legado.app.domain.model.ModuleDef
 import io.legado.app.domain.model.ModuleItem
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookShelfKey
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.ResolveBookshelfConflictUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.help.source.exploreKinds
+import io.legado.app.ui.book.conflict.BookshelfConflictController
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.stackTraceStr
@@ -44,6 +49,7 @@ import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import splitties.init.appCtx
 import java.security.MessageDigest
 import java.util.concurrent.ConcurrentHashMap
 
@@ -57,6 +63,7 @@ class HomepageViewModel(
     private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
     private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
+    private val resolveBookshelfConflictUseCase: ResolveBookshelfConflictUseCase,
 ) : BaseViewModel(application) {
 
     private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
@@ -97,6 +104,13 @@ class HomepageViewModel(
     private val _effects = MutableSharedFlow<HomepageEffect>(extraBufferCapacity = 8)
     val effects = _effects.asSharedFlow()
 
+    // 冲突状态通过 uiFlagsFlow 汇入 uiState(见下方 uiState),与其它 ViewModel 的写法保持一致。
+    private val conflictController = BookshelfConflictController(
+        scope = viewModelScope,
+        addToBookshelfUseCase = addToBookshelfUseCase,
+        resolveBookshelfConflictUseCase = resolveBookshelfConflictUseCase,
+    )
+
     private val loadJobs = ConcurrentHashMap<String, Job>()
     private val exploreSourcePartsFlow = bookSourceRepository.flowExploreSourceParts()
 
@@ -161,8 +175,13 @@ class HomepageViewModel(
 
     // 4. 聚合层
     private val uiFlagsFlow =
-        combine(_isRefreshing, _isManageMode) { refreshing, manage ->
-            HomepageUiFlags(refreshing, manage)
+        combine(
+            _isRefreshing,
+            _isManageMode,
+            conflictController.conflict,
+            conflictController.isResolving,
+        ) { refreshing, manage, conflict, resolvingConflict ->
+            HomepageUiFlags(refreshing, manage, conflict, resolvingConflict)
         }
 
     private val manageStateFlow = combine(
@@ -288,7 +307,9 @@ class HomepageViewModel(
             modules = modules,
             isRefreshing = flags.isRefreshing,
             isManageMode = flags.isManageMode,
-            manageState = manageState
+            manageState = manageState,
+            bookshelfConflict = flags.bookshelfConflict,
+            isResolvingBookshelfConflict = flags.isResolvingBookshelfConflict,
         )
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
 
@@ -324,6 +345,17 @@ class HomepageViewModel(
             }
         }
 
+        viewModelScope.launch {
+            conflictController.effects.collect { effect ->
+                when (effect) {
+                    is BookshelfConflictController.Effect.ShowMessage ->
+                        _effects.emit(
+                            HomepageEffect.ShowSnackbar(appCtx.getString(effect.messageRes))
+                        )
+                }
+            }
+        }
+
         viewModelScope.launch {
             bookSourceRepository.flowHomepageModules().collect { sources ->
                 _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl }
@@ -1013,8 +1045,41 @@ class HomepageViewModel(
     }
 
     fun onAddToShelf(book: SearchBook) {
-        execute {
-            addToBookshelfUseCase.execute(book)
+        conflictController.addToShelf(book)
+    }
+
+    fun dismissBookshelfConflict() {
+        conflictController.dismiss()
+    }
+
+    fun coexistWithBookshelfConflict(
+        existingBookUrl: String,
+        options: ChangeSourceMigrationOptions,
+    ) {
+        conflictController.coexist(existingBookUrl, options)
+    }
+
+    fun migrateBookshelfConflict(
+        existingBookUrl: String,
+        options: ChangeSourceMigrationOptions,
+    ) {
+        conflictController.migrate(existingBookUrl, options)
+    }
+
+    fun openBookshelfConflictBook(summary: ConflictBookSummary) {
+        // 先收起 Sheet 再导航,否则返回本页时 Sheet 会重新显示并吃掉一次返回键。
+        conflictController.dismiss()
+        viewModelScope.launch {
+            _effects.emit(
+                HomepageEffect.NavigateToBookInfo(
+                    name = summary.name,
+                    author = summary.author,
+                    bookUrl = summary.bookUrl,
+                    origin = summary.origin,
+                    coverPath = summary.displayCover,
+                    sharedCoverKey = null,
+                )
+            )
         }
     }
 
@@ -1089,7 +1154,9 @@ class HomepageViewModel(
 
 private data class HomepageUiFlags(
     val isRefreshing: Boolean,
-    val isManageMode: Boolean
+    val isManageMode: Boolean,
+    val bookshelfConflict: BookshelfConflict?,
+    val isResolvingBookshelfConflict: Boolean,
 )
 
 private data class RankingKindsArgs(

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

@@ -42,7 +42,10 @@ 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.SearchBook
+import io.legado.app.domain.model.BookshelfConflict
 import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.domain.usecase.copyMigratableFieldsTo
+import io.legado.app.help.book.isNotShelf
 import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
 import io.legado.app.ui.book.changesource.ChangeBookSourceEffect
 import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
@@ -55,6 +58,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.card.SelectionItemCard
+import io.legado.app.ui.widget.components.conflict.BookshelfConflictSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -67,12 +71,19 @@ import org.koin.androidx.compose.koinViewModel
 
 @Stable
 private data class PendingShelfConflict(
-    val existingBook: Book,
+    val conflict: BookshelfConflict,
     val source: BookSource,
     val newBook: Book,
     val toc: List<BookChapter>,
 )
 
+/**
+ * 换源 Sheet。
+ *
+ * @param oldBook 要换源的书。**调用方必须如实带上「未上架」标记**([io.legado.app.constant.BookType.notShelf]):
+ *   未上架的书换源不存在「新增还是替换」的歧义,本组件据此跳过该询问。
+ *   详情页这类从书源解析出来的 Book 不带该标记,需要由调用方按自己的在架状态补上。
+ */
 @Composable
 fun ChangeSourceSheet(
     show: Boolean,
@@ -116,6 +127,8 @@ fun ChangeSourceSheet(
     var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
     var showFilterSheet by rememberSaveable { mutableStateOf(false) }
     val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf)
+    // 不在书架的书换源,只是给这本还没上架的书换个源,不存在「新增还是替换」的歧义,直接替换即可。
+    val canAddAsNew = allowAddAsNew && !oldBook.isNotShelf
 
     val editSourceResult =
         rememberLauncherForActivityResult(androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()) {
@@ -181,14 +194,14 @@ fun ChangeSourceSheet(
                         onDismissRequest()
                     }
                 } else if (onReplaceConflict != null) {
-                    viewModel.findShelfConflict(book) { existingBook ->
+                    viewModel.findShelfConflict(book) { conflict ->
                         loadingAction = false
-                        if (existingBook == null) {
+                        if (conflict == null) {
                             onAddAsNew(book, toc)
                             context.toastOnUi(bookAddedToShelfText)
                         } else {
                             shelfConflict = PendingShelfConflict(
-                                existingBook = existingBook,
+                                conflict = conflict,
                                 source = source,
                                 newBook = book,
                                 toc = toc,
@@ -373,7 +386,7 @@ fun ChangeSourceSheet(
                             if (item.bookUrl != oldBook.bookUrl) {
                                 if (!item.sameBookTypeLocal(oldBook.type)) {
                                     mismatchBook = item
-                                } else if (allowAddAsNew) {
+                                } else if (canAddAsNew) {
                                     actionBook = item
                                 } else {
                                     performAction(item, true)
@@ -448,7 +461,7 @@ fun ChangeSourceSheet(
         confirmText = stringResource(android.R.string.ok),
         onConfirm = { searchBook ->
             mismatchBook = null
-            if (allowAddAsNew) {
+            if (canAddAsNew) {
                 actionBook = searchBook
             } else {
                 performAction(searchBook, true)
@@ -457,7 +470,7 @@ fun ChangeSourceSheet(
         dismissText = stringResource(android.R.string.cancel),
         onDismiss = { mismatchBook = null }
     )
-    if (allowAddAsNew) {
+    if (canAddAsNew) {
         AppAlertDialog(
             data = actionBook,
             onDismissRequest = { actionBook = null },
@@ -468,74 +481,52 @@ fun ChangeSourceSheet(
             onConfirm = { performAction(it, true) }
         )
     }
-    AppAlertDialog(
-        data = shelfConflict,
+    BookshelfConflictSheet(
+        conflict = shelfConflict?.conflict,
         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 ->
+        onOpenExistingBook = { summary ->
+            // 先收起冲突 Sheet 再跳转,否则返回本页时它会重新显示并吃掉一次返回键。
             shelfConflict = null
-            onReplaceConflict?.invoke(
-                conflict.existingBook,
-                conflict.source,
-                conflict.newBook,
-                conflict.toc,
-                settings.migrationOptions(),
+            context.startActivity(
+                MainActivity.createBookInfoIntent(
+                    context = context,
+                    name = summary.name,
+                    author = summary.author,
+                    bookUrl = summary.bookUrl,
+                    origin = summary.origin,
+                    coverPath = summary.displayCover,
+                )
             )
-            onDismissRequest()
         },
-        dismissText = stringResource(R.string.add_as_new_book),
-        onDismiss = {
-            shelfConflict?.let { conflict ->
-                onAddAsNew(conflict.newBook, conflict.toc)
-                context.toastOnUi(bookAddedToShelfText)
-            }
+        onCoexist = { existingBookUrl, options ->
+            val pending = shelfConflict
             shelfConflict = null
-        },
-        content = { conflict ->
-            val existingSource = conflict.existingBook.originName.ifBlank {
-                conflict.existingBook.origin
+            if (pending != null) {
+                // 共存也必须兑现 Sheet 上勾选的数据项,否则同一个交互在换源入口与其它入口
+                // 语义不一致。这里只按选项把已有作品的字段复制到新书,不碰阅读记录。
+                viewModel.loadShelfBook(existingBookUrl) { existingBook ->
+                    existingBook?.copyMigratableFieldsTo(pending.newBook, options)
+                    onAddAsNew(pending.newBook, pending.toc)
+                    context.toastOnUi(bookAddedToShelfText)
+                }
             }
-            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,
-                )
+        },
+        onMigrate = { existingBookUrl, options ->
+            val pending = shelfConflict
+            shelfConflict = null
+            if (pending != null) {
+                viewModel.loadShelfBook(existingBookUrl) { oldBook ->
+                    if (oldBook != null) {
+                        onReplaceConflict?.invoke(
+                            oldBook,
+                            pending.source,
+                            pending.newBook,
+                            pending.toc,
+                            options,
+                        )
+                    }
+                    onDismissRequest()
+                }
             }
         },
     )

+ 243 - 0
app/src/main/java/io/legado/app/ui/widget/components/conflict/BookshelfConflictSheet.kt

@@ -0,0 +1,243 @@
+package io.legado.app.ui.widget.components.conflict
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.ConflictBookSummary
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppRadioButton
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.card.SelectionItemCard
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.text.AppText
+
+/**
+ * [ChangeSourceMigrationOptions] 不是 Parcelable,默认 Saver 只认能塞进 Bundle 的类型,
+ * 直接用 rememberSaveable 会在组合时抛 IllegalArgumentException。这里按字段逐个存取。
+ *
+ * 注意:给 [ChangeSourceMigrationOptions] 增加字段时,必须同步在这里补一个存取位,
+ * 否则恢复出来的选项会静默丢失新字段的勾选状态。
+ */
+private val ChangeSourceMigrationOptionsSaver = listSaver<ChangeSourceMigrationOptions, Boolean>(
+    save = {
+        listOf(
+            it.migrateChapters,
+            it.migrateReadingProgress,
+            it.migrateGroup,
+            it.migrateCover,
+            it.migrateCategory,
+            it.migrateRemark,
+            it.migrateReadConfig,
+            it.deleteDownloadedChapters,
+        )
+    },
+    restore = {
+        ChangeSourceMigrationOptions(
+            migrateChapters = it[0],
+            migrateReadingProgress = it[1],
+            migrateGroup = it[2],
+            migrateCover = it[3],
+            migrateCategory = it[4],
+            migrateRemark = it[5],
+            migrateReadConfig = it[6],
+            deleteDownloadedChapters = it[7],
+        )
+    },
+)
+
+/**
+ * 加入书架时发现疑似重复的处理 Sheet。
+ *
+ * 换源、批量换源、搜索/发现/首页加入书架共用这个组件:
+ * - 卡片点击 → 打开书架已有作品的详情页;
+ * - 多个候选时通过单选决定「对哪一本」执行操作,默认选中最近阅读过的那本;
+ * - 底部「共存 / 迁移」复用换源的携带数据选项。
+ */
+@Composable
+fun BookshelfConflictSheet(
+    conflict: BookshelfConflict?,
+    isResolving: Boolean = false,
+    onDismissRequest: () -> Unit,
+    onOpenExistingBook: (ConflictBookSummary) -> Unit,
+    onCoexist: (existingBookUrl: String, options: ChangeSourceMigrationOptions) -> Unit,
+    onMigrate: (existingBookUrl: String, options: ChangeSourceMigrationOptions) -> Unit,
+) {
+    val stateKey = conflict?.incoming?.bookUrl
+    var options by rememberSaveable(stateKey, stateSaver = ChangeSourceMigrationOptionsSaver) {
+        mutableStateOf(ChangeSourceMigrationOptions())
+    }
+    var showOptions by rememberSaveable { mutableStateOf(false) }
+    var selectedBookUrl by rememberSaveable(stateKey) { mutableStateOf<String?>(null) }
+
+    AppModalBottomSheet(
+        show = conflict != null,
+        onDismissRequest = {
+            // 关闭冲突 Sheet 时一并收起选项子 Sheet,否则会留下一个没有归属的「要携带的数据」面板。
+            showOptions = false
+            onDismissRequest()
+        },
+        title = stringResource(R.string.bookshelf_conflict_title),
+        endAction = {
+            MediumTonalButton(
+                onClick = { showOptions = true },
+                icon = Icons.Outlined.Settings,
+                contentDescription = stringResource(R.string.setting)
+            )
+        },
+    ) {
+        val candidates = conflict?.candidates.orEmpty()
+        if (conflict != null && candidates.isNotEmpty()) {
+            val target = candidates.firstOrNull { it.bookUrl == selectedBookUrl }
+                ?: candidates.first()
+            AppText(
+                text = stringResource(R.string.bookshelf_conflict_existing_title),
+                style = LegadoTheme.typography.titleSmallEmphasized,
+            )
+            AppText(
+                text = stringResource(R.string.bookshelf_conflict_open_hint),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+            Spacer(modifier = Modifier.height(8.dp))
+            LazyColumn(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 320.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                items(candidates, key = { it.bookUrl }) { candidate ->
+                    Row(
+                        verticalAlignment = Alignment.CenterVertically,
+                        horizontalArrangement = Arrangement.spacedBy(4.dp),
+                    ) {
+                        if (candidates.size > 1) {
+                            AppRadioButton(
+                                selected = candidate.bookUrl == target.bookUrl,
+                                onClick = { selectedBookUrl = candidate.bookUrl },
+                            )
+                        }
+                        ConflictBookCard(
+                            summary = candidate,
+                            onClick = { onOpenExistingBook(candidate) },
+                            modifier = Modifier.weight(1f),
+                        )
+                    }
+                }
+            }
+            Spacer(modifier = Modifier.height(12.dp))
+            // 这条说明解释的是下面两个按钮的行为,因此贴在按钮上方,而不是放进「要携带的数据」面板里。
+            AppText(
+                text = stringResource(R.string.bookshelf_conflict_actions_hint),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+            Spacer(modifier = Modifier.height(8.dp))
+            if (isResolving) {
+                Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+                    AppCircularProgressIndicator()
+                }
+            } else {
+                ConfirmDismissButtonsRow(
+                    onDismiss = { onCoexist(target.bookUrl, options) },
+                    onConfirm = { onMigrate(target.bookUrl, options) },
+                    dismissText = stringResource(R.string.bookshelf_conflict_coexist),
+                    confirmText = stringResource(R.string.bookshelf_conflict_migrate),
+                )
+            }
+            Spacer(modifier = Modifier.height(12.dp))
+        }
+    }
+
+    ChangeSourceMigrationOptionsSheet(
+        // 没有冲突对象时不允许单独出现,避免选项面板脱离它所属的书籍。
+        show = showOptions && conflict != null,
+        title = stringResource(R.string.bookshelf_conflict_options_title),
+        initialOptions = options,
+        showDeleteDownloaded = false,
+        onDismissRequest = { showOptions = false },
+        onConfirm = {
+            options = it
+            showOptions = false
+        }
+    )
+}
+
+@Composable
+private fun ConflictBookCard(
+    summary: ConflictBookSummary,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    SelectionItemCard(
+        title = summary.name,
+        modifier = modifier,
+        containerColor = LegadoTheme.colorScheme.onSheetContent,
+        leadingContent = {
+            CoilBookCover(
+                name = summary.name,
+                author = summary.author,
+                path = summary.displayCover,
+                sourceOrigin = summary.origin,
+                bookUrl = summary.bookUrl,
+                preferCache = true,
+                modifier = Modifier
+                    .width(52.dp)
+                    .aspectRatio(5f / 7f),
+            )
+        },
+        supportingContent = {
+            Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+                if (summary.author.isNotBlank()) {
+                    AppText(
+                        text = summary.author,
+                        style = LegadoTheme.typography.labelLargeEmphasized,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+                AppText(
+                    text = summary.sourceName,
+                    style = LegadoTheme.typography.labelMediumEmphasized,
+                    color = LegadoTheme.colorScheme.primary,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                AppText(
+                    text = stringResource(R.string.all_chapter_num, summary.totalChapterNum),
+                    style = LegadoTheme.typography.labelSmallEmphasized,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        },
+        onToggleSelection = onClick,
+    )
+}

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

@@ -3339,4 +3339,16 @@
     <string name="feature_bookmarks_no_matching_notes">没有找到相关笔记</string>
     <string name="feature_bookmarks_no_notes">暂无笔记</string>
     <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
+
+    <string name="bookshelf_conflict_title">检测到可能重复的作品</string>
+    <string name="bookshelf_conflict_existing_title">书架中已有的作品</string>
+    <string name="bookshelf_conflict_open_hint">点击卡片查看该作品详情</string>
+    <string name="bookshelf_conflict_coexist">共存</string>
+    <string name="bookshelf_conflict_migrate">迁移</string>
+    <string name="bookshelf_conflict_options_title">要携带的数据</string>
+    <string name="bookshelf_conflict_actions_hint">共存:把勾选的数据从已有作品复制到新加入的书。迁移:用勾选的数据以新书替换已有作品。</string>
+    <string name="bookshelf_conflict_coexist_done">已作为另一本加入书架</string>
+    <string name="bookshelf_conflict_migrate_done">已迁移到新的书源</string>
+    <string name="bookshelf_conflict_resolve_failed">处理失败,请重试</string>
+    <string name="bookshelf_conflict_toc_failed">获取目录失败,已取消迁移,请重试</string>
 </resources>

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

@@ -3345,4 +3345,16 @@
     <string name="feature_bookmarks_no_matching_notes">No matching notes</string>
     <string name="feature_bookmarks_no_notes">No notes</string>
     <string name="feature_bookmarks_chapter_fallback">Chapter %1$d</string>
+
+    <string name="bookshelf_conflict_title">Possible Duplicate</string>
+    <string name="bookshelf_conflict_existing_title">Already on bookshelf</string>
+    <string name="bookshelf_conflict_open_hint">Tap a book to open its details</string>
+    <string name="bookshelf_conflict_coexist">Keep Both</string>
+    <string name="bookshelf_conflict_migrate">Migrate</string>
+    <string name="bookshelf_conflict_options_title">Data to Carry Over</string>
+    <string name="bookshelf_conflict_actions_hint">Keep both: copy the selected fields from the existing book to the new one. Migrate: replace the existing book with the new one using the selected fields.</string>
+    <string name="bookshelf_conflict_coexist_done">Added as another copy</string>
+    <string name="bookshelf_conflict_migrate_done">Migrated to the new source</string>
+    <string name="bookshelf_conflict_resolve_failed">Could not finish the operation</string>
+    <string name="bookshelf_conflict_toc_failed">Could not load the table of contents. Migration cancelled, please retry.</string>
 </resources>

+ 122 - 0
app/src/test/java/io/legado/app/data/repository/ReadRecordRepositoryTest.kt

@@ -2,11 +2,14 @@ package io.legado.app.data.repository
 
 import android.app.Application
 import androidx.room.Room
+import androidx.room.withTransaction
 import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.help.config.AppConfigStore
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.runBlocking
 import org.junit.After
 import org.junit.Assert.assertEquals
@@ -205,6 +208,125 @@ class ReadRecordRepositoryTest {
         assertEquals(0, database.readRecordDao.getSessionsByBook(deviceId, targetName, author).size)
     }
 
+    @Test
+    fun `coexisting copies are timed independently while the work total is their sum`() =
+        runBlocking {
+            val copyA = "https://a.example/book"
+            val copyB = "https://b.example/book"
+            repository.saveReadSession(session(bookUrl = copyA, start = 1_000, end = 1_200))
+            repository.saveReadSession(session(bookUrl = copyB, start = 2_000, end = 2_300))
+
+            assertEquals(200L, repository.getBookCopyReadTime(copyA, targetName, author).first())
+            assertEquals(300L, repository.getBookCopyReadTime(copyB, targetName, author).first())
+            // 作品维度 = 两个副本之和
+            assertEquals(500L, repository.getBookReadTime(targetName, author).first())
+        }
+
+    @Test
+    fun `copy without owned session falls back to unowned legacy sessions`() = runBlocking {
+        val copyA = "https://a.example/book"
+        database.readRecordDao.insertSession(session(bookUrl = "", start = 1_000, end = 1_200))
+
+        assertEquals(200L, repository.getBookCopyReadTime(copyA, targetName, author).first())
+    }
+
+    @Test
+    fun `unowned legacy sessions leave both copies once a second copy exists`() = runBlocking {
+        val copyA = "https://a.example/book"
+        val copyB = "https://b.example/book"
+        // 走 saveReadSession,让汇总记录(作品维度)与会话一起建立
+        repository.saveReadSession(session(bookUrl = "", start = 1_000, end = 1_200))
+        database.bookDao.insert(Book(bookUrl = copyA, name = targetName, author = author))
+
+        // 作品只有一个副本时,未归属的历史时长算在它头上,避免升级后凭空消失
+        assertEquals(200L, repository.getBookCopyReadTime(copyA, targetName, author).first())
+
+        // 共存出第二个副本后,谁都不再显示,避免同一段历史在两个副本里各出现一次
+        database.bookDao.insert(Book(bookUrl = copyB, name = targetName, author = author))
+        assertEquals(0L, repository.getBookCopyReadTime(copyA, targetName, author).first())
+        assertEquals(0L, repository.getBookCopyReadTime(copyB, targetName, author).first())
+        // 作品维度始终保留这段历史
+        assertEquals(200L, repository.getBookReadTime(targetName, author).first())
+    }
+
+    @Test
+    fun `migrating a copy moves its sessions without doubling the total`() = runBlocking {
+        val oldBook = Book(bookUrl = "https://old.example/book", name = targetName, author = author)
+        val newBook = Book(bookUrl = "https://new.example/book", name = targetName, author = author)
+        database.readRecordDao.insertSession(
+            session(
+                bookUrl = oldBook.bookUrl,
+                start = 1_000,
+                end = 1_200
+            )
+        )
+        database.readRecordDao.insert(ReadRecord(deviceId, targetName, author, 200, 1_200))
+
+        database.withTransaction { repository.reassignBookReadSessions(oldBook, newBook) }
+
+        assertEquals(
+            200L,
+            repository.getBookCopyReadTime(newBook.bookUrl, targetName, author).first()
+        )
+        assertEquals(
+            0L,
+            repository.getBookCopyReadTime(oldBook.bookUrl, targetName, author).first()
+        )
+        assertEquals(
+            200L,
+            database.readRecordDao.getReadRecord(deviceId, targetName, author)?.readTime
+        )
+    }
+
+    @Test
+    fun `migrating to a renamed book moves the total instead of keeping an orphan`() = runBlocking {
+        val oldBook = Book(bookUrl = "https://old.example/book", name = targetName, author = author)
+        val newBook = Book(bookUrl = "https://new.example/book", name = sourceName, author = author)
+        val date = "1970-01-01"
+        database.readRecordDao.insertSession(
+            session(
+                bookUrl = oldBook.bookUrl,
+                start = 1_000,
+                end = 1_200
+            )
+        )
+        database.readRecordDao.insert(ReadRecord(deviceId, targetName, author, 200, 1_200))
+        database.readRecordDao.insertDetail(
+            ReadRecordDetail(deviceId, targetName, author, date, 200, 10, 1_000, 1_200)
+        )
+
+        database.withTransaction { repository.reassignBookReadSessions(oldBook, newBook) }
+
+        assertEquals(
+            200L,
+            repository.getBookCopyReadTime(newBook.bookUrl, sourceName, author).first()
+        )
+        assertNull(database.readRecordDao.getReadRecord(deviceId, targetName, author))
+        assertNull(database.readRecordDao.getDetail(deviceId, targetName, author, date))
+        assertEquals(
+            200L,
+            database.readRecordDao.getReadRecord(deviceId, sourceName, author)?.readTime
+        )
+        assertEquals(
+            200L,
+            database.readRecordDao.getDetail(deviceId, sourceName, author, date)?.readTime
+        )
+    }
+
+    private fun session(
+        bookUrl: String,
+        start: Long,
+        end: Long,
+    ) = ReadRecordSession(
+        deviceId = deviceId,
+        bookName = targetName,
+        bookAuthor = author,
+        bookUrl = bookUrl,
+        startTime = start,
+        endTime = end,
+        words = 10,
+    )
+
     private suspend fun mergeAndAssert(targetSessionDuration: Long = 0, sourceSessionDuration: Long = 0, targetLegacyTime: Long = 0, sourceLegacyTime: Long = 0) {
         val source = insertRecord(sourceName, sourceSessionDuration + sourceLegacyTime, sourceSessionDuration)
         insertRecord(targetName, targetSessionDuration + targetLegacyTime, targetSessionDuration)

+ 133 - 0
app/src/test/java/io/legado/app/domain/usecase/FindBookshelfConflictUseCaseTest.kt

@@ -0,0 +1,133 @@
+package io.legado.app.domain.usecase
+
+import android.app.Application
+import androidx.room.Room
+import io.legado.app.constant.BookType
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.help.config.AppConfigStore
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class FindBookshelfConflictUseCaseTest {
+
+    private lateinit var database: AppDatabase
+    private lateinit var useCase: FindBookshelfConflictUseCase
+
+    @Before
+    fun setUp() {
+        AppConfigStore.init(RuntimeEnvironment.getApplication())
+        database = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(),
+            AppDatabase::class.java
+        )
+            .allowMainThreadQueries().build()
+        useCase = FindBookshelfConflictUseCase(
+            BookRepository(
+                database.bookDao,
+                database.bookChapterDao,
+                database
+            )
+        )
+    }
+
+    @After
+    fun tearDown() = database.close()
+
+    @Test
+    fun `same name and author is reported as conflict`() = runBlocking {
+        insertShelfBook("https://old.example/book", "斗破苍穹", "天蚕土豆")
+        val incoming =
+            Book(bookUrl = "https://new.example/book", name = "斗破苍穹", author = "天蚕土豆")
+
+        val conflict = useCase.execute(incoming)
+
+        assertEquals(
+            listOf("https://old.example/book"),
+            conflict?.candidates?.map { it.bookUrl },
+        )
+    }
+
+    @Test
+    fun `name is matched after trimming and folding whitespace`() = runBlocking {
+        insertShelfBook("https://old.example/book", "斗破  苍穹 ", "天蚕土豆")
+        val incoming = Book(
+            bookUrl = "https://new.example/book",
+            name = "斗破 苍穹",
+            author = "天蚕土豆",
+        )
+
+        val conflict = useCase.execute(incoming)
+
+        assertEquals(1, conflict?.candidates?.size)
+    }
+
+    @Test
+    fun `blank author on either side is still a conflict`() = runBlocking {
+        insertShelfBook("https://old.example/book", "斗破苍穹", "")
+        val incoming =
+            Book(bookUrl = "https://new.example/book", name = "斗破苍穹", author = "天蚕土豆")
+
+        val conflict = useCase.execute(incoming)
+
+        assertEquals(1, conflict?.candidates?.size)
+    }
+
+    @Test
+    fun `different author is not a conflict`() = runBlocking {
+        insertShelfBook("https://old.example/book", "斗破苍穹", "天蚕土豆")
+        val incoming =
+            Book(bookUrl = "https://new.example/book", name = "斗破苍穹", author = "其他人")
+
+        assertNull(useCase.execute(incoming))
+    }
+
+    @Test
+    fun `different name is not a conflict`() = runBlocking {
+        insertShelfBook("https://old.example/book", "斗破苍穹", "天蚕土豆")
+        val incoming =
+            Book(bookUrl = "https://new.example/book", name = "武动乾坤", author = "天蚕土豆")
+
+        assertNull(useCase.execute(incoming))
+    }
+
+    @Test
+    fun `the book being added is never its own conflict`() = runBlocking {
+        insertShelfBook("https://old.example/book", "斗破苍穹", "天蚕土豆")
+        val incoming =
+            Book(bookUrl = "https://old.example/book", name = "斗破苍穹", author = "天蚕土豆")
+
+        assertNull(useCase.execute(incoming))
+    }
+
+    @Test
+    fun `removed from shelf books are ignored`() = runBlocking {
+        database.bookDao.insert(
+            Book(
+                bookUrl = "https://preview.example/book",
+                name = "斗破苍穹",
+                author = "天蚕土豆",
+                type = BookType.notShelf,
+            )
+        )
+        val incoming =
+            Book(bookUrl = "https://new.example/book", name = "斗破苍穹", author = "天蚕土豆")
+
+        assertNull(useCase.execute(incoming))
+    }
+
+    private suspend fun insertShelfBook(bookUrl: String, name: String, author: String) {
+        database.bookDao.insert(Book(bookUrl = bookUrl, name = name, author = author))
+    }
+}

+ 197 - 0
app/src/test/java/io/legado/app/domain/usecase/ResolveBookshelfConflictUseCaseTest.kt

@@ -0,0 +1,197 @@
+package io.legado.app.domain.usecase
+
+import android.app.Application
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.ReadRecordRepository
+import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.ReadSettings
+import io.legado.app.help.config.AppConfigStore
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ResolveBookshelfConflictUseCaseTest {
+
+    private lateinit var database: AppDatabase
+    private lateinit var readRecordRepository: ReadRecordRepository
+    private lateinit var bookRepository: BookRepository
+    private lateinit var useCase: ResolveBookshelfConflictUseCase
+
+    @Before
+    fun setUp() {
+        AppConfigStore.init(RuntimeEnvironment.getApplication())
+        database = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(),
+            AppDatabase::class.java
+        )
+            .allowMainThreadQueries().build()
+        readRecordRepository =
+            ReadRecordRepository(database.readRecordDao, database, SettingsRepository())
+        bookRepository = BookRepository(database.bookDao, database.bookChapterDao, database)
+        useCase = ResolveBookshelfConflictUseCase(
+            bookRepository = bookRepository,
+            bookSourceRepository = BookSourceRepository(database.bookSourceDao),
+            changeBookSourceUseCase = ChangeBookSourceUseCase(
+                database = database,
+                bookDao = database.bookDao,
+                bookChapterDao = database.bookChapterDao,
+                otherSettingsGateway = StubOtherSettingsGateway(),
+                readSettingsGateway = StubReadSettingsGateway(),
+                readRecordRepository = readRecordRepository,
+            ),
+        )
+    }
+
+    @After
+    fun tearDown() = database.close()
+
+    @Test
+    fun `coexist copies only the selected fields to the new book`() = runBlocking {
+        insertShelfBook(
+            bookUrl = existingUrl,
+            group = 7L,
+            customCoverUrl = "cover-a",
+            customTag = "tag-a",
+            remark = "remark-a",
+        )
+        val newBook = Book(bookUrl = newUrl, name = name, author = author)
+
+        useCase.coexist(
+            existingBookUrl = existingUrl,
+            newBook = newBook,
+            options = ChangeSourceMigrationOptions(
+                migrateGroup = true,
+                migrateCover = true,
+                migrateCategory = false,
+                migrateRemark = false,
+                migrateReadingProgress = false,
+                migrateReadConfig = false,
+            ),
+            chapters = emptyList(),
+        )
+
+        val stored = bookRepository.getBook(newUrl)
+        assertNotNull(stored)
+        assertEquals(7L, stored?.group)
+        assertEquals("cover-a", stored?.customCoverUrl)
+        // 未勾选的项必须保持新书自己的值,不能顺手搬过来。
+        assertNull(stored?.customTag)
+        assertNull(stored?.remark)
+    }
+
+    @Test
+    fun `coexist never carries reading time over to the new copy`() = runBlocking {
+        insertShelfBook(bookUrl = existingUrl)
+        // 书架已有作品读了 200ms
+        readRecordRepository.saveReadSession(
+            ReadRecordSession(
+                bookName = name,
+                bookAuthor = author,
+                bookUrl = existingUrl,
+                startTime = 1_000,
+                endTime = 1_200,
+                words = 10,
+            )
+        )
+
+        useCase.coexist(
+            existingBookUrl = existingUrl,
+            newBook = Book(bookUrl = newUrl, name = name, author = author),
+            options = ChangeSourceMigrationOptions(),
+            chapters = emptyList(),
+        )
+
+        assertEquals(
+            200L,
+            readRecordRepository.getBookCopyReadTime(existingUrl, name, author).first()
+        )
+        assertEquals(0L, readRecordRepository.getBookCopyReadTime(newUrl, name, author).first())
+        // 作品维度仍是 200,没有因为多一个副本而翻倍。
+        assertEquals(200L, readRecordRepository.getBookReadTime(name, author).first())
+    }
+
+    @Test
+    fun `migrate without a toc cancels instead of wiping the existing book`() = runBlocking {
+        insertShelfBook(bookUrl = existingUrl, durChapterIndex = 5, durChapterTitle = "第五章")
+        val newBook = Book(bookUrl = newUrl, name = name, author = author)
+
+        // 书源不存在 -> 目录取不到,迁移必须中止
+        val error = runCatching {
+            useCase.migrate(
+                existingBookUrl = existingUrl,
+                newBook = newBook,
+                options = ChangeSourceMigrationOptions(),
+            )
+        }.exceptionOrNull()
+        assertTrue("目录缺失时迁移必须中止", error is BookTocUnavailableException)
+
+        // 旧书与它的进度必须原封不动,新书也不能入库。
+        assertEquals(5, bookRepository.getBook(existingUrl)?.durChapterIndex)
+        assertEquals("第五章", bookRepository.getBook(existingUrl)?.durChapterTitle)
+        assertNull(bookRepository.getBook(newUrl))
+    }
+
+    private suspend fun insertShelfBook(
+        bookUrl: String,
+        group: Long = 0L,
+        customCoverUrl: String? = null,
+        customTag: String? = null,
+        remark: String? = null,
+        durChapterIndex: Int = 0,
+        durChapterTitle: String? = null,
+    ) {
+        database.bookDao.insert(
+            Book(
+                bookUrl = bookUrl,
+                name = name,
+                author = author,
+                group = group,
+                customCoverUrl = customCoverUrl,
+                customTag = customTag,
+                remark = remark,
+                durChapterIndex = durChapterIndex,
+                durChapterTitle = durChapterTitle,
+            )
+        )
+    }
+
+    private class StubOtherSettingsGateway : OtherSettingsGateway {
+        override val currentSettings: OtherSettings = OtherSettings()
+        override val settings = MutableStateFlow(OtherSettings())
+        override suspend fun update(transform: (OtherSettings) -> OtherSettings) = Unit
+    }
+
+    private class StubReadSettingsGateway : ReadSettingsGateway {
+        override val currentSettings: ReadSettings = ReadSettings()
+        override val settings = MutableStateFlow(ReadSettings())
+        override suspend fun update(transform: (ReadSettings) -> ReadSettings) = Unit
+    }
+
+    private companion object {
+        const val name = "斗破苍穹"
+        const val author = "天蚕土豆"
+        const val existingUrl = "https://old.example/book"
+        const val newUrl = "https://new.example/book"
+    }
+}