Просмотр исходного кода

fix(read-record): 修复备份导入重复记录并完善未知作者归属 (#2076)

* feat(readRecord): 新增阅读记录跨设备聚合与归属修复功能

- 数据库迁移至v103,升级时合并旧设备分区阅读记录到本地,归并空作者记录
- 避免多设备记录合并时取最大值造成累计时长与字数丢失
- 新增阅读记录归属冲突确认弹窗,支持记住用户选择与撤销已保存决定
- 新增阅读记录扫描与修复功能,可合并可确定归属的记录并清理重复时段记录
- 阅读记录汇总、详情、时间线实现跨设备聚合去重,避免同步副本重复统计
- 补充多语言字符串与领域拆分边界测试用例

* fix(read-record): 恢复幂等、尊重保留决定、移除无用字段

- 备份恢复的汇总/明细改取较大值而非相加,避免同一备份重复导入时长翻倍
- 导入后按会话表重建汇总与每日明细,正确合并跨设备会话时长且保持幂等
- saveReadSession 自动合并未知作者记录时尊重用户已记住的「保留独立记录」决定
- 移除从未赋值的 ReadRecordRepairReport.skippedCount,并更新修复结果文案

---------

Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
懒得起名字 2 месяцев назад
Родитель
Сommit
7355629d6b
25 измененных файлов с 5836 добавлено и 174 удалено
  1. 4782 0
      app/schemas/io.legado.app.data.AppDatabase/103.json
  2. 1 1
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  3. 115 0
      app/src/main/java/io/legado/app/data/DatabaseMigrations.kt
  4. 51 10
      app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt
  5. 33 0
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordAliasDecision.kt
  6. 19 0
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordIdentity.kt
  7. 13 0
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordRepairReport.kt
  8. 6 1
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt
  9. 1 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  10. 342 76
      app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt
  11. 1 0
      app/src/main/java/io/legado/app/di/appModule.kt
  12. 81 41
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  13. 8 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  14. 3 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt
  15. 37 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  16. 20 20
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  17. 99 0
      app/src/main/java/io/legado/app/ui/book/read/ReadRecordAliasDelegate.kt
  18. 56 5
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
  19. 63 12
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt
  20. 15 2
      app/src/main/res/values-zh-rCN/strings.xml
  21. 15 2
      app/src/main/res/values-zh-rHK/strings.xml
  22. 15 2
      app/src/main/res/values-zh-rTW/strings.xml
  23. 15 2
      app/src/main/res/values/strings.xml
  24. 39 0
      app/src/test/java/io/legado/app/data/entities/readRecord/ReadRecordIdentityTest.kt
  25. 6 0
      app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

+ 4782 - 0
app/schemas/io.legado.app.data.AppDatabase/103.json

@@ -0,0 +1,4782 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 103,
+    "identityHash": "1d8ba5302de7a5122f4ceca46ee69c5d",
+    "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 '', `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": "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, `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": "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, 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
+          }
+        ],
+        "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, '1d8ba5302de7a5122f4ceca46ee69c5d')"
+    ]
+  }
+}

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

@@ -112,7 +112,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 102,
+    version = 103,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,

+ 115 - 0
app/src/main/java/io/legado/app/data/DatabaseMigrations.kt

@@ -21,6 +21,7 @@ object DatabaseMigrations {
             migration_35_36, migration_36_37, migration_37_38, migration_38_39,
             migration_39_40, migration_40_41, migration_41_42, migration_42_43,
             migration_82_83, migration_98_99, migration_99_100,
+            migration_102_103,
         )
     }
 
@@ -551,4 +552,118 @@ object DatabaseMigrations {
             database.execSQL("ALTER TABLE highlightRules ADD COLUMN fontSizeOffset INTEGER NOT NULL DEFAULT 0")
         }
     }
+
+    /**
+     * 新版阅读器统一使用空 deviceId。旧版本数据库中的记录可能仍带有 Android ID,
+     * 需要在覆盖升级时归并到本地分区,否则升级后继续阅读会产生两条记录。
+     * 作者为空且书架中只有一个作者时使用该作者,否则继续保留空作者;冲突行按 SQL
+     * 中的聚合规则合并,以保证主键唯一。
+     */
+    private val migration_102_103 = object : Migration(102, 103) {
+        override fun migrate(database: SupportSQLiteDatabase) {
+            // 将旧设备分区合并到本地分区;同一本书的旧空作者仅在书架存在唯一作者时归并。
+            // 阅读时段记录按书名、作者和完整时间区间聚合,字段重复的记录只保留一条并取较大的字数。
+            database.execSQL(
+                """
+                CREATE TABLE readRecord_migrated (
+                    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)
+                )
+                """.trimIndent()
+            )
+            database.execSQL(
+                """
+                INSERT INTO readRecord_migrated(deviceId, bookName, bookAuthor, readTime, lastRead)
+                SELECT '', bookName, canonicalAuthor, SUM(readTime), MAX(lastRead)
+                FROM (
+                    SELECT rr.bookName, rr.readTime, rr.lastRead,
+                        CASE WHEN rr.bookAuthor <> '' THEN rr.bookAuthor ELSE COALESCE((
+                            SELECT CASE WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author) ELSE '' END
+                            FROM books b WHERE b.name = rr.bookName
+                        ), '') END AS canonicalAuthor
+                    FROM readRecord rr
+                )
+                GROUP BY bookName, canonicalAuthor
+                """.trimIndent()
+            )
+            database.execSQL("DROP TABLE readRecord")
+            database.execSQL("ALTER TABLE readRecord_migrated RENAME TO readRecord")
+
+            database.execSQL(
+                """
+                CREATE TABLE readRecordDetail_migrated (
+                    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)
+                )
+                """.trimIndent()
+            )
+            database.execSQL(
+                """
+                INSERT INTO readRecordDetail_migrated(
+                    deviceId, bookName, bookAuthor, date, readTime, readWords,
+                    firstReadTime, lastReadTime
+                )
+                SELECT '', bookName, canonicalAuthor, date, SUM(readTime), SUM(readWords),
+                    COALESCE(MIN(CASE WHEN firstReadTime > 0 THEN firstReadTime ELSE NULL END), 0),
+                    MAX(lastReadTime)
+                FROM (
+                    SELECT rd.bookName, rd.date, rd.readTime, rd.readWords,
+                        rd.firstReadTime, rd.lastReadTime,
+                        CASE WHEN rd.bookAuthor <> '' THEN rd.bookAuthor ELSE COALESCE((
+                            SELECT CASE WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author) ELSE '' END
+                            FROM books b WHERE b.name = rd.bookName
+                        ), '') END AS canonicalAuthor
+                    FROM readRecordDetail rd
+                )
+                GROUP BY bookName, canonicalAuthor, date
+                """.trimIndent()
+            )
+            database.execSQL("DROP TABLE readRecordDetail")
+            database.execSQL("ALTER TABLE readRecordDetail_migrated RENAME TO readRecordDetail")
+
+            database.execSQL(
+                """
+                CREATE TABLE readRecordSession_migrated (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+                    deviceId TEXT NOT NULL,
+                    bookName TEXT NOT NULL,
+                    bookAuthor TEXT NOT NULL DEFAULT '',
+                    startTime INTEGER NOT NULL,
+                    endTime INTEGER NOT NULL,
+                    words INTEGER NOT NULL
+                )
+                """.trimIndent()
+            )
+            database.execSQL(
+                """
+                INSERT INTO readRecordSession_migrated(
+                    deviceId, bookName, bookAuthor, startTime, endTime, words
+                )
+                SELECT '', bookName, canonicalAuthor, startTime, endTime, MAX(words)
+                FROM (
+                    SELECT rs.bookName, rs.startTime, rs.endTime, rs.words,
+                        CASE WHEN rs.bookAuthor <> '' THEN rs.bookAuthor ELSE COALESCE((
+                            SELECT CASE WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author) ELSE '' END
+                            FROM books b WHERE b.name = rs.bookName
+                        ), '') END AS canonicalAuthor
+                    FROM readRecordSession rs
+                )
+                GROUP BY bookName, canonicalAuthor, startTime, endTime
+                """.trimIndent()
+            )
+            database.execSQL("DROP TABLE readRecordSession")
+            database.execSQL("ALTER TABLE readRecordSession_migrated RENAME TO readRecordSession")
+        }
+    }
 }

+ 51 - 10
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -130,21 +130,25 @@ interface ReadRecordDao {
     @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
     fun deleteDetailByName(bookName: String, bookAuthor: String)
 
+    /** 删除指定书籍指定日期的统计详情。 */
+    @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName AND bookAuthor = :bookAuthor AND date = :date")
+    suspend fun deleteDetailByNameAndDate(bookName: String, bookAuthor: String, date: String)
+
     /**
-     * 获取指定书籍的最后一条阅读会话
+     * 获取指定书籍的最后一条阅读时段记录
      * 用于判断是否可以合并
      */
     @Query("SELECT * FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY endTime DESC LIMIT 1")
     suspend fun getLatestSessionByBook(bookName: String, bookAuthor: String): ReadRecordSession?
 
     /**
-     * 更新现有的会话
+     * 更新现有的阅读时段记录
      */
     @Update
     suspend fun updateSession(session: ReadRecordSession)
 
     /**
-     * 插入阅读会话记录。
+     * 插入阅读时段记录。
      */
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     fun insertSession(session: ReadRecordSession)
@@ -167,7 +171,8 @@ interface ReadRecordDao {
     @Query(
         """
         SELECT * FROM readRecord
-        WHERE NOT (deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor)
+        WHERE deviceId = :deviceId
+        AND NOT (bookName = :bookName AND bookAuthor = :bookAuthor)
         ORDER BY
             CASE WHEN bookName = :bookName THEN 0 ELSE 1 END,
             lastRead DESC
@@ -180,7 +185,7 @@ interface ReadRecordDao {
     ): List<ReadRecord>
 
     /**
-     * 获取某一天某一本书的所有会话记录
+     * 获取某一天某一本书的所有阅读时段记录
      */
     @Query("""
         SELECT * FROM readRecordSession 
@@ -198,7 +203,7 @@ interface ReadRecordDao {
     ): List<ReadRecordSession>
 
     /**
-     * 获取某一天所有书籍的会话记录
+     * 获取某一天所有书籍的阅读时段记录
      */
     @Query("""
     SELECT * FROM readRecordSession 
@@ -211,10 +216,28 @@ interface ReadRecordDao {
     @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND (bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%')")
     suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
 
-    // 清除会话记录
+    // 清除阅读时段记录
     @Query("DELETE FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
     fun deleteSessionByName(bookName: String, bookAuthor: String)
 
+    /** 按阅读时段内容删除记录,用于跨设备删除同一阅读时段的同步副本。 */
+    @Query("DELETE FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor AND startTime = :startTime AND endTime = :endTime AND words = :words")
+    suspend fun deleteSessionByIdentity(
+        bookName: String,
+        bookAuthor: String,
+        startTime: Long,
+        endTime: Long,
+        words: Long,
+    )
+
+    /** 获取所有设备中指定书名和作者的汇总记录。 */
+    @Query("SELECT * FROM readRecord WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
+    suspend fun getReadRecordsByName(bookName: String, bookAuthor: String): List<ReadRecord>
+
+    /** 获取所有设备中指定书名但作者为空的旧记录。 */
+    @Query("SELECT * FROM readRecord WHERE bookName = :bookName AND bookAuthor = ''")
+    suspend fun getUnknownAuthorRecords(bookName: String): List<ReadRecord>
+
     @Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
     fun getAllDetails(): Flow<List<ReadRecordDetail>>
 
@@ -224,9 +247,25 @@ interface ReadRecordDao {
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
     fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
 
+    /** 获取所有设备的阅读时段记录,供跨设备时间线聚合使用。 */
+    @Query("SELECT * FROM readRecordSession ORDER BY startTime ASC")
+    fun getAllSessions(): Flow<List<ReadRecordSession>>
+
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
     suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordSession>
 
+    /** 清理同步产生的、字段完全相同的阅读时段记录,只保留最早写入的一行。 */
+    @Query(
+        """
+        DELETE FROM readRecordSession
+        WHERE id NOT IN (
+            SELECT MIN(id) FROM readRecordSession
+            GROUP BY deviceId, bookName, bookAuthor, startTime, endTime, words
+        )
+        """
+    )
+    suspend fun deleteDuplicateSessions()
+
     @Query(
         """
         SELECT * FROM readRecordSession
@@ -235,6 +274,7 @@ interface ReadRecordDao {
         AND bookAuthor = :bookAuthor
         AND startTime = :startTime
         AND endTime = :endTime
+        AND words = :words
         LIMIT 1
         """
     )
@@ -243,14 +283,15 @@ interface ReadRecordDao {
         bookName: String,
         bookAuthor: String,
         startTime: Long,
-        endTime: Long
+        endTime: Long,
+        words: Long
     ): ReadRecordSession?
 
     @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>>
 
-    @Query("SELECT readTime FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
-    fun getReadTimeFlow(deviceId: String, bookName: String, bookAuthor: String): Flow<Long?>
+    @Query("SELECT SUM(readTime) FROM readRecord WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
+    fun getReadTimeFlow(bookName: String, bookAuthor: String): Flow<Long?>
 
     @Delete
     suspend fun deleteDetail(detail: ReadRecordDetail)

+ 33 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordAliasDecision.kt

@@ -0,0 +1,33 @@
+package io.legado.app.data.entities.readRecord
+
+/** 未知作者记录的归属处理方式。 */
+enum class ReadRecordAliasAction { MERGE, KEEP }
+
+/** 负责作者归属决定的规范化、编码、解析和撤销。 */
+object ReadRecordAliasDecision {
+    private const val SEPARATOR = "\u0001"
+
+    /** 生成用于持久化用户选择的规范化书名/作者键。 */
+    fun key(bookName: String, author: String): String =
+        ReadRecordIdentity.key(bookName, author)
+
+    /** 将一个归属决定编码为单行文本,格式为 key、分隔符和 merge/keep。 */
+    fun encode(key: String, action: ReadRecordAliasAction): String =
+        "$key$SEPARATOR${action.name.lowercase()}"
+
+    /** 解析指定键的决定;键不匹配或内容非法时返回 null。 */
+    fun decode(line: String, key: String): ReadRecordAliasAction? {
+        if (!line.startsWith("$key$SEPARATOR")) return null
+        return when (line.substringAfter(SEPARATOR)) {
+            "merge" -> ReadRecordAliasAction.MERGE
+            "keep" -> ReadRecordAliasAction.KEEP
+            else -> null
+        }
+    }
+
+    /** 从多行持久化内容中删除指定键的旧决定。 */
+    fun removeForKey(lines: String, key: String): String = lines
+        .lineSequence()
+        .filter { it.isNotBlank() && !it.startsWith("$key$SEPARATOR") }
+        .joinToString("\n")
+}

+ 19 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordIdentity.kt

@@ -0,0 +1,19 @@
+package io.legado.app.data.entities.readRecord
+
+/** 阅读记录聚合与恢复共同使用的规范化身份工具。 */
+object ReadRecordIdentity {
+    /** 规范化书名:折叠连续空白并去除首尾空白。 */
+    fun bookName(value: String): String = normalize(value)
+
+    /** 空字符串表示确实未知作者,非空字符串表示真实作者。 */
+    fun author(value: String): String = normalize(value)
+
+    /** 生成跨恢复、合并流程复用的稳定身份键。 */
+    fun key(bookName: String, author: String): String =
+        "${bookName(bookName)}\u0000${author(author)}"
+
+    private fun normalize(value: String): String =
+        value.replace(WHITESPACE, " ").trim()
+
+    private val WHITESPACE = Regex("[\\p{Z}\\s]+")
+}

+ 13 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordRepairReport.kt

@@ -0,0 +1,13 @@
+package io.legado.app.data.entities.readRecord
+
+/** 阅读记录问题扫描或修复后的汇总结果。 */
+data class ReadRecordRepairReport(
+    /** 扫描时为发现的可合并重复项数量,修复时为实际合并的记录数量。 */
+    val mergedCount: Int = 0,
+    /** 处理过程中发生异常的记录数量。 */
+    val exceptionCount: Int = 0,
+    /** 扫描时为发现的字段重复阅读时段记录数,修复时为实际删除的重复记录数。 */
+    val duplicateSessionCount: Int = 0,
+    /** 被规范化书名或作者字段的记录数量。 */
+    val normalizedRecordCount: Int = 0,
+)

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

@@ -20,4 +20,9 @@ data class ReadRecordSession(
 
     // 本次阅读的字数
     val words: Long = 0
-)
+) {
+    /** 跨设备稳定身份,不依赖 Room 自动生成的数据库行 ID。 */
+    val stableFingerprint: String
+        get() = listOf(deviceId, bookName, bookAuthor, startTime, endTime, words)
+            .joinToString("\u0001")
+}

+ 1 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -35,6 +35,7 @@ object LocalPreferencesKeys {
     val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
     val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
     val READ_RECORD_DISPLAY_MODE = stringPreferencesKey("readRecordDisplayMode")
+    val READ_RECORD_ALIAS_DECISIONS = stringPreferencesKey("readRecordAliasDecisions")
     val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")
     val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_cover_album_id")
     val SELECTED_LIGHT_COVER_ALBUM_ID = stringPreferencesKey("selected_light_cover_album_id")

+ 342 - 76
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -6,9 +6,13 @@ import cn.hutool.core.date.DateUtil
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.dao.ReadRecordDao
 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.local.preferences.LocalPreferencesKeys
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
@@ -39,7 +43,8 @@ class ReadRecordRepository(
     }
 
     /**
-     * 根据搜索关键字获取最新的阅读书籍列表流
+     * 根据搜索关键字获取跨设备聚合后的最新阅读书籍列表流。
+     * 同一本书的各设备记录会合并阅读时长,并保留最新阅读时间。
      */
     fun getLatestReadRecords(query: String = ""): Flow<List<ReadRecord>> {
         return if (query.isBlank()) {
@@ -47,10 +52,23 @@ class ReadRecordRepository(
         } else {
             dao.searchReadRecordsByLastRead(query)
         }
+            .map { records ->
+                records.groupBy { it.bookName to it.bookAuthor }
+                    .values
+                    .map { sameBook ->
+                        sameBook.first().copy(
+                            deviceId = "",
+                            readTime = sameBook.sumOf { it.readTime },
+                            lastRead = sameBook.maxOf { it.lastRead },
+                        )
+                    }
+                    .sortedByDescending { it.lastRead }
+            }
     }
 
     /**
-     * 获取所有的每日统计详情流
+     * 获取跨设备聚合后的每日统计详情流。
+     * 同一本书同一天的各设备详情会合并阅读时长和字数。
      */
     fun getAllRecordDetails(query: String = ""): Flow<List<ReadRecordDetail>> {
         return if (query.isBlank()) {
@@ -58,14 +76,41 @@ class ReadRecordRepository(
         } else {
             dao.searchDetails(query)
         }
+            .map { details ->
+                details.groupBy { Triple(it.bookName, it.bookAuthor, it.date) }
+                    .values
+                    .map { sameDay ->
+                        sameDay.first().copy(
+                            deviceId = "",
+                            readTime = sameDay.sumOf { it.readTime },
+                            readWords = sameDay.sumOf { it.readWords },
+                            firstReadTime = sameDay.map { it.firstReadTime }
+                                .filter { it > 0L }
+                                .minOrNull() ?: 0L,
+                            lastReadTime = sameDay.maxOf { it.lastReadTime },
+                        )
+                    }
+                    .sortedWith(compareByDescending<ReadRecordDetail> { it.date }.thenByDescending { it.lastReadTime })
+            }
     }
 
     fun getAllSessions(): Flow<List<ReadRecordSession>> {
-        return dao.getAllSessions(getCurrentDeviceId())
+        // UI 展示的是跨设备合并后的时间线;去重键不包含自增 id,避免同步副本重复计时。
+        return dao.getAllSessions().map { sessions ->
+            sessions.distinctBy {
+                listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words)
+            }
+        }
     }
 
     fun getBookSessions(bookName: String, bookAuthor: String): Flow<List<ReadRecordSession>> {
-        return dao.getSessionsByBookFlow(getCurrentDeviceId(), bookName, bookAuthor)
+        // 时间线按书名、作者和阅读时段内容去重,避免同步副本重复计时。
+        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) }
+                .toList()
+        }
     }
 
     fun getBookTimelineDays(bookName: String, bookAuthor: String): Flow<List<ReadRecordTimelineDay>> {
@@ -82,7 +127,8 @@ class ReadRecordRepository(
     }
 
     fun getBookReadTime(bookName: String, bookAuthor: String): Flow<Long> {
-        return dao.getReadTimeFlow(getCurrentDeviceId(), bookName, bookAuthor).map { it ?: 0L }
+        // 统计所有设备的汇总时长,与跨设备时间线保持一致。
+        return dao.getReadTimeFlow(bookName, bookAuthor).map { it ?: 0L }
     }
 
     suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
@@ -93,31 +139,74 @@ class ReadRecordRepository(
         )
     }
 
+    /** 获取指定书名下作者为空的旧记录,供打开书籍时确认归属。 */
+    suspend fun getUnknownAuthorRecords(bookName: String): List<ReadRecord> {
+        return dao.getUnknownAuthorRecords(bookName)
+    }
+
     /**
-     * 保存一个完整的阅读会话.
+     * 保存一个完整的阅读时段记录.
      */
     suspend fun saveReadSession(newSession: ReadRecordSession) {
         if (!readRecordEnabled.first()) return
         if (newSession.endTime <= newSession.startTime) return
+        val normalizedSession = newSession.copy(
+            bookName = ReadRecordIdentity.bookName(newSession.bookName),
+            bookAuthor = ReadRecordIdentity.author(newSession.bookAuthor),
+        )
         database.withTransaction {
+            // 旧版记录可能没有作者;打开同一本有作者的书后,将未知作者记录并入当前记录,
+            // 避免第一次阅读时重新创建一条独立的阅读统计。
+            // 用户曾明确选择「保留独立记录」时尊重该决定,不自动合并。
+            if (normalizedSession.bookAuthor.isNotBlank() &&
+                !hasKeepAliasDecision(normalizedSession.bookName, normalizedSession.bookAuthor)
+            ) {
+                val unknownAuthorRecord = dao.getReadRecord(
+                    normalizedSession.deviceId,
+                    normalizedSession.bookName,
+                    ""
+                )
+                if (unknownAuthorRecord != null) {
+                    mergeSingleReadRecordInto(
+                        targetRecord = ReadRecord(
+                            deviceId = normalizedSession.deviceId,
+                            bookName = normalizedSession.bookName,
+                            bookAuthor = normalizedSession.bookAuthor
+                        ),
+                        sourceRecord = unknownAuthorRecord
+                    )
+                }
+            }
             val existingSession = dao.getSession(
-                newSession.deviceId,
-                newSession.bookName,
-                newSession.bookAuthor,
-                newSession.startTime,
-                newSession.endTime
+                normalizedSession.deviceId,
+                normalizedSession.bookName,
+                normalizedSession.bookAuthor,
+                normalizedSession.startTime,
+                normalizedSession.endTime,
+                normalizedSession.words
             )
             if (existingSession != null) return@withTransaction
 
-            val segmentDuration = newSession.endTime - newSession.startTime
-            dao.insertSession(newSession)
+            val segmentDuration = normalizedSession.endTime - normalizedSession.startTime
+            dao.insertSession(normalizedSession)
             val dateString =
-                DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN)
-            updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString)
-            updateReadRecord(newSession, segmentDuration)
+                DateUtil.format(Date(normalizedSession.startTime), DatePattern.NORM_DATE_PATTERN)
+            updateReadRecordDetail(normalizedSession, segmentDuration, normalizedSession.words, dateString)
+            updateReadRecord(normalizedSession, segmentDuration)
         }
     }
 
+    /** 用户是否曾为当前书籍明确选择「保留独立记录」。 */
+    private suspend fun hasKeepAliasDecision(bookName: String, bookAuthor: String): Boolean {
+        val key = ReadRecordIdentity.key(bookName, bookAuthor)
+        return localPreferencesRepository
+            .getString(LocalPreferencesKeys.READ_RECORD_ALIAS_DECISIONS.name)
+            .first()
+            .split('\n')
+            .mapNotNull { ReadRecordAliasDecision.decode(it, key) }
+            .firstOrNull() == ReadRecordAliasAction.KEEP
+    }
+
     private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
         if (durationDelta <= 0) return
         val existingRecord = dao.getReadRecord(session.deviceId, session.bookName, session.bookAuthor)
@@ -157,7 +246,7 @@ class ReadRecordRepository(
         if (existingDetail != null) {
             existingDetail.readTime += durationDelta
             existingDetail.readWords += wordsDelta
-            existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime)
+            existingDetail.firstReadTime = minPositive(existingDetail.firstReadTime, session.startTime)
             existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
             dao.insertDetail(existingDetail)
         } else {
@@ -178,73 +267,95 @@ class ReadRecordRepository(
 
     suspend fun deleteDetail(detail: ReadRecordDetail) {
         database.withTransaction {
-            dao.deleteDetail(detail)
-            dao.deleteSessionsByBookAndDate(
-                detail.deviceId,
-                detail.bookName,
-                detail.bookAuthor,
-                detail.date
-            )
-            updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
+            // 聚合详情代表所有设备同一天的阅读,删除时必须同步删除底层阅读时段记录。
+            val affectedDevices = dao.allSession.asSequence()
+                .filter {
+                    it.bookName == detail.bookName &&
+                        it.bookAuthor == detail.bookAuthor &&
+                        DateUtil.format(Date(it.startTime), DatePattern.NORM_DATE_PATTERN) == detail.date
+                }
+                .mapTo(linkedSetOf()) { it.deviceId }
+                .apply { addAll(dao.getReadRecordsByName(detail.bookName, detail.bookAuthor).map { it.deviceId }) }
+            dao.deleteDetailByNameAndDate(detail.bookName, detail.bookAuthor, detail.date)
+            affectedDevices.forEach { deviceId ->
+                dao.deleteSessionsByBookAndDate(deviceId, detail.bookName, detail.bookAuthor, detail.date)
+                // 没有阅读时段记录的旧版汇总记录不能因删除详情而被误删。
+                if (dao.getSessionsByBook(deviceId, detail.bookName, detail.bookAuthor).isNotEmpty()) {
+                    updateReadRecordTotal(deviceId, detail.bookName, detail.bookAuthor)
+                }
+            }
         }
     }
 
     suspend fun deleteSession(session: ReadRecordSession) {
         database.withTransaction {
-            dao.deleteSession(session)
-
+            val affectedDevices = dao.allSession.asSequence()
+                .filter {
+                    it.bookName == session.bookName &&
+                        it.bookAuthor == session.bookAuthor &&
+                        it.startTime == session.startTime &&
+                        it.endTime == session.endTime &&
+                        it.words == session.words
+                }
+                .mapTo(linkedSetOf()) { it.deviceId }
+                .apply { addAll(dao.getReadRecordsByName(session.bookName, session.bookAuthor).map { it.deviceId }) }
+            dao.deleteSessionByIdentity(
+                session.bookName,
+                session.bookAuthor,
+                session.startTime,
+                session.endTime,
+                session.words,
+            )
             val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
-            val remainingSessions =
-                dao.getSessionsByBookAndDate(
-                    session.deviceId,
-                    session.bookName,
-                    session.bookAuthor,
-                    dateString
-                )
-
-            if (remainingSessions.isEmpty()) {
-                val detail = dao.getDetail(
-                    session.deviceId,
-                    session.bookName,
-                    session.bookAuthor,
-                    dateString
-                )
-                detail?.let { dao.deleteDetail(it) }
-            } else {
-                val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
-                val totalWords = remainingSessions.sumOf { it.words }
-                val firstRead = remainingSessions.minOf { it.startTime }
-                val lastRead = remainingSessions.maxOf { it.endTime }
-
-                val existingDetail = dao.getDetail(
-                    session.deviceId,
-                    session.bookName,
-                    session.bookAuthor,
-                    dateString
-                )
-                dao.insertDetail(
-                    existingDetail?.copy(
-                        readTime = totalTime,
-                        readWords = totalWords,
-                        firstReadTime = firstRead,
-                        lastReadTime = lastRead
-                    ) ?: ReadRecordDetail(
-                        deviceId = session.deviceId,
+            affectedDevices.forEach { deviceId ->
+                    val record = ReadRecord(
+                        deviceId = deviceId,
                         bookName = session.bookName,
                         bookAuthor = session.bookAuthor,
-                        date = dateString,
-                        readTime = totalTime,
-                        readWords = totalWords,
-                        firstReadTime = firstRead,
-                        lastReadTime = lastRead,
                     )
-                )
-            }
-
-            updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
+                    val remainingSessions = dao.getSessionsByBookAndDate(
+                        deviceId,
+                        record.bookName,
+                        record.bookAuthor,
+                        dateString,
+                    )
+                    val detail = dao.getDetail(
+                        deviceId,
+                        record.bookName,
+                        record.bookAuthor,
+                        dateString,
+                    )
+                    if (remainingSessions.isEmpty()) {
+                        detail?.let { dao.deleteDetail(it) }
+                    } else {
+                        dao.insertDetail(
+                            (detail ?: ReadRecordDetail(
+                                deviceId = record.deviceId,
+                                bookName = record.bookName,
+                                bookAuthor = record.bookAuthor,
+                                date = dateString,
+                            )).copy(
+                                readTime = remainingSessions.sumOf { it.endTime - it.startTime },
+                                readWords = remainingSessions.sumOf { it.words },
+                                firstReadTime = remainingSessions
+                                    .map { it.startTime }
+                                    .filter { it > 0L }
+                                    .minOrNull() ?: 0L,
+                                lastReadTime = remainingSessions.maxOf { it.endTime },
+                            )
+                        )
+                    }
+                    updateReadRecordTotal(deviceId, record.bookName, record.bookAuthor)
+                }
         }
     }
 
+    private fun minPositive(left: Long, right: Long): Long = when {
+        left <= 0L -> right
+        right <= 0L -> left
+        else -> min(left, right)
+    }
+
     private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
         val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
 
@@ -278,9 +389,9 @@ class ReadRecordRepository(
 
     suspend fun deleteReadRecord(record: ReadRecord) {
         database.withTransaction {
-            dao.deleteReadRecord(record)
-            dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
-            dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
+            dao.deleteByName(record.bookName, record.bookAuthor)
+            dao.deleteDetailByName(record.bookName, record.bookAuthor)
+            dao.deleteSessionByName(record.bookName, record.bookAuthor)
         }
     }
 
@@ -347,7 +458,7 @@ class ReadRecordRepository(
                     existingTargetDetail.copy(
                         readTime = existingTargetDetail.readTime + detail.readTime,
                         readWords = existingTargetDetail.readWords + detail.readWords,
-                        firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime),
+                        firstReadTime = minPositive(existingTargetDetail.firstReadTime, detail.firstReadTime),
                         lastReadTime = max(existingTargetDetail.lastReadTime, detail.lastReadTime)
                     )
                 )
@@ -371,6 +482,161 @@ class ReadRecordRepository(
 
         dao.deleteReadRecord(source)
         updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor)
+        // 旧版只保存汇总时长、没有阅读时段明细;重算阅读时段总时长时不能把这部分历史清零。
+        if (sourceSessions.isEmpty() && source.readTime > 0) {
+            val mergedTarget = dao.getReadRecord(
+                targetRecord.deviceId,
+                targetRecord.bookName,
+                targetRecord.bookAuthor
+            )
+            if (mergedTarget == null) {
+                dao.insert(
+                    ReadRecord(
+                        deviceId = targetRecord.deviceId,
+                        bookName = targetRecord.bookName,
+                        bookAuthor = targetRecord.bookAuthor,
+                        readTime = source.readTime,
+                        lastRead = max(target.lastRead, source.lastRead)
+                    )
+                )
+            } else {
+                dao.update(mergedTarget.copy(readTime = mergedTarget.readTime + source.readTime))
+            }
+        }
+    }
+
+    /** 清理字段完全相同的阅读时段记录,并根据剩余记录重建汇总记录。 */
+    suspend fun repairDuplicateSessions(): Int {
+        return database.withTransaction {
+            val before = dao.allSession.size
+            dao.deleteDuplicateSessions()
+            dao.all.forEach { record ->
+                if (dao.getSessionsByBook(record.deviceId, record.bookName, record.bookAuthor).isNotEmpty()) {
+                    updateReadRecordTotal(record.deviceId, record.bookName, record.bookAuthor)
+                }
+            }
+            return@withTransaction before - dao.allSession.size
+        }
+    }
+
+    /** 规范化书名/作者,并同步合并汇总、日期详情和阅读时段记录中的碰撞记录。 */
+    suspend fun repairReadRecordIdentities(): ReadRecordRepairReport {
+        return database.withTransaction {
+            var merged = 0
+            var normalized = 0
+            var exceptions = 0
+            dao.all.forEach { record ->
+                val name = ReadRecordIdentity.bookName(record.bookName)
+                val author = ReadRecordIdentity.author(record.bookAuthor)
+                if (name != record.bookName || author != record.bookAuthor) {
+                    runCatching {
+                        mergeReadRecordInto(ReadRecord(record.deviceId, name, author), listOf(record))
+                        merged++
+                        normalized++
+                    }.onFailure { exceptions++ }
+                }
+            }
+            dao.allDetail.forEach { detail ->
+                val normalized = detail.copy(
+                    bookName = ReadRecordIdentity.bookName(detail.bookName),
+                    bookAuthor = ReadRecordIdentity.author(detail.bookAuthor),
+                )
+                if (normalized.bookName != detail.bookName || normalized.bookAuthor != detail.bookAuthor) {
+                    val existing = dao.getDetail(normalized.deviceId, normalized.bookName, normalized.bookAuthor, normalized.date)
+                    if (existing == null) dao.insertDetail(normalized)
+                    else dao.insertDetail(existing.copy(
+                        readTime = existing.readTime + detail.readTime,
+                        readWords = existing.readWords + detail.readWords,
+                        firstReadTime = minPositive(existing.firstReadTime, detail.firstReadTime),
+                        lastReadTime = max(existing.lastReadTime, detail.lastReadTime),
+                    ))
+                    dao.deleteDetail(detail)
+                }
+            }
+            dao.allSession.forEach { session ->
+                val normalized = session.copy(
+                    bookName = ReadRecordIdentity.bookName(session.bookName),
+                    bookAuthor = ReadRecordIdentity.author(session.bookAuthor),
+                )
+                if (normalized.bookName != session.bookName || normalized.bookAuthor != session.bookAuthor) {
+                    val collision = dao.getSession(
+                        normalized.deviceId,
+                        normalized.bookName,
+                        normalized.bookAuthor,
+                        normalized.startTime,
+                        normalized.endTime,
+                        normalized.words,
+                    )
+                    if (collision == null) dao.updateSession(normalized) else dao.deleteSession(session)
+                }
+            }
+            dao.deleteDuplicateSessions()
+            return@withTransaction ReadRecordRepairReport(
+                mergedCount = merged,
+                exceptionCount = exceptions,
+                normalizedRecordCount = normalized,
+            )
+        }
+    }
+
+    /** 只读扫描阅读记录问题,不修改数据库;结果用于展示可修复项数量。 */
+    suspend fun scanReadRecordIssues(): ReadRecordRepairReport {
+        val records = dao.all
+        val details = dao.allDetail
+        val sessions = dao.allSession
+        val duplicateSessions = sessions.size - sessions.distinctBy {
+            listOf(it.deviceId, it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words)
+        }.size
+        val duplicateRecords = records.size - records.distinctBy {
+            listOf(it.deviceId, ReadRecordIdentity.bookName(it.bookName), ReadRecordIdentity.author(it.bookAuthor))
+        }.size
+        val duplicateDetails = details.size - details.distinctBy {
+            listOf(it.deviceId, ReadRecordIdentity.bookName(it.bookName), ReadRecordIdentity.author(it.bookAuthor), it.date)
+        }.size
+        val normalized = records.count {
+            it.bookName != ReadRecordIdentity.bookName(it.bookName) ||
+                it.bookAuthor != ReadRecordIdentity.author(it.bookAuthor)
+        }
+        return ReadRecordRepairReport(
+            duplicateSessionCount = duplicateSessions,
+            mergedCount = duplicateRecords + duplicateDetails,
+            normalizedRecordCount = normalized,
+        )
+    }
+
+    /**
+     * 以阅读时段记录为权威重建汇总与每日明细,并保留未被会话覆盖的历史时长。
+     *
+     * 备份恢复后调用:会话按身份去重导入(幂等),汇总/明细导入取已有与导入两者中的较大值,
+     * 再按会话重算,可避免同一备份重复导入导致时长翻倍,同时正确合并跨设备的会话时长。
+     */
+    suspend fun reconcileReadRecordTotalsFromSessions() {
+        database.withTransaction {
+            dao.all.forEach { record ->
+                val sessions = dao.getSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
+                if (sessions.isEmpty()) return@forEach
+                dao.update(record.copy(
+                    readTime = maxOf(record.readTime, sessions.sumOf { it.endTime - it.startTime }),
+                    lastRead = maxOf(record.lastRead, sessions.maxOf { it.endTime }),
+                ))
+            }
+            dao.allDetail.forEach { detail ->
+                val sessions = dao.getSessionsByBook(detail.deviceId, detail.bookName, detail.bookAuthor)
+                    .filter {
+                        DateUtil.format(Date(it.startTime), DatePattern.NORM_DATE_PATTERN) == detail.date
+                    }
+                if (sessions.isEmpty()) return@forEach
+                dao.insertDetail(detail.copy(
+                    readTime = maxOf(detail.readTime, sessions.sumOf { it.endTime - it.startTime }),
+                    readWords = maxOf(detail.readWords, sessions.sumOf { it.words }),
+                    firstReadTime = minPositive(
+                        detail.firstReadTime,
+                        sessions.map { it.startTime }.filter { it > 0L }.minOrNull() ?: 0L,
+                    ),
+                    lastReadTime = maxOf(detail.lastReadTime, sessions.maxOf { it.endTime }),
+                ))
+            }
+        }
     }
 
 }

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

@@ -738,6 +738,7 @@ val appModule = module {
             bookSourceRepository = get(),
             bookmarkRepository = get(),
             bookRepository = get(),
+            readRecordRepository = get(),
             readerSession = get(),
         )
     }

+ 81 - 41
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -4,6 +4,7 @@ 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 io.legado.app.BuildConfig
@@ -33,6 +34,8 @@ 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.repository.ReadRecordRepository
 import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.ui.book.read.ConfigUpdateAction
@@ -78,6 +81,9 @@ import java.io.FileInputStream
 object Restore : KoinComponent {
 
     private const val TAG = "Restore"
+    // 阅读器当前只使用本地记录分区。旧版备份可能保留设备 Android ID,
+    // 恢复时须归一化,否则同一本书会因 deviceId 不同而显示为两条记录。
+    private const val LOCAL_READ_RECORD_DEVICE_ID = ""
 
     suspend fun restore(context: Context, uri: Uri) {
         BackupRestoreLock.withLock {
@@ -268,30 +274,21 @@ object Restore : KoinComponent {
             }
         }
         if (BackupConfig.dbIsNotIgnored("readRecord")) {
-            fileToListT<ReadRecord>(path, "readRecord.json")?.let {
-                it.forEach { readRecord ->
-                    try {
-                        restoreReadRecord(readRecord)
-                    } catch (_: SQLiteConstraintException) {
-                    }
+            appDb.withTransaction {
+                fileToListT<ReadRecord>(path, "readRecord.json")?.forEach { readRecord ->
+                    try { restoreReadRecord(readRecord) } catch (_: SQLiteConstraintException) { }
                 }
-            }
-            fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.let {
-                it.forEach { detail ->
-                    try {
-                        restoreReadRecordDetail(detail)
-                    } catch (_: SQLiteConstraintException) {
-                    }
+                fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.forEach { detail ->
+                    try { restoreReadRecordDetail(detail) } catch (_: SQLiteConstraintException) { }
                 }
-            }
-            fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.let {
-                it.forEach { session ->
-                    try {
-                        restoreReadRecordSession(session)
-                    } catch (_: SQLiteConstraintException) {
-                    }
+                fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.forEach { session ->
+                    try { restoreReadRecordSession(session) } catch (_: SQLiteConstraintException) { }
                 }
             }
+            reconcileReadRecordAliases()
+            // 会话导入按身份去重(幂等),汇总/明细取较大值后按会话重算,
+            // 避免同一备份重复导入导致阅读时长翻倍。
+            get<ReadRecordRepository>().reconcileReadRecordTotalsFromSessions()
         }
         if (BackupConfig.dbIsNotIgnored("server")) {
             File(path, "servers.json").takeIf {
@@ -418,50 +415,93 @@ object Restore : KoinComponent {
         }
     }
 
+    /** 导入汇总记录时统一到本地分区,取已有与导入两者中的较大时长,保证重复导入幂等。 */
     private suspend fun restoreReadRecord(readRecord: ReadRecord) {
+        val localRecord = readRecord.copy(
+            deviceId = LOCAL_READ_RECORD_DEVICE_ID,
+            bookName = ReadRecordIdentity.bookName(readRecord.bookName),
+            bookAuthor = ReadRecordIdentity.author(readRecord.bookAuthor)
+        )
         val existing = appDb.readRecordDao.getReadRecord(
-            readRecord.deviceId,
-            readRecord.bookName,
-            readRecord.bookAuthor
+            localRecord.deviceId,
+            localRecord.bookName,
+            localRecord.bookAuthor
         )
         appDb.readRecordDao.insert(
             existing?.copy(
-                readTime = maxOf(existing.readTime, readRecord.readTime),
-                lastRead = maxOf(existing.lastRead, readRecord.lastRead)
-            ) ?: readRecord
+                readTime = maxOf(existing.readTime, localRecord.readTime),
+                lastRead = maxOf(existing.lastRead, localRecord.lastRead)
+            ) ?: localRecord
         )
     }
 
+    /** 导入每日详情时统一到本地分区,取已有与导入两者中的较大统计值,保证重复导入幂等。 */
     private suspend fun restoreReadRecordDetail(detail: ReadRecordDetail) {
+        val localDetail = detail.copy(
+            deviceId = LOCAL_READ_RECORD_DEVICE_ID,
+            bookName = ReadRecordIdentity.bookName(detail.bookName),
+            bookAuthor = ReadRecordIdentity.author(detail.bookAuthor)
+        )
         val existing = appDb.readRecordDao.getDetail(
-            detail.deviceId,
-            detail.bookName,
-            detail.bookAuthor,
-            detail.date
+            localDetail.deviceId,
+            localDetail.bookName,
+            localDetail.bookAuthor,
+            localDetail.date
         )
         appDb.readRecordDao.insertDetail(
             existing?.copy(
-                readTime = maxOf(existing.readTime, detail.readTime),
-                readWords = maxOf(existing.readWords, detail.readWords),
-                firstReadTime = minPositive(existing.firstReadTime, detail.firstReadTime),
-                lastReadTime = maxOf(existing.lastReadTime, detail.lastReadTime)
-            ) ?: detail
+                readTime = maxOf(existing.readTime, localDetail.readTime),
+                readWords = maxOf(existing.readWords, localDetail.readWords),
+                firstReadTime = minPositive(existing.firstReadTime, localDetail.firstReadTime),
+                lastReadTime = maxOf(existing.lastReadTime, localDetail.lastReadTime)
+            ) ?: localDetail
         )
     }
 
+    /** 导入会话时统一到本地分区,并按完整会话身份跳过已有副本。 */
     private suspend fun restoreReadRecordSession(session: ReadRecordSession) {
+        val localSession = session.copy(
+            deviceId = LOCAL_READ_RECORD_DEVICE_ID,
+            bookName = ReadRecordIdentity.bookName(session.bookName),
+            bookAuthor = ReadRecordIdentity.author(session.bookAuthor)
+        )
         val existing = appDb.readRecordDao.getSession(
-            session.deviceId,
-            session.bookName,
-            session.bookAuthor,
-            session.startTime,
-            session.endTime
+            localSession.deviceId,
+            localSession.bookName,
+            localSession.bookAuthor,
+            localSession.startTime,
+            localSession.endTime,
+            localSession.words
         )
         if (existing == null) {
-            appDb.readRecordDao.insertSession(session)
+            appDb.readRecordDao.insertSession(localSession)
         }
     }
 
+    /** 导入完成后,将能唯一匹配书架作者的旧空作者记录迁移到规范作者。 */
+    private suspend fun reconcileReadRecordAliases() {
+        val repository = get<ReadRecordRepository>()
+        appDb.readRecordDao.all
+            .filter { it.bookAuthor.isBlank() }
+            .forEach { source ->
+                val authors = appDb.bookDao.findByName(source.bookName)
+                    .asSequence()
+                    .map { it.author.trim() }
+                    .filter { it.isNotBlank() }
+                    .distinct()
+                    .toList()
+                val author = authors.singleOrNull() ?: return@forEach
+                repository.mergeReadRecordInto(
+                    targetRecord = ReadRecord(
+                        deviceId = source.deviceId,
+                        bookName = source.bookName,
+                        bookAuthor = ReadRecordIdentity.author(author),
+                    ),
+                    sourceRecords = listOf(source),
+                )
+            }
+    }
+
     private fun minPositive(left: Long, right: Long): Long {
         return when {
             left <= 0L -> right

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -544,6 +544,9 @@ sealed interface ReadBookIntent {
     data object DismissSheet : ReadBookIntent
     data class SetActiveSheet(val sheet: ReadBookSheet?) : ReadBookIntent
     data class ShowDialog(val dialog: ReadBookDialog) : ReadBookIntent
+    data class ResolveReadRecordAlias(val merge: Boolean, val rememberChoice: Boolean = false) : ReadBookIntent
+    /** 清除所有持久化的未知作者决定,使冲突可以再次由用户确认。 */
+    data object ClearReadRecordAliasDecisions : ReadBookIntent
     data object DismissDialog : ReadBookIntent
 
     // Source actions
@@ -1013,6 +1016,11 @@ sealed interface ReadBookSheet {
 
 @Immutable
 sealed interface ReadBookDialog {
+    data class ReadRecordAliasConflict(
+        val bookName: String,
+        val author: String,
+        val readTime: Long,
+    ) : ReadBookDialog
     data class ConfirmRestoreProgress(val progress: BookProgress) : ReadBookDialog
     data class SureSyncProgress(val progress: BookProgress) : ReadBookDialog
     data object RestoreLastBookProgress : ReadBookDialog

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt

@@ -87,6 +87,8 @@ class ReadBookLoadDelegate(
         suspend fun syncReadPreferencesSnapshot()
 
         fun openChapter(index: Int, durChapterPos: Int)
+
+        suspend fun checkReadRecordAlias(book: Book)
     }
 
     private var changeSourceCoroutine: Coroutine<*>? = null
@@ -153,6 +155,7 @@ class ReadBookLoadDelegate(
             return
         }
         ReadBook.upMsg(null)
+        host.checkReadRecordAlias(book)
 
         if (!isSameBook) {
             ReadBook.loadContent(resetPageOffset = true) {

+ 37 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -1,6 +1,10 @@
 package io.legado.app.ui.book.read
 
 import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -57,6 +61,7 @@ import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
 import io.legado.app.ui.widget.components.log.AppLogSheet
+import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.collectLatest
 import org.koin.compose.koinInject
@@ -98,6 +103,38 @@ fun ReadBookScreen(
     val skipDialog = state.activeDialog as? ReadBookDialog.ConfirmSkipToChapter
     val payDialog = state.activeDialog as? ReadBookDialog.ConfirmChapterPay
     val addToBookshelfDialog = state.activeDialog as? ReadBookDialog.ConfirmAddToBookshelf
+    val readRecordAliasDialog = state.activeDialog as? ReadBookDialog.ReadRecordAliasConflict
+    var rememberAliasChoice by remember(readRecordAliasDialog) { mutableStateOf(false) }
+
+    AppAlertDialog(
+        show = readRecordAliasDialog != null,
+        onDismissRequest = { onIntent(ReadBookIntent.ResolveReadRecordAlias(false, rememberAliasChoice)) },
+        title = stringResource(R.string.read_record_alias_title),
+        text = readRecordAliasDialog?.let {
+            stringResource(R.string.read_record_alias_message, it.bookName, it.readTime / 60000, it.author)
+        }.orEmpty(),
+        confirmText = stringResource(R.string.read_record_alias_merge),
+        onConfirm = { onIntent(ReadBookIntent.ResolveReadRecordAlias(true, rememberAliasChoice)) },
+        dismissText = stringResource(R.string.read_record_alias_keep_separate),
+        onDismiss = { onIntent(ReadBookIntent.ResolveReadRecordAlias(false, rememberAliasChoice)) },
+        content = {
+            Row(
+                modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
+                verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+            ) {
+                Checkbox(
+                    checked = rememberAliasChoice,
+                    onCheckedChange = { rememberAliasChoice = it },
+                )
+                AppText(stringResource(R.string.read_record_alias_remember))
+                TextButton(onClick = {
+                    onIntent(ReadBookIntent.ClearReadRecordAliasDecisions)
+                }) {
+                    AppText(stringResource(R.string.read_record_alias_revoke))
+                }
+            }
+        },
+    )
 
     AppAlertDialog(
         show = restoreDialog != null,

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

@@ -16,6 +16,7 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.data.repository.HighlightRuleRepository
@@ -146,17 +147,15 @@ class ReadBookViewModel(
     private val bookSourceRepository: BookSourceRepository,
     private val bookmarkRepository: BookmarkRepository,
     private val bookRepository: BookRepository,
+    private val readRecordRepository: ReadRecordRepository,
     private val readerSession: ReaderSession,
 ) : BaseViewModel(application) {
-
     // --- MVI State ---
 
     private val _uiState = MutableStateFlow(ReadBookUiState())
     val uiState = _uiState.asStateFlow()
-
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
-
     private val _readAloudProgress = MutableStateFlow(
         activeReadAloudProgress(
             isPlaying = BaseReadAloudService.isPlay(),
@@ -164,12 +163,10 @@ class ReadBookViewModel(
         )
     )
     val readAloudProgress = _readAloudProgress.asStateFlow()
-
     private suspend fun emitEffectWhenSubscribed(effect: ReadBookEffect) {
         _effects.subscriptionCount.first { it > 0 }
         _effects.emit(effect)
     }
-
     /**
      * 书籍信息/目录有两种呈现:`useNewTocSheet` 开着时在阅读页内开 Sheet,
      * 否则按老路子发 Effect 开新页。两个入口的判断完全一致,合在一处,
@@ -187,11 +184,9 @@ class ReadBookViewModel(
             _effects.tryEmit(fallbackEffect(book))
         }
     }
-
     private fun closeReadMenu() {
         _uiState.update { it.copy(menuState = ReadBookMenuState()) }
     }
-
     /**
      * 直接打开书籍详情(跳过阅读信息 Sheet,长按标题胶囊时使用)。
      */
@@ -200,7 +195,6 @@ class ReadBookViewModel(
         closeReadMenu()
         _effects.tryEmit(ReadBookEffect.OpenBookInfo(book.name, book.author, book.bookUrl))
     }
-
     // --- 正文处理域 ---
 
     private val contentProcessDelegate = ReadContentProcessDelegate(
@@ -215,7 +209,6 @@ class ReadBookViewModel(
     )
 
     val contentProcessState = contentProcessDelegate.uiState
-
     // --- 划线/高亮笔记域:一次「选中 → 配置样式/备注 → 保存」的临时会话 ---
 
     private val markingDelegate = MarkingDelegate(
@@ -239,7 +232,6 @@ class ReadBookViewModel(
     )
 
     val markingState = markingDelegate.uiState
-
     /**
      * 划线笔记编辑可能从目录 Sheet 进入:保存/删除/取消后应回到原 sheet(目录),
      * 而不是被丢回阅读页。从划词菜单新建时无原 sheet,回 null。
@@ -320,7 +312,6 @@ class ReadBookViewModel(
     )
 
     val aiState = aiDelegate.uiState
-
     // --- 高亮规则域 ---
 
     private val highlightRuleDelegate = ReadHighlightRuleDelegate(
@@ -414,6 +405,12 @@ class ReadBookViewModel(
         bookKey = _uiState.map { it.book?.let { book -> book.name to book.author } },
     )
 
+    private val readRecordAliasDelegate = ReadRecordAliasDelegate(
+        viewModelScope, localPreferencesRepository, readRecordRepository,
+        { _uiState.value.activeDialog != null },
+        { book, time -> _uiState.update { it.copy(activeDialog = ReadBookDialog.ReadRecordAliasConflict(book.name, book.author, time)) } },
+        { _uiState.update { it.copy(activeDialog = null) } },
+    )
     // --- 开书 / 目录 / 换源 / 进度同步域(无自持状态,isInitFinish 仍在 UiState)---
 
     private val loadDelegate: ReadBookLoadDelegate = ReadBookLoadDelegate(
@@ -449,6 +446,12 @@ class ReadBookViewModel(
             override fun openChapter(index: Int, durChapterPos: Int) {
                 this@ReadBookViewModel.openChapter(index, durChapterPos)
             }
+
+            /**
+             * 打开书籍时检查旧版作者为空的阅读记录。
+             * 已有持久化决定时自动处理,否则暂存候选记录并弹出确认框。
+             */
+            override suspend fun checkReadRecordAlias(book: Book) = readRecordAliasDelegate.check(book)
         },
         bookRepository = bookRepository,
         bookSourceRepository = bookSourceRepository,
@@ -1007,6 +1010,9 @@ class ReadBookViewModel(
                 it.copy(activeSheet = intent.sheet)
             }
             is ReadBookIntent.ShowDialog -> _uiState.update { it.copy(activeDialog = intent.dialog) }
+            is ReadBookIntent.ResolveReadRecordAlias ->
+                readRecordAliasDelegate.resolve(intent.merge, intent.rememberChoice)
+            is ReadBookIntent.ClearReadRecordAliasDecisions -> readRecordAliasDelegate.clearDecisions()
             is ReadBookIntent.DismissDialog -> _uiState.update { it.copy(activeDialog = null) }
             is ReadBookIntent.ShowLogin -> {
                 ReadBook.bookSource?.bookSourceUrl?.let { sourceUrl ->
@@ -2464,9 +2470,7 @@ class ReadBookViewModel(
                 ?: return@launch
             val url = chapter.getAbsoluteURL()
             if (url.isBlank()) return@launch
-            val useBrowser = localPreferencesRepository
-                .getPreference(LocalPreferencesKeys.READ_URL_IN_BROWSER, false)
-                .first()
+            val useBrowser = readSettingsRepository.currentSettings.readUrlInBrowser
             if (useBrowser) {
                 context.openUrl(url.substringBefore(",{"))
             } else {
@@ -2506,13 +2510,9 @@ class ReadBookViewModel(
 
     private fun toggleReadUrlInBrowser() {
         viewModelScope.launch {
-            val current = localPreferencesRepository
-                .getPreference(LocalPreferencesKeys.READ_URL_IN_BROWSER, false)
-                .first()
+            val current = readSettingsRepository.currentSettings.readUrlInBrowser
             val newValue = !current
-            localPreferencesRepository.updatePreference(
-                LocalPreferencesKeys.READ_URL_IN_BROWSER, newValue
-            )
+            readSettingsRepository.update { it.copy(readUrlInBrowser = newValue) }
             _effects.tryEmit(
                 ReadBookEffect.ShowToast(
                     context.getString(

+ 99 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadRecordAliasDelegate.kt

@@ -0,0 +1,99 @@
+package io.legado.app.ui.book.read
+
+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.ReadRecordIdentity
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.repository.ReadRecordRepository
+import io.legado.app.data.repository.SettingsRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+/** 未知作者阅读记录的归属确认与合并域。 */
+class ReadRecordAliasDelegate(
+    private val scope: CoroutineScope,
+    private val localPreferencesRepository: SettingsRepository,
+    private val readRecordRepository: ReadRecordRepository,
+    private val hasActiveDialog: () -> Boolean,
+    private val showConflict: (Book, Long) -> Unit,
+    private val dismissDialog: () -> Unit,
+) {
+    private var pendingBook: Book? = null
+    private var pendingSources: List<ReadRecord> = emptyList()
+    private var pendingKey: String? = null
+
+    /** 打开书籍时检查未知作者记录;已有决定则自动执行,否则请求显示确认框。 */
+    suspend fun check(book: Book) {
+        if (hasActiveDialog()) return
+        val sources = readRecordRepository.getUnknownAuthorRecords(book.name)
+        if (sources.isEmpty()) return
+        val key = ReadRecordIdentity.key(book.name, book.author)
+        val decision = localPreferencesRepository
+            .getString(LocalPreferencesKeys.READ_RECORD_ALIAS_DECISIONS.name)
+            .first()
+            .split('\n')
+            .mapNotNull { ReadRecordAliasDecision.decode(it, key) }
+            .firstOrNull()
+        if (decision != null) {
+            if (decision == ReadRecordAliasAction.MERGE) merge(book, sources)
+            return
+        }
+        pendingBook = book
+        pendingSources = sources
+        pendingKey = key
+        showConflict(book, sources.sumOf { it.readTime })
+    }
+
+    /** 处理用户的合并/保留选择,并按需记住决定。 */
+    fun resolve(merge: Boolean, rememberChoice: Boolean) {
+        val book = pendingBook
+        val sources = pendingSources
+        val key = pendingKey
+        pendingBook = null
+        pendingSources = emptyList()
+        pendingKey = null
+        dismissDialog()
+        if (book == null) return
+        scope.launch {
+            if (rememberChoice && key != null) {
+                val old = localPreferencesRepository
+                    .getString(LocalPreferencesKeys.READ_RECORD_ALIAS_DECISIONS.name)
+                    .first()
+                val cleaned = ReadRecordAliasDecision.removeForKey(old, key)
+                localPreferencesRepository.putString(
+                    LocalPreferencesKeys.READ_RECORD_ALIAS_DECISIONS.name,
+                    listOfNotNull(
+                        cleaned.takeIf { it.isNotBlank() },
+                        ReadRecordAliasDecision.encode(
+                            key,
+                            if (merge) ReadRecordAliasAction.MERGE else ReadRecordAliasAction.KEEP,
+                        ),
+                    ).joinToString("\n"),
+                )
+            }
+            if (merge) merge(book, sources)
+        }
+    }
+
+    /** 清除所有已记住的未知作者归属决定。 */
+    fun clearDecisions() {
+        scope.launch {
+            localPreferencesRepository.putString(
+                LocalPreferencesKeys.READ_RECORD_ALIAS_DECISIONS.name,
+                "",
+            )
+        }
+    }
+
+    private suspend fun merge(book: Book, sources: List<ReadRecord>) {
+        sources.groupBy { it.deviceId }.forEach { (deviceId, records) ->
+            readRecordRepository.mergeReadRecordInto(
+                ReadRecord(deviceId, book.name, book.author),
+                records,
+            )
+        }
+    }
+}

+ 56 - 5
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -53,7 +53,6 @@ import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.contentDescription
@@ -111,6 +110,9 @@ import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.StringUtils.formatFriendlyDate
 import io.legado.app.utils.formatReadDuration
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.emptyFlow
 import org.koin.androidx.compose.koinViewModel
 import java.time.LocalDate
 import java.time.format.DateTimeFormatter
@@ -139,6 +141,9 @@ fun ReadRecordRouteScreen(
         onBackClick = onBackClick,
         onBookClick = onBookClick,
         onSummaryClick = onSummaryClick,
+        onScanRepair = { viewModel.onIntent(ReadRecordIntent.ScanRepair) },
+        onRepairDatabase = { viewModel.onIntent(ReadRecordIntent.RepairDatabase) },
+        effects = viewModel.effects,
     )
 }
 
@@ -153,10 +158,14 @@ fun ReadRecordScreen(
     onBackClick: () -> Unit,
     onBookClick: (String, String) -> Unit,
     onSummaryClick: () -> Unit,
+    effects: Flow<ReadRecordEffect> = emptyFlow(),
+    onScanRepair: () -> Unit = {},
+    onRepairDatabase: () -> Unit = {},
 ) {
     val snackbarHostState = remember { SnackbarHostState() }
     val scope = rememberCoroutineScope()
-    val context = LocalContext.current
+    val noMergeCandidatesMessage = stringResource(R.string.no_merge_candidates)
+    val operationFailedMessage = stringResource(R.string.operation_failed)
 
     val displayMode = state.displayMode
     val readRecordEnabled = state.readRecordEnabled
@@ -169,6 +178,16 @@ fun ReadRecordScreen(
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val inSelectionMode = selectedItemKeys.isNotEmpty()
 
+    LaunchedEffect(Unit) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is ReadRecordEffect.ShowError -> {
+                    snackbarHostState.showSnackbar(effect.message.ifBlank { operationFailedMessage })
+                }
+            }
+        }
+    }
+
     var skipDeleteConfirm by remember { mutableStateOf(false) }
     var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
     var pendingDeleteCount by remember { mutableStateOf(1) }
@@ -396,7 +415,7 @@ fun ReadRecordScreen(
                                         val candidates = loadMergeCandidates(record)
                                         if (candidates.isEmpty()) {
                                             snackbarHostState.showSnackbar(
-                                                context.getString(R.string.no_merge_candidates)
+                                                noMergeCandidatesMessage
                                             )
                                         } else {
                                             mergeDialogData = record to candidates
@@ -455,9 +474,27 @@ fun ReadRecordScreen(
                 onIntent(ReadRecordIntent.ClearRecords)
                 selectedItemKeys = emptySet()
             }
-        }
+        },
+        onScanRepair = { showActionsSheet = false; onScanRepair() },
+        onRepairDatabase = { showActionsSheet = false; onRepairDatabase() }
     )
 
+    state.repairReport?.let { report ->
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(ReadRecordIntent.DismissRepairReport) },
+            title = stringResource(R.string.read_record_repair_title),
+            text = stringResource(
+                R.string.read_record_repair_message,
+                report.mergedCount,
+                report.exceptionCount,
+                report.duplicateSessionCount,
+            ),
+            confirmText = stringResource(R.string.ok),
+            onConfirm = { onIntent(ReadRecordIntent.DismissRepairReport) },
+        )
+    }
+
     var skipDeleteConfirmTemp by remember(pendingDeleteAction != null) { mutableStateOf(false) }
 
     AppAlertDialog(
@@ -606,7 +643,9 @@ private fun ReadRecordActionsSheet(
     onDismissRequest: () -> Unit,
     onToggleCalendar: () -> Unit,
     onReadRecordEnabledChange: (Boolean) -> Unit,
-    onClearReadRecords: () -> Unit
+    onClearReadRecords: () -> Unit,
+    onScanRepair: () -> Unit,
+    onRepairDatabase: () -> Unit,
 ) {
     AppModalBottomSheet(
         show = show,
@@ -633,6 +672,18 @@ private fun ReadRecordActionsSheet(
                 imageVector = Icons.Default.Schedule,
                 onCheckedChange = onReadRecordEnabledChange
             )
+            CompactClickableSettingItem(
+                title = stringResource(R.string.read_record_scan_issues),
+                description = stringResource(R.string.read_record_scan_issues_summary),
+                imageVector = Icons.Default.Search,
+                onClick = onScanRepair
+            )
+            CompactClickableSettingItem(
+                title = stringResource(R.string.read_record_repair),
+                description = stringResource(R.string.read_record_repair_summary),
+                imageVector = Icons.Default.Merge,
+                onClick = onRepairDatabase
+            )
             CompactClickableSettingItem(
                 title = stringResource(R.string.clear_read_records),
                 description = stringResource(R.string.clear_read_records_summary),

+ 63 - 12
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -7,15 +7,24 @@ import cn.hutool.core.date.DateUtil
 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.ReadRecordRepairReport
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.ReadRecordRepository
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flatMapLatest
@@ -29,15 +38,16 @@ import java.util.Date
 data class ReadRecordUiState(
     val isLoading: Boolean = true,
     val totalReadTime: Long = 0,
-    val groupedRecords: Map<String, List<ReadRecordDetail>> = emptyMap(),
-    val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
-    val latestRecords: List<ReadRecord> = emptyList(),
+    val groupedRecords: ImmutableMap<String, ImmutableList<ReadRecordDetail>> = persistentMapOf(),
+    val timelineRecords: ImmutableMap<String, ImmutableList<ReadRecordSession>> = persistentMapOf(),
+    val latestRecords: ImmutableList<ReadRecord> = persistentListOf(),
     val selectedDate: LocalDate? = null,
     val searchKey: String? = null,
-    val dailyReadCounts: Map<LocalDate, Int> = emptyMap(),
-    val dailyReadTimes: Map<LocalDate, Long> = emptyMap(),
+    val dailyReadCounts: ImmutableMap<LocalDate, Int> = persistentMapOf(),
+    val dailyReadTimes: ImmutableMap<LocalDate, Long> = persistentMapOf(),
     val displayMode: DisplayMode = DisplayMode.AGGREGATE,
     val readRecordEnabled: Boolean = true,
+    val repairReport: ReadRecordRepairReport? = null,
 )
 
 enum class DisplayMode {
@@ -67,6 +77,9 @@ class ReadRecordViewModel(
     }
 
     private val _searchKey = MutableStateFlow("")
+    private val _repairReport = MutableStateFlow<ReadRecordRepairReport?>(null)
+    private val _effects = MutableSharedFlow<ReadRecordEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
     private val _selectedDate = MutableStateFlow<LocalDate?>(null)
     val readRecordEnabled: StateFlow<Boolean> = repository.readRecordEnabled
         .stateIn(
@@ -131,13 +144,17 @@ class ReadRecordViewModel(
         ReadRecordUiState(
             isLoading = false,
             totalReadTime = data.totalReadTime,
-            groupedRecords = filteredDetails.groupBy { it.date },
-            timelineRecords = timelineMap,
-            latestRecords = data.latestRecords,
+            groupedRecords = filteredDetails.groupBy { it.date }
+                .mapValues { (_, value) -> value.toImmutableList() }
+                .toImmutableMap(),
+            timelineRecords = timelineMap
+                .mapValues { (_, value) -> value.toImmutableList() }
+                .toImmutableMap(),
+            latestRecords = data.latestRecords.toImmutableList(),
             selectedDate = selectedDate,
             searchKey = searchKey,
-            dailyReadCounts = dailyCounts,
-            dailyReadTimes = dailyTimes,
+            dailyReadCounts = dailyCounts.toImmutableMap(),
+            dailyReadTimes = dailyTimes.toImmutableMap(),
             displayMode = displayMode,
             readRecordEnabled = enabled,
         )
@@ -145,7 +162,12 @@ class ReadRecordViewModel(
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5000),
         initialValue = ReadRecordUiState(isLoading = true)
-    )
+    ).combine(_repairReport) { state, report -> state.copy(repairReport = report) }
+        .stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(5000),
+            initialValue = ReadRecordUiState(isLoading = true),
+        )
 
     fun onIntent(intent: ReadRecordIntent) {
         when (intent) {
@@ -158,6 +180,9 @@ class ReadRecordViewModel(
             ReadRecordIntent.ClearRecords -> clearReadRecords()
             is ReadRecordIntent.SetEnabled -> setReadRecordEnabled(intent.enabled)
             is ReadRecordIntent.MergeRecords -> mergeReadRecords(intent.target, intent.sources)
+            ReadRecordIntent.ScanRepair -> scanRepair()
+            ReadRecordIntent.RepairDatabase -> repairDatabase()
+            ReadRecordIntent.DismissRepairReport -> _repairReport.value = null
         }
     }
 
@@ -165,6 +190,27 @@ class ReadRecordViewModel(
         _searchKey.value = query
     }
 
+    /** 执行只读问题扫描,并将结果放入统一 UiState。 */
+    private fun scanRepair() {
+        viewModelScope.launch {
+            runCatching { repository.scanReadRecordIssues() }
+                .onSuccess { _repairReport.value = it }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowError(it.localizedMessage.orEmpty())) }
+        }
+    }
+
+    /** 在事务中修复身份碰撞和字段完全相同的阅读时段记录。 */
+    private fun repairDatabase() {
+        viewModelScope.launch {
+            runCatching {
+                val identity = repository.repairReadRecordIdentities()
+                val sessions = repository.repairDuplicateSessions()
+                identity.copy(duplicateSessionCount = sessions)
+            }.onSuccess { _repairReport.value = it }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowError(it.localizedMessage.orEmpty())) }
+        }
+    }
+
     fun setDisplayMode(mode: DisplayMode) {
         _displayMode.value = mode
         viewModelScope.launch {
@@ -257,6 +303,11 @@ sealed interface ReadRecordIntent {
     data object ClearRecords : ReadRecordIntent
     data class SetEnabled(val enabled: Boolean) : ReadRecordIntent
     data class MergeRecords(val target: ReadRecord, val sources: List<ReadRecord>) : ReadRecordIntent
+    data object ScanRepair : ReadRecordIntent
+    data object RepairDatabase : ReadRecordIntent
+    data object DismissRepairReport : ReadRecordIntent
 }
 
-sealed interface ReadRecordEffect
+sealed interface ReadRecordEffect {
+    data class ShowError(val message: String) : ReadRecordEffect
+}

+ 15 - 2
app/src/main/res/values-zh-rCN/strings.xml

@@ -2601,13 +2601,26 @@
     <string name="delete_read_record_message">确定要删除这条记录吗?</string>
     <string name="delete_selected_read_records_message">确定要删除选中的 %1$d 条阅读记录吗?</string>
     <string name="clear_read_records">清空阅读记录</string>
-    <string name="clear_read_records_summary">删除全部阅读记录、每日明细和时间线会话</string>
+    <string name="clear_read_records_summary">删除全部阅读记录、每日明细和时间线阅读时段记录</string>
     <string name="clear_read_records_message">确定要清空全部阅读记录吗?此操作无法撤销。</string>
     <string name="enable_read_record">开启阅读记录</string>
-    <string name="enable_read_record_summary">阅读书籍时记录之后的阅读会话</string>
+    <string name="enable_read_record_summary">阅读书籍时记录阅读时段</string>
     <string name="disable_read_record">关闭阅读记录</string>
     <string name="do_not_remind_again">不再提示</string>
     <string name="merge_read_records">合并阅读记录</string>
+    <string name="read_record_alias_title">阅读记录归属确认</string>
+    <string name="read_record_alias_message">发现《%1$s》的旧版未知作者记录(%2$d 分钟),是否合并到作者“%3$s”?</string>
+    <string name="read_record_alias_merge">合并</string>
+    <string name="read_record_alias_keep_separate">保留独立记录</string>
+    <string name="read_record_alias_remember">记住我的选择</string>
+    <string name="read_record_alias_revoke">撤销已记住决定</string>
+    <string name="read_record_repair_title">阅读记录修复结果</string>
+    <string name="read_record_repair_message">合并:%1$d,异常:%2$d,字段完全相同的阅读时段记录:%3$d</string>
+    <string name="read_record_scan_issues">扫描阅读记录问题</string>
+    <string name="read_record_scan_issues_summary">检查重复汇总记录、每日详情和字段完全相同的阅读时段记录</string>
+    <string name="read_record_repair">修复阅读记录</string>
+    <string name="read_record_repair_summary">合并可确定归属的记录,并清理字段完全相同的阅读时段记录</string>
+    <string name="operation_failed">操作失败</string>
     <string name="merge_read_records_message">将选中的阅读记录合并到“%1$s”/%2$s。</string>
     <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
     <string name="no_merge_candidates">没有可合并的其它阅读记录</string>

+ 15 - 2
app/src/main/res/values-zh-rHK/strings.xml

@@ -2470,13 +2470,26 @@
     <string name="delete_read_record_message">確定要刪除這條記錄嗎?</string>
     <string name="delete_selected_read_records_message">確定要刪除所選的 %1$d 條閱讀記錄嗎?</string>
     <string name="clear_read_records">清空閱讀記錄</string>
-    <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線會話</string>
+    <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線閱讀時段記錄</string>
     <string name="clear_read_records_message">確定要清空全部閱讀記錄嗎?此操作無法復原。</string>
     <string name="enable_read_record">開啟閱讀記錄</string>
-    <string name="enable_read_record_summary">閱讀書籍時記錄之後的閱讀會話</string>
+    <string name="enable_read_record_summary">閱讀書籍時記錄閱讀時段</string>
     <string name="disable_read_record">關閉閱讀記錄</string>
     <string name="do_not_remind_again">不再提示</string>
     <string name="merge_read_records">合併閱讀記錄</string>
+    <string name="read_record_alias_title">閱讀記錄歸屬確認</string>
+    <string name="read_record_alias_message">發現《%1$s》的舊版未知作者記錄(%2$d 分鐘),是否合併到作者「%3$s」?</string>
+    <string name="read_record_alias_merge">合併</string>
+    <string name="read_record_alias_keep_separate">保留獨立記錄</string>
+    <string name="read_record_alias_remember">記住我的選擇</string>
+    <string name="read_record_alias_revoke">撤銷已記住決定</string>
+    <string name="read_record_repair_title">閱讀記錄修復結果</string>
+    <string name="read_record_repair_message">合併:%1$d,異常:%2$d,欄位完全相同的閱讀時段記錄:%3$d</string>
+    <string name="read_record_scan_issues">掃描閱讀記錄問題</string>
+    <string name="read_record_scan_issues_summary">檢查重複彙總記錄、每日詳情和欄位完全相同的閱讀時段記錄</string>
+    <string name="read_record_repair">修復閱讀記錄</string>
+    <string name="read_record_repair_summary">合併可確定歸屬的記錄,並清理欄位完全相同的閱讀時段記錄</string>
+    <string name="operation_failed">操作失敗</string>
     <string name="merge_read_records_message">將選取的閱讀記錄合併到「%1$s」/%2$s。</string>
     <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
     <string name="no_merge_candidates">沒有可合併的其他閱讀記錄</string>

+ 15 - 2
app/src/main/res/values-zh-rTW/strings.xml

@@ -2475,13 +2475,26 @@
     <string name="delete_read_record_message">確定要刪除這條記錄嗎?</string>
     <string name="delete_selected_read_records_message">確定要刪除所選的 %1$d 筆閱讀記錄嗎?</string>
     <string name="clear_read_records">清空閱讀記錄</string>
-    <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線會話</string>
+    <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線閱讀時段記錄</string>
     <string name="clear_read_records_message">確定要清空全部閱讀記錄嗎?此操作無法復原。</string>
     <string name="enable_read_record">開啟閱讀記錄</string>
-    <string name="enable_read_record_summary">閱讀書籍時記錄之後的閱讀會話</string>
+    <string name="enable_read_record_summary">閱讀書籍時記錄閱讀時段</string>
     <string name="disable_read_record">關閉閱讀記錄</string>
     <string name="do_not_remind_again">不再提示</string>
     <string name="merge_read_records">合併閱讀記錄</string>
+    <string name="read_record_alias_title">閱讀記錄歸屬確認</string>
+    <string name="read_record_alias_message">發現《%1$s》的舊版未知作者記錄(%2$d 分鐘),是否合併到作者「%3$s」?</string>
+    <string name="read_record_alias_merge">合併</string>
+    <string name="read_record_alias_keep_separate">保留獨立記錄</string>
+    <string name="read_record_alias_remember">記住我的選擇</string>
+    <string name="read_record_alias_revoke">撤銷已記住決定</string>
+    <string name="read_record_repair_title">閱讀記錄修復結果</string>
+    <string name="read_record_repair_message">合併:%1$d,異常:%2$d,欄位完全相同的閱讀時段記錄:%3$d</string>
+    <string name="read_record_scan_issues">掃描閱讀記錄問題</string>
+    <string name="read_record_scan_issues_summary">檢查重複彙總記錄、每日詳情和欄位完全相同的閱讀時段記錄</string>
+    <string name="read_record_repair">修復閱讀記錄</string>
+    <string name="read_record_repair_summary">合併可確定歸屬的記錄,並清理欄位完全相同的閱讀時段記錄</string>
+    <string name="operation_failed">操作失敗</string>
     <string name="merge_read_records_message">將選取的閱讀記錄合併到「%1$s」/%2$s。</string>
     <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
     <string name="no_merge_candidates">沒有可合併的其他閱讀記錄</string>

+ 15 - 2
app/src/main/res/values/strings.xml

@@ -2606,13 +2606,26 @@
     <string name="delete_read_record_message">Delete this reading record?</string>
     <string name="delete_selected_read_records_message">Delete %1$d selected reading records?</string>
     <string name="clear_read_records">Clear reading records</string>
-    <string name="clear_read_records_summary">Delete all reading records, daily details, and timeline sessions</string>
+    <string name="clear_read_records_summary">Delete all reading records, daily details, and timeline reading-period records</string>
     <string name="clear_read_records_message">Clear all reading records? This cannot be undone.</string>
     <string name="enable_read_record">Enable reading records</string>
-    <string name="enable_read_record_summary">Record future reading sessions when you read books</string>
+    <string name="enable_read_record_summary">Record reading periods when you read books</string>
     <string name="disable_read_record">Disable reading records</string>
     <string name="do_not_remind_again">Do not remind again</string>
     <string name="merge_read_records">Merge reading records</string>
+    <string name="read_record_alias_title">Reading record ownership</string>
+    <string name="read_record_alias_message">Found an old reading record for “%1$s” with an unknown author (%2$d minutes). Merge it into “%3$s”?</string>
+    <string name="read_record_alias_merge">Merge</string>
+    <string name="read_record_alias_keep_separate">Keep separate</string>
+    <string name="read_record_alias_remember">Remember my choice</string>
+    <string name="read_record_alias_revoke">Revoke remembered decisions</string>
+    <string name="read_record_repair_title">Reading record repair result</string>
+    <string name="read_record_repair_message">Merged: %1$d, errors: %2$d, records with identical fields: %3$d</string>
+    <string name="read_record_scan_issues">Scan reading record issues</string>
+    <string name="read_record_scan_issues_summary">Check duplicate records, daily details, and reading-period records with identical fields</string>
+    <string name="read_record_repair">Repair reading records</string>
+    <string name="read_record_repair_summary">Merge records with a determined owner and remove reading-period records with identical fields</string>
+    <string name="operation_failed">Operation failed</string>
     <string name="merge_read_records_message">Merge selected reading records into “%1$s” by %2$s.</string>
     <string name="merge_read_record_candidate">%1$s / %2$s (%3$s)</string>
     <string name="no_merge_candidates">No other reading records can be merged</string>

+ 39 - 0
app/src/test/java/io/legado/app/data/entities/readRecord/ReadRecordIdentityTest.kt

@@ -0,0 +1,39 @@
+package io.legado.app.data.entities.readRecord
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** 阅读记录身份规范化、会话指纹和归属决定的边界测试。 */
+class ReadRecordIdentityTest {
+    @Test
+    fun normalizesAllWhitespaceAndTrimsEdges() {
+        assertEquals("示例 书", ReadRecordIdentity.bookName(" \u2003示例\u00a0 书\t"))
+        assertEquals("作者 名", ReadRecordIdentity.author("作者\n\r\t名"))
+    }
+
+    @Test
+    fun emptyAuthorRemainsDistinctFromRealAuthor() {
+        assertEquals("书\u0000", ReadRecordIdentity.key("书", ""))
+        assertTrue(ReadRecordIdentity.key("书", "") != ReadRecordIdentity.key("书", "未知作者"))
+    }
+
+    @Test
+    fun sessionFingerprintIsStableAndIncludesWords() {
+        val first = ReadRecordSession(deviceId = "device", bookName = "书", bookAuthor = "作者", startTime = 10L, endTime = 20L, words = 3L)
+        val same = ReadRecordSession(deviceId = "device", bookName = "书", bookAuthor = "作者", startTime = 10L, endTime = 20L, words = 3L)
+        val differentWords = same.copy(words = 4L)
+        assertEquals(first.stableFingerprint, same.stableFingerprint)
+        assertTrue(first.stableFingerprint != differentWords.stableFingerprint)
+    }
+
+    @Test
+    fun aliasDecisionCanBeReplacedAndRevoked() {
+        val key = ReadRecordAliasDecision.key("书 ", " 作者")
+        val encoded = ReadRecordAliasDecision.encode(key, ReadRecordAliasAction.MERGE)
+        assertEquals(ReadRecordAliasAction.MERGE, ReadRecordAliasDecision.decode(encoded, key))
+        val replaced = ReadRecordAliasDecision.removeForKey(encoded, key) +
+            if (ReadRecordAliasDecision.removeForKey(encoded, key).isBlank()) "" else "\n"
+        assertTrue(ReadRecordAliasDecision.decode(replaced, key) == null)
+    }
+}

+ 6 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -263,6 +263,12 @@ class ReadBookDomainSplitBoundaryTest {
                     "uploadReadingProgressUseCase.execute",
                 ),
             ),
+            DomainSplit(
+                name = "阅读记录归属",
+                delegateFile = "io/legado/app/ui/book/read/ReadRecordAliasDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf("getUnknownAuthorRecords", "ReadRecordAliasDecision."),
+            ),
             DomainSplit(
                 name = "书签",
                 delegateFile = "io/legado/app/ui/book/read/ReadBookmarkDelegate.kt",