Преглед изворни кода

feat(reader): 融合全文页码 —— 估算校准 + 全书后台扫描

融合 PR #1854(估算+校准层)和 PR #1851(全书后台扫描+前台服务):

骨架(#1854):
- HeuristicPageEstimator 公式估算,覆盖在线书和本地书
- PageEstimateCalibrationStore 本地仿射回归自校准
- WholeBookPageCoordinator generation 防旧+前缀和校正
- ExactChapterPageCountStore 真实页数 Room 持久化
- LatestChapterTaskScheduler 章节加载调度重构
- 8 个单测覆盖核心断言

融合(#1851):
- FullBookPaginator 全书后台扫描,改造为喂 correctChapter()
- FullBookPageService 前台服务+进度通知
- CustomTipPlaceholder FULL_PAGE_INDEX/FULL_PAGE_SIZE 占位符
- AndroidManifest 注册 FullBookPageService
- PageView 全文页码 UI 展示

集成路径:FullBookPaginator → ReadBook.paginateLocalBookPages()
  → wholeBookPageCoordinator.correctChapter()

Closes #1854
Closes #1851
HapeLee пре 3 месеци
родитељ
комит
ff1014235a
48 измењених фајлова са 7533 додато и 165 уклоњено
  1. 4626 0
      app/schemas/io.legado.app.data.AppDatabase/98.json
  2. 3 0
      app/src/main/AndroidManifest.xml
  3. 2 1
      app/src/main/java/io/legado/app/constant/NotificationId.kt
  4. 7 3
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  5. 28 0
      app/src/main/java/io/legado/app/data/dao/ExactChapterPageCountDao.kt
  6. 31 0
      app/src/main/java/io/legado/app/data/entities/ExactChapterPageCountEntity.kt
  7. 1 0
      app/src/main/java/io/legado/app/di/appDatabaseModule.kt
  8. 53 0
      app/src/main/java/io/legado/app/help/book/BookHelp.kt
  9. 3 1
      app/src/main/java/io/legado/app/help/config/CustomTipPlaceholder.kt
  10. 4 1
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  11. 91 0
      app/src/main/java/io/legado/app/model/FullBookPaginator.kt
  12. 126 0
      app/src/main/java/io/legado/app/model/LatestChapterTaskScheduler.kt
  13. 355 144
      app/src/main/java/io/legado/app/model/ReadBook.kt
  14. 64 0
      app/src/main/java/io/legado/app/service/FullBookPageService.kt
  15. 14 7
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  16. 10 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  17. 7 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  18. 44 0
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  19. 2 0
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt
  20. 1 0
      app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt
  21. 29 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/ChapterPageEstimator.kt
  22. 81 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/ExactChapterPageCountStore.kt
  23. 39 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/HeuristicPageEstimator.kt
  24. 158 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/PageEstimateCalibrationStore.kt
  25. 157 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/PageEstimateConfig.kt
  26. 110 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/PageEstimateMetrics.kt
  27. 381 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageCoordinator.kt
  28. 132 0
      app/src/main/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageIndex.kt
  29. 5 1
      app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigContract.kt
  30. 39 0
      app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigScreen.kt
  31. 12 1
      app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigViewModel.kt
  32. 2 0
      app/src/main/res/values-zh-rCN/arrays.xml
  33. 11 0
      app/src/main/res/values-zh-rCN/strings.xml
  34. 2 0
      app/src/main/res/values-zh-rHK/arrays.xml
  35. 8 0
      app/src/main/res/values-zh-rHK/strings.xml
  36. 2 0
      app/src/main/res/values-zh-rTW/arrays.xml
  37. 8 0
      app/src/main/res/values-zh-rTW/strings.xml
  38. 2 0
      app/src/main/res/values/arrays.xml
  39. 11 0
      app/src/main/res/values/strings.xml
  40. 72 0
      app/src/test/java/io/legado/app/model/LatestChapterTaskSchedulerTest.kt
  41. 32 0
      app/src/test/java/io/legado/app/ui/book/read/WholeBookPageConfigInvalidationTest.kt
  42. 93 0
      app/src/test/java/io/legado/app/ui/book/read/pageestimate/HeuristicPageEstimatorTest.kt
  43. 49 0
      app/src/test/java/io/legado/app/ui/book/read/pageestimate/PageEstimateConfigTest.kt
  44. 45 0
      app/src/test/java/io/legado/app/ui/book/read/pageestimate/PageEstimateMetricsTest.kt
  45. 108 0
      app/src/test/java/io/legado/app/ui/book/read/pageestimate/PageEstimateSamplesTest.kt
  46. 379 0
      app/src/test/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageCoordinatorTest.kt
  47. 90 0
      app/src/test/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageIndexTest.kt
  48. 4 4
      app/src/test/java/io/legado/app/ui/main/MainNavigatorReadAloudTest.kt

+ 4626 - 0
app/schemas/io.legado.app.data.AppDatabase/98.json

@@ -0,0 +1,4626 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 98,
+    "identityHash": "9260a9f224fc67c73adda5990c6f01eb",
+    "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, 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"
+          }
+        ],
+        "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 '', `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": "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, `rule` 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": "rule",
+            "columnName": "rule",
+            "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, 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"
+          }
+        ],
+        "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"
+            ]
+          }
+        ]
+      }
+    ],
+    "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, '9260a9f224fc67c73adda5990c6f01eb')"
+    ]
+  }
+}

+ 3 - 0
app/src/main/AndroidManifest.xml

@@ -532,6 +532,9 @@
         <service
             android:name=".service.DownloadService"
             android:foregroundServiceType="dataSync" />
+        <service
+            android:name=".service.FullBookPageService"
+            android:foregroundServiceType="dataSync" />
 
         <service
             android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

+ 2 - 1
app/src/main/java/io/legado/app/constant/NotificationId.kt

@@ -13,7 +13,8 @@ object NotificationId {
     const val WebService = 105
     const val DownloadService = 106
     const val CheckSourceService = 107
+    const val FullBookPageService = 108
     const val Download = 10000
     const val ExportBook = 201
 
-}
+}

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

@@ -24,6 +24,7 @@ import io.legado.app.data.dao.ChapterSpeechDao
 import io.legado.app.data.dao.CloudTtsEngineDao
 import io.legado.app.data.dao.CookieDao
 import io.legado.app.data.dao.DictRuleDao
+import io.legado.app.data.dao.ExactChapterPageCountDao
 import io.legado.app.data.dao.HighlightRuleDao
 import io.legado.app.data.dao.HighlightTagRuleDao
 import io.legado.app.data.dao.HomepageCustomSetDao
@@ -71,6 +72,7 @@ import io.legado.app.data.entities.ChapterSpeechSegmentEntity
 import io.legado.app.data.entities.CloudTtsEngineEntity
 import io.legado.app.data.entities.Cookie
 import io.legado.app.data.entities.DictRule
+import io.legado.app.data.entities.ExactChapterPageCountEntity
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HighlightTagRule
 import io.legado.app.data.entities.HomepageCustomSet
@@ -108,7 +110,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 97,
+    version = 98,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -124,7 +126,7 @@ val appDb by lazy {
         BookCharacterEvent::class, BookCharacterRelation::class, BookKnowledgeEntry::class,
         BookOutlineNode::class, ReadAloudVoiceEntity::class, BookVoiceBindingEntity::class,
         ChapterSpeechAnalysisEntity::class, ChapterSpeechSegmentEntity::class,
-        CloudTtsEngineEntity::class],
+        CloudTtsEngineEntity::class, ExactChapterPageCountEntity::class],
     views = [BookSourcePart::class],
     autoMigrations = [
         AutoMigration(from = 43, to = 44),
@@ -180,7 +182,8 @@ val appDb by lazy {
         AutoMigration(from = 93, to = 94),
         AutoMigration(from = 94, to = 95),
         AutoMigration(from = 95, to = 96),
-        AutoMigration(from = 96, to = 97)
+        AutoMigration(from = 96, to = 97),
+        AutoMigration(from = 97, to = 98)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {
@@ -209,6 +212,7 @@ abstract class AppDatabase : RoomDatabase() {
     abstract val cacheDao: CacheDao
     abstract val ruleSubDao: RuleSubDao
     abstract val dictRuleDao: DictRuleDao
+    abstract val exactChapterPageCountDao: ExactChapterPageCountDao
     abstract val keyboardAssistsDao: KeyboardAssistsDao
     abstract val serverDao: ServerDao
     abstract val searchContentHistoryDao: SearchContentHistoryDao

+ 28 - 0
app/src/main/java/io/legado/app/data/dao/ExactChapterPageCountDao.kt

@@ -0,0 +1,28 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.legado.app.data.entities.ExactChapterPageCountEntity
+
+@Dao
+interface ExactChapterPageCountDao {
+
+    @Query(
+        "select * from exact_chapter_page_counts " +
+            "where bookId = :bookId and layoutSignature = :layoutSignature " +
+            "and engineVersion = :engineVersion"
+    )
+    suspend fun getByLayout(
+        bookId: String,
+        layoutSignature: Long,
+        engineVersion: Int,
+    ): List<ExactChapterPageCountEntity>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsert(entity: ExactChapterPageCountEntity)
+
+    @Query("delete from exact_chapter_page_counts where bookId = :bookId and chapterId = :chapterId")
+    suspend fun deleteChapter(bookId: String, chapterId: String)
+}

+ 31 - 0
app/src/main/java/io/legado/app/data/entities/ExactChapterPageCountEntity.kt

@@ -0,0 +1,31 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+
+@Entity(
+    tableName = "exact_chapter_page_counts",
+    primaryKeys = ["bookId", "chapterId", "layoutSignature"],
+    indices = [
+        Index(value = ["bookId", "layoutSignature"]),
+    ],
+    foreignKeys = [
+        ForeignKey(
+            entity = Book::class,
+            parentColumns = ["bookUrl"],
+            childColumns = ["bookId"],
+            onDelete = ForeignKey.CASCADE,
+        ),
+    ],
+)
+data class ExactChapterPageCountEntity(
+    val bookId: String,
+    val chapterId: String,
+    val chapterIndex: Int,
+    val contentHash: Long,
+    val layoutSignature: Long,
+    val engineVersion: Int,
+    val pageCount: Int,
+    val updatedAt: Long,
+)

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

@@ -44,6 +44,7 @@ val appDatabaseModule = module {
     factory<CacheDao> { get<AppDatabase>().cacheDao }
     factory<RuleSubDao> { get<AppDatabase>().ruleSubDao }
     factory<DictRuleDao> { get<AppDatabase>().dictRuleDao }
+    factory<ExactChapterPageCountDao> { get<AppDatabase>().exactChapterPageCountDao }
     factory<KeyboardAssistsDao> { get<AppDatabase>().keyboardAssistsDao }
     factory<ServerDao> { get<AppDatabase>().serverDao }
     factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }

+ 53 - 0
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -62,6 +62,7 @@ import kotlin.math.min
 
 @Suppress("unused", "ConstPropertyName")
 object BookHelp {
+    private const val CACHED_CONTENT_PREFIX_LENGTH = 1_024
     private val downloadDir: File = appCtx.externalFiles
     private const val cacheFolderName = "book_cache"
     private const val cacheImageFolderName = "images"
@@ -662,6 +663,58 @@ object BookHelp {
         return null
     }
 
+    /**
+     * 只统计已经落盘的章节缓存,不读取本地书源,也不会触发网络请求。
+     */
+    fun getCachedContentLength(book: Book, bookChapter: BookChapter): Int? {
+        return getCachedContentInfo(book, bookChapter)?.contentLength
+    }
+
+    /**
+     * 流式读取已落盘正文,只保留用于页数缓存校验的短前缀,不加载本地书源或网络正文。
+     */
+    fun getCachedContentInfo(book: Book, bookChapter: BookChapter): CachedContentInfo? {
+        val file = downloadDir.getFile(
+            cacheFolderName,
+            book.getFolderName(),
+            bookChapter.getFileName()
+        )
+        if (!file.isFile) return null
+
+        return try {
+            var length = 0L
+            val prefix = StringBuilder(CACHED_CONTENT_PREFIX_LENGTH)
+            file.bufferedReader().use { reader ->
+                val buffer = CharArray(DEFAULT_BUFFER_SIZE)
+                while (true) {
+                    val readCount = reader.read(buffer)
+                    if (readCount < 0) break
+                    length += readCount
+                    if (prefix.length < CACHED_CONTENT_PREFIX_LENGTH) {
+                        prefix.append(
+                            buffer,
+                            0,
+                            min(readCount, CACHED_CONTENT_PREFIX_LENGTH - prefix.length),
+                        )
+                    }
+                    if (length > Int.MAX_VALUE) {
+                        return CachedContentInfo(Int.MAX_VALUE, prefix.toString())
+                    }
+                }
+            }
+            length.toInt().takeIf { it > 0 }?.let {
+                CachedContentInfo(it, prefix.toString())
+            }
+        } catch (_: IOException) {
+            null
+        }
+    }
+
+    data class CachedContentInfo(
+        val contentLength: Int,
+        val prefix: String,
+    )
+
     /**
      * 删除章节内容
      */

+ 3 - 1
app/src/main/java/io/legado/app/help/config/CustomTipPlaceholder.kt

@@ -26,7 +26,9 @@ enum class CustomTipPlaceholder(
     CHAPTER_SIZE("{ChapterSize}", "ChapterSize", R.string.placeholder_chapter_size),
     PAGE_INDEX("{PageIndex}", "PageIndex", R.string.placeholder_page_index),
     PAGE_SIZE("{PageSize}", "PageSize", R.string.placeholder_page_size),
-    READ_PROGRESS("{ReadProgress}", "ReadProgress", R.string.placeholder_read_progress);
+    READ_PROGRESS("{ReadProgress}", "ReadProgress", R.string.placeholder_read_progress),
+    FULL_PAGE_INDEX("{FullPageIndex}", "FullPageIndex", R.string.placeholder_full_page_index),
+    FULL_PAGE_SIZE("{FullPageSize}", "FullPageSize", R.string.placeholder_full_page_size);
 
     companion object {
         /** 占位符 key(即花括号内的部分),用于校验。 */

+ 4 - 1
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -68,6 +68,8 @@ object ReadBookConfig {
     const val tipTimeBatteryClassic = 16
     const val tipChapterTitleArrowClassic = 17
     const val tipCustom = 18
+    const val tipWholeBookPage = 19
+    const val tipWholeBookPageAndProgress = 20
     // endregion
 
     const val configFileName = "readConfig.json"
@@ -779,7 +781,8 @@ object ReadBookConfig {
         tipNone, tipBookName, tipChapterTitle, tipChapterTitleArrow, tipChapterTitleArrowClassic,
         tipTime, tipBattery, tipBatteryClassic, tipBatteryInside, tipBatteryIcon, tipBatteryPercentage,
         tipPage, tipTotalProgress, tipTotalProgress1, tipPageAndTotal, tipTimeBattery,
-        tipTimeBatteryClassic, tipTimeBatteryPercentage, tipCustom
+        tipTimeBatteryClassic, tipTimeBatteryPercentage, tipWholeBookPage,
+        tipWholeBookPageAndProgress, tipCustom
     )
     val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList()
     val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList()

+ 91 - 0
app/src/main/java/io/legado/app/model/FullBookPaginator.kt

@@ -0,0 +1,91 @@
+package io.legado.app.model
+
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.config.CustomTipPlaceholder
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.service.FullBookPageService
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import splitties.init.appCtx
+
+data class FullBookPaginationState(
+    val isRunning: Boolean = false,
+    val completed: Int = 0,
+    val total: Int = 0,
+)
+
+/** Optional precision producer for local books; the coordinator remains the only page-count SSOT. */
+object FullBookPaginator {
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+    private var job: Job? = null
+    private var activeBookUrl: String? = null
+    private val _state = MutableStateFlow(FullBookPaginationState())
+    val state = _state.asStateFlow()
+
+    fun startIfNeeded() {
+        val bookUrl = ReadBook.book?.takeIf { it.isLocal }?.bookUrl
+        if (bookUrl == null || !isFullPagePlaceholderActive()) {
+            stop()
+            return
+        }
+        if (job?.isActive == true && activeBookUrl == bookUrl) return
+        stop()
+        activeBookUrl = bookUrl
+        job = scope.launch {
+            _state.value = FullBookPaginationState(isRunning = true)
+            runCatching { FullBookPageService.start(appCtx) }
+            try {
+                ReadBook.paginateLocalBookPages { completed, total ->
+                    _state.update { it.copy(completed = completed, total = total) }
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } finally {
+                _state.update { it.copy(isRunning = false) }
+            }
+        }
+    }
+
+    fun stop() {
+        job?.cancel()
+        job = null
+        activeBookUrl = null
+        _state.value = FullBookPaginationState()
+    }
+
+    private fun isFullPagePlaceholderActive(): Boolean {
+        val directTips = listOf(
+            ReadBookConfig.tipHeaderLeft,
+            ReadBookConfig.tipHeaderMiddle,
+            ReadBookConfig.tipHeaderRight,
+            ReadBookConfig.tipFooterLeft,
+            ReadBookConfig.tipFooterMiddle,
+            ReadBookConfig.tipFooterRight,
+        )
+        if (directTips.any {
+                it == ReadBookConfig.tipWholeBookPage ||
+                    it == ReadBookConfig.tipWholeBookPageAndProgress
+            }
+        ) return true
+        return listOf(
+        ReadBookConfig.customTipHeaderLeft,
+        ReadBookConfig.customTipHeaderMiddle,
+        ReadBookConfig.customTipHeaderRight,
+        ReadBookConfig.customTipFooterLeft,
+        ReadBookConfig.customTipFooterMiddle,
+        ReadBookConfig.customTipFooterRight,
+    ).any { template ->
+        CustomTipPlaceholder.extractPlaceholders(template).any {
+            it == CustomTipPlaceholder.FULL_PAGE_INDEX.key ||
+                it == CustomTipPlaceholder.FULL_PAGE_SIZE.key
+        }
+    }
+    }
+}

+ 126 - 0
app/src/main/java/io/legado/app/model/LatestChapterTaskScheduler.kt

@@ -0,0 +1,126 @@
+package io.legado.app.model
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
+
+internal class LatestChapterTaskScheduler<K>(
+    private val scope: CoroutineScope,
+    private val context: CoroutineContext = Dispatchers.IO,
+    private val onError: (K, Throwable) -> Unit = { _, _ -> },
+) {
+    private val lock = Any()
+    private val entries = mutableMapOf<K, Entry>()
+
+    fun submit(
+        key: K,
+        block: suspend CoroutineScope.() -> Unit,
+    ): Deferred<Unit> {
+        val request = Request(block = block)
+        synchronized(lock) {
+            val entry = entries.getOrPut(key, ::Entry)
+            if (entry.running == null) {
+                startLocked(key, entry, request)
+            } else {
+                entry.pending?.result?.cancel(
+                    CancellationException("Replaced by a newer chapter task")
+                )
+                entry.pending = request
+            }
+        }
+        return request.result
+    }
+
+    fun cancel(key: K) {
+        val running = synchronized(lock) {
+            entries.remove(key)?.also { entry ->
+                entry.pending?.result?.cancel()
+            }?.running?.job
+        }
+        running?.cancel()
+    }
+
+    fun cancelAll() {
+        cancelIf { true }
+    }
+
+    fun cancelIf(predicate: (K) -> Boolean) {
+        val runningJobs = synchronized(lock) {
+            entries.entries
+                .filter { predicate(it.key) }
+                .mapNotNull { (key, entry) ->
+                    entries.remove(key)
+                    entry.pending?.result?.cancel()
+                    entry.running?.job
+                }
+        }
+        runningJobs.forEach(Job::cancel)
+    }
+
+    internal fun stateOf(key: K): TaskState = synchronized(lock) {
+        entries[key]?.let {
+            TaskState(running = it.running != null, pending = it.pending != null)
+        } ?: TaskState()
+    }
+
+    private fun startLocked(key: K, entry: Entry, request: Request) {
+        val token = Any()
+        val job = scope.launch(context, start = CoroutineStart.LAZY) {
+            try {
+                request.block(this)
+                request.result.complete(Unit)
+            } catch (error: CancellationException) {
+                request.result.cancel(error)
+                throw error
+            } catch (error: Throwable) {
+                request.result.completeExceptionally(error)
+                onError(key, error)
+            } finally {
+                onTaskFinished(key, token)
+            }
+        }
+        entry.running = Running(token = token, job = job)
+        job.start()
+    }
+
+    private fun onTaskFinished(key: K, token: Any) {
+        synchronized(lock) {
+            val entry = entries[key] ?: return
+            if (entry.running?.token !== token) return
+            entry.running = null
+            val next = entry.pending
+            entry.pending = null
+            if (next == null) {
+                entries.remove(key)
+            } else {
+                startLocked(key, entry, next)
+            }
+        }
+    }
+
+    private class Entry {
+        var running: Running? = null
+        var pending: Request? = null
+    }
+
+    private class Running(
+        val token: Any,
+        val job: Job,
+    )
+
+    private class Request(
+        val block: suspend CoroutineScope.() -> Unit,
+        val result: CompletableDeferred<Unit> = CompletableDeferred(),
+    )
+
+    internal data class TaskState(
+        val running: Boolean = false,
+        val pending: Boolean = false,
+    )
+}

+ 355 - 144
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -37,12 +37,21 @@ import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
+import io.legado.app.ui.book.read.pageestimate.ChapterLengthInfo
+import io.legado.app.ui.book.read.pageestimate.ChapterContentHasher
+import io.legado.app.ui.book.read.pageestimate.LocalPageEstimateCalibrationStore
+import io.legado.app.ui.book.read.pageestimate.LocalPageEstimateMetrics
+import io.legado.app.ui.book.read.pageestimate.PageEstimateConfig
+import io.legado.app.ui.book.read.pageestimate.PageEstimateMetrics
+import io.legado.app.ui.book.read.pageestimate.RoomExactChapterPageCountStore
+import io.legado.app.ui.book.read.pageestimate.WholeBookPageCoordinator
+import io.legado.app.ui.book.read.pageestimate.WholeBookPageState
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.postEvent
+import io.legado.app.utils.dpToPx
 import io.legado.app.utils.stackTraceStr
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
@@ -53,6 +62,7 @@ import kotlinx.coroutines.cancelChildren
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
@@ -64,7 +74,6 @@ import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 import splitties.init.appCtx
 import java.util.concurrent.ConcurrentHashMap
-import kotlin.coroutines.cancellation.CancellationException
 import kotlin.math.max
 import kotlin.math.min
 
@@ -89,7 +98,11 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private var lastReadLength: Long = 0
     private val loadingChapters = arrayListOf<Int>()
     private val readRecord = ReadRecord()
-    private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
+    private val chapterLayoutScheduler = LatestChapterTaskScheduler<ChapterLayoutTaskKey>(this) {
+            _, error ->
+        AppLog.put("ChapterProvider ERROR", error)
+        appCtx.toastOnUi("ChapterProvider ERROR:\n${error.stackTraceStr}")
+    }
     private val translationObserverJobs = ConcurrentHashMap<Int, Job>()
     private val prevChapterLoadingLock = Mutex()
     private val curChapterLoadingLock = Mutex()
@@ -120,6 +133,21 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private var autoSaveJob: Job? = null
 
     private var currentActiveSession: ReadRecordSession? = null
+
+    private val wholeBookPageCoordinator = WholeBookPageCoordinator(
+        scope = this,
+        calibrationStore = LocalPageEstimateCalibrationStore,
+        exactPageCountStore = RoomExactChapterPageCountStore,
+        metrics = PageEstimateMetrics { metric ->
+            LocalPageEstimateMetrics.record(metric)
+            AppLog.putDebug("PageEstimate $metric")
+        },
+        onStateChanged = {
+            launch {
+                callBack?.upContent(resetPageOffset = false)
+            }
+        },
+    )
     //占位
     private var currentReadLength: Long = 10L
     private const val AUTO_SAVE_INTERVAL = 120 * 1000L
@@ -127,6 +155,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private const val MIN_READ_DURATION = 10 * 1000L
 
     fun resetData(book: Book) {
+        wholeBookPageCoordinator.clear()
         ReadBook.book = book
         readRecord.bookName = book.name
         readRecord.bookAuthor = book.author
@@ -149,6 +178,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         lastBookProgress = null
         webBookProgress = null
         TextFile.clear()
+        requestWholeBookPageEstimate()
         synchronized(this) {
             loadingChapters.clear()
             downloadedChapters.clear()
@@ -185,6 +215,198 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             downloadedChapters.clear()
             downloadFailChapters.clear()
         }
+        requestWholeBookPageEstimate()
+    }
+
+    fun requestWholeBookPageEstimate() {
+        val book = book ?: return
+        if (book.isImage || book.isPdf) {
+            wholeBookPageCoordinator.clear()
+            return
+        }
+        val contentWidth = ChapterProvider.visibleWidth
+        val contentHeight = ChapterProvider.visibleHeight
+        if (contentWidth <= 0 || contentHeight <= 0) return
+        val processor = contentProcessor ?: ContentProcessor.get(book)
+
+        val config = PageEstimateConfig(
+            readerType = pageAnim(),
+            textSizePx = ChapterProvider.contentPaint.textSize,
+            textHeightPx = ChapterProvider.contentPaintTextHeight,
+            lineSpacingPx = ChapterProvider.contentPaintTextHeight *
+                (ChapterProvider.lineSpacingExtra - 1f),
+            paragraphSpacingPx = ChapterProvider.contentPaintTextHeight *
+                ChapterProvider.paragraphSpacing / 10f,
+            titleTextSizePx = ChapterProvider.titlePaint.textSize,
+            titleTextHeightPx = ChapterProvider.titlePaintTextHeight,
+            titleLineSpacingPx = ChapterProvider.titlePaintTextHeight *
+                (ChapterProvider.titleLineSpacingExtra - 1f),
+            titleTopSpacingPx = ChapterProvider.titleTopSpacing.toFloat(),
+            titleBottomSpacingPx = ChapterProvider.titleBottomSpacing.toFloat(),
+            endPaddingPx = 20.dpToPx().toFloat(),
+            contentWidthPx = contentWidth,
+            contentHeightPx = contentHeight,
+            fontKey = ReadBookConfig.textFont,
+            titleFontKey = ReadBookConfig.titleFont,
+            letterSpacing = ReadBookConfig.letterSpacing,
+            paragraphIndent = ReadBookConfig.paragraphIndent,
+            titleMode = ReadBookConfig.titleMode,
+            doublePage = ChapterProvider.doublePage,
+            useZhLayout = ReadBookConfig.useZhLayout,
+            textFullJustify = ReadBookConfig.textFullJustify,
+            contentKey = buildString {
+                append(book.config.useReplaceRule)
+                append('|').append(book.config.delTag)
+                append('|').append(book.config.translationMode)
+                append('|').append(AppConfig.chineseConverterType)
+                append('|').append(AppConfig.replaceEnableDefault)
+                append('|').append(processor.getTitleReplaceRules())
+                append('|').append(processor.getContentReplaceRules())
+            },
+        )
+        val bookUrl = book.bookUrl
+        val showChapterTitle = ReadBookConfig.titleMode != 2
+        val wholeBookAverageChapterLength = book.wordCount.toContentLength()
+            ?.div(chapterSize.coerceAtLeast(1))
+        wholeBookPageCoordinator.requestEstimate(
+            config = config,
+            bookId = bookUrl,
+            fallbackContentLength = wholeBookAverageChapterLength,
+        ) {
+            val chapters = appDb.bookChapterDao.getChapterList(bookUrl)
+            val bytesPerChar = chapters.bytesPerChar()
+            chapters.map { chapter ->
+                val cachedContent = if (book.isLocalTxt) {
+                    null
+                } else {
+                    BookHelp.getCachedContentInfo(book, chapter)
+                }
+                ChapterLengthInfo(
+                    chapterIndex = chapter.index,
+                    chapterId = chapter.url,
+                    titleLength = chapter.title.length,
+                    includeTitle = showChapterTitle || chapter.isVolume,
+                    contentLength = if (chapter.isVolume) {
+                        0
+                    } else {
+                        chapter.wordCount.toContentLength()
+                            ?: cachedContent?.contentLength
+                            ?: chapter.charCountFromOffset(bytesPerChar)
+                    },
+                    contentHash = if (book.isLocalTxt) {
+                        ChapterContentHasher.fromLocalOffsets(chapter.start, chapter.end)
+                    } else {
+                        cachedContent?.let {
+                            ChapterContentHasher.fromLengthAndPrefix(it.contentLength, it.prefix)
+                        }
+                    },
+                )
+            }
+        }
+        FullBookPaginator.startIfNeeded()
+    }
+
+    fun getWholeBookPageState(chapterIndex: Int, localPageIndex: Int): WholeBookPageState? =
+        wholeBookPageCoordinator.getState(chapterIndex, localPageIndex)
+
+    /** Performs local-book precision pagination and feeds the existing whole-book coordinator. */
+    suspend fun paginateLocalBookPages(onProgress: (completed: Int, total: Int) -> Unit) {
+        val book = book?.takeIf { it.isLocal && !it.isImage && !it.isPdf } ?: return
+        val generation = wholeBookPageCoordinator.generation
+        val chapters = withContext(IO) { appDb.bookChapterDao.getChapterList(book.bookUrl) }
+        val processor = ContentProcessor.get(book)
+        val useReplaceRule = book.getUseReplaceRule(AppConfig.replaceEnableDefault)
+        for ((completed, chapter) in chapters.withIndex()) {
+            ensureActive()
+            onProgress(completed, chapters.size)
+            val content = withContext(IO) { BookHelp.getContent(book, chapter) } ?: continue
+            val displayTitle = chapter.getDisplayTitle(
+                processor.getTitleReplaceRules(),
+                useReplaceRule,
+                chineseConverterType = AppConfig.chineseConverterType,
+            )
+            val processed = processor.getContent(book, chapter, content, includeTitle = false)
+            val textChapter = ChapterProvider.getTextChapterAsync(
+                this, book, chapter, displayTitle, processed, chapters.size,
+            )
+            try {
+                var pageCount = 0
+                withTimeout(30_000L) {
+                    for (page in textChapter.layoutChannel) {
+                        ensureActive()
+                        pageCount++
+                    }
+                }
+                wholeBookPageCoordinator.correctChapter(
+                    chapterIndex = chapter.index,
+                    realPageCount = pageCount.coerceAtLeast(1),
+                    layoutGeneration = generation,
+                )
+            } finally {
+                textChapter.cancelLayout()
+            }
+        }
+        onProgress(chapters.size, chapters.size)
+    }
+
+    private fun String?.toContentLength(): Int? {
+        val value = this?.replace(",", "")?.trim() ?: return null
+        val match = Regex("""(\d+(?:\.\d+)?)\s*([万千百]?)""").find(value) ?: return null
+        val multiplier = when (match.groupValues[2]) {
+            "万" -> 10_000f
+            "千" -> 1_000f
+            "百" -> 100f
+            else -> 1f
+        }
+        return (match.groupValues[1].toFloatOrNull()?.times(multiplier))
+            ?.toInt()
+            ?.takeIf { it > 0 }
+    }
+
+    private fun BookChapter.offsetContentLength(): Long? {
+        val startOffset = start ?: return null
+        val endOffset = end ?: return null
+        return (endOffset - startOffset).takeIf { it > 0 }
+    }
+
+    /**
+     * 本地 TXT 的 start/end 是**字节**偏移(TextFile 全程按 ByteArray 切分),
+     * 而 wordCount 和缓存正文长度都是**字符**数,混用会让中文章节虚高 2~3 倍。
+     * 用两者都已知的章节现算字节/字符比,比猜字符集靠谱。
+     */
+    private fun List<BookChapter>.bytesPerChar(): Float? {
+        var bytes = 0L
+        var chars = 0L
+        forEach { chapter ->
+            val byteLength = chapter.offsetContentLength() ?: return@forEach
+            val charLength = chapter.wordCount.toContentLength() ?: return@forEach
+            bytes += byteLength
+            chars += charLength
+        }
+        if (chars <= 0L) return null
+        return (bytes.toFloat() / chars).takeIf { it.isFinite() && it >= 1f }
+    }
+
+    /**
+     * 比值测不出来时返回 null —— 交给协调器的「已知章节长度中位数」兜底,
+     * 好过按字符集拍一个常数进去。
+     */
+    private fun BookChapter.charCountFromOffset(bytesPerChar: Float?): Int? {
+        val byteLength = offsetContentLength() ?: return null
+        val ratio = bytesPerChar ?: return null
+        return (byteLength / ratio)
+            .toLong()
+            .coerceAtMost(Int.MAX_VALUE.toLong())
+            .toInt()
+            .takeIf { it > 0 }
+    }
+
+    private fun correctWholeBookPageCount(textChapter: TextChapter) {
+        wholeBookPageCoordinator.correctChapter(
+            chapterIndex = textChapter.chapter.index,
+            realPageCount = textChapter.pageSize,
+            layoutGeneration = textChapter.pageEstimateGeneration,
+        )
     }
 
     fun upWebBook(book: Book) {
@@ -987,83 +1209,116 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         if (canceled || chapter.index !in durChapterIndex - 1..durChapterIndex + 1) {
             return
         }
-        chapterLoadingJobs[chapter.index]?.cancel()
-        val job = Coroutine.async(this, start = CoroutineStart.LAZY) {
-            val contentProcessor = ContentProcessor.get(book.name, book.origin)
-            val displayTitle = chapter.getDisplayTitle(
-                contentProcessor.getTitleReplaceRules(),
-                book.getUseReplaceRule(AppConfig.replaceEnableDefault),
-                chineseConverterType = AppConfig.chineseConverterType,
-            )
-            val contents = contentProcessor
-                .getContent(book, chapter, content, includeTitle = false)
-            ensureActive()
-            val textChapter = ChapterProvider.getTextChapterAsync(
-                this, book, chapter, displayTitle, contents, simulatedChapterSize
+        chapterLayoutScheduler.submit(chapter.taskKey()) {
+            layoutLoadedChapter(
+                book = book,
+                chapter = chapter,
+                content = content,
+                upContent = upContent,
+                resetPageOffset = resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
             )
-            when (val offset = chapter.index - durChapterIndex) {
-                0 -> curChapterLoadingLock.withLock {
-                    withContext(Main) {
-                        ensureActive()
-                        curTextChapter = textChapter
-                    }
-                    callBack?.upMenuView()
-                    var available = false
-                    collectLayoutPages(textChapter) { page ->
-                        val index = page.index
-                        if (!available && page.containPos(durChapterPos)) {
-                            if (upContent) {
-                                callBack?.upContent(offset, resetPageOffset)
-                            }
-                            available = true
+            withContext(Main) {
+                success?.invoke()
+            }
+        }
+    }
+
+    private suspend fun CoroutineScope.layoutLoadedChapter(
+        book: Book,
+        chapter: BookChapter,
+        content: String,
+        upContent: Boolean,
+        resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean,
+    ) {
+        val pageEstimateGeneration = wholeBookPageCoordinator.generation
+        val contentProcessor = ContentProcessor.get(book.name, book.origin)
+        val displayTitle = chapter.getDisplayTitle(
+            contentProcessor.getTitleReplaceRules(),
+            book.getUseReplaceRule(AppConfig.replaceEnableDefault),
+            chineseConverterType = AppConfig.chineseConverterType,
+        )
+        val contents = contentProcessor
+            .getContent(book, chapter, content, includeTitle = false)
+        ensureActive()
+        wholeBookPageCoordinator.updateChapterContent(
+            chapterIndex = chapter.index,
+            actualLength = contents.textList
+                .sumOf { it.length.toLong() }
+                .coerceAtMost(Int.MAX_VALUE.toLong())
+                .toInt(),
+            contentHash = if (book.isLocalTxt) {
+                ChapterContentHasher.fromLocalOffsets(chapter.start, chapter.end)
+            } else {
+                ChapterContentHasher.fromContent(content)
+            },
+            layoutGeneration = pageEstimateGeneration,
+        )
+        val textChapter = ChapterProvider.getTextChapterAsync(
+            this, book, chapter, displayTitle, contents, simulatedChapterSize
+        ).apply {
+            this.pageEstimateGeneration = pageEstimateGeneration
+        }
+        when (val offset = chapter.index - durChapterIndex) {
+            0 -> curChapterLoadingLock.withLock {
+                withContext(Main) {
+                    ensureActive()
+                    curTextChapter?.cancelLayout()
+                    curTextChapter = textChapter
+                }
+                callBack?.upMenuView()
+                var available = false
+                val layoutCompleted = collectLayoutPages(textChapter) { page ->
+                    val index = page.index
+                    if (!available && page.containPos(durChapterPos)) {
+                        if (upContent) {
+                            callBack?.upContent(offset, resetPageOffset)
                         }
-                        if (upContent && isScroll) {
-                            if (max(index - 3, 0) < durPageIndex) {
-                                callBack?.upContent(offset, false)
-                            }
+                        available = true
+                    }
+                    if (upContent && isScroll) {
+                        if (max(index - 3, 0) < durPageIndex) {
+                            callBack?.upContent(offset, false)
                         }
-                        callBack?.onLayoutPageCompleted(index, page)
                     }
-                    if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
-                    curPageChanged(
-                        preserveReadAloudPosition = preserveReadAloudPosition,
-                    )
-                    callBack?.contentLoadFinish()
+                    callBack?.onLayoutPageCompleted(index, page)
                 }
+                if (layoutCompleted) correctWholeBookPageCount(textChapter)
+                if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
+                curPageChanged(
+                    preserveReadAloudPosition = preserveReadAloudPosition,
+                )
+                callBack?.contentLoadFinish()
+            }
 
-                -1 -> prevChapterLoadingLock.withLock {
-                    withContext(Main) {
-                        ensureActive()
-                        prevTextChapter = textChapter
-                    }
-                    collectLayoutPages(textChapter) {}
-                    if (upContent) callBack?.upContent(offset, resetPageOffset)
+            -1 -> prevChapterLoadingLock.withLock {
+                withContext(Main) {
+                    ensureActive()
+                    prevTextChapter?.cancelLayout()
+                    prevTextChapter = textChapter
                 }
-
-                1 -> nextChapterLoadingLock.withLock {
-                    withContext(Main) {
-                        ensureActive()
-                        nextTextChapter = textChapter
-                    }
-                    collectLayoutPages(textChapter) { page ->
-                        if (page.index > 1) return@collectLayoutPages
-                        if (upContent) callBack?.upContent(offset, resetPageOffset)
-                    }
+                if (collectLayoutPages(textChapter) {}) {
+                    correctWholeBookPageCount(textChapter)
                 }
+                if (upContent) callBack?.upContent(offset, resetPageOffset)
             }
 
-            return@async
-        }.onError {
-            if (it is CancellationException) {
-                return@onError
+            1 -> nextChapterLoadingLock.withLock {
+                withContext(Main) {
+                    ensureActive()
+                    nextTextChapter?.cancelLayout()
+                    nextTextChapter = textChapter
+                }
+                val layoutCompleted = collectLayoutPages(textChapter) { page ->
+                    if (page.index > 1) return@collectLayoutPages
+                    if (upContent) callBack?.upContent(offset, resetPageOffset)
+                }
+                if (layoutCompleted) correctWholeBookPageCount(textChapter)
             }
-            AppLog.put("ChapterProvider ERROR", it)
-            appCtx.toastOnUi("ChapterProvider ERROR:\n${it.stackTraceStr}")
-        }.onSuccess {
-            success?.invoke()
+
+            else -> textChapter.cancelLayout()
         }
-        chapterLoadingJobs[chapter.index] = job
-        job.start()
     }
 
     /**
@@ -1073,16 +1328,18 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private suspend fun collectLayoutPages(
         textChapter: TextChapter,
         onPage: (TextPage) -> Unit,
-    ) {
-        try {
+    ): Boolean {
+        return try {
             withTimeout(30_000L) {
                 for (page in textChapter.layoutChannel) {
                     ensureActive()
                     onPage(page)
                 }
             }
+            true
         } catch (_: TimeoutCancellationException) {
             AppLog.put("Layout channel timeout for chapter ${textChapter.chapter.index}")
+            false
         }
     }
 
@@ -1097,75 +1354,16 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         if (chapter.index !in durChapterIndex - 1..durChapterIndex + 1) {
             return
         }
-        kotlin.runCatching {
-            val contentProcessor = ContentProcessor.get(book.name, book.origin)
-            val displayTitle = chapter.getDisplayTitle(
-                contentProcessor.getTitleReplaceRules(),
-                book.getUseReplaceRule(AppConfig.replaceEnableDefault),
-                chineseConverterType = AppConfig.chineseConverterType,
-            )
-            val contents = contentProcessor
-                .getContent(book, chapter, content, includeTitle = false)
-            val textChapter = ChapterProvider.getTextChapterAsync(
-                this@ReadBook, book, chapter, displayTitle, contents, simulatedChapterSize
+        chapterLayoutScheduler.submit(chapter.taskKey()) {
+            layoutLoadedChapter(
+                book = book,
+                chapter = chapter,
+                content = content,
+                upContent = upContent,
+                resetPageOffset = resetPageOffset,
+                preserveReadAloudPosition = false,
             )
-            when (val offset = chapter.index - durChapterIndex) {
-                0 -> {
-                    curTextChapter?.cancelLayout()
-                    withContext(Main) {
-                        curTextChapter = textChapter
-                    }
-                    callBack?.upMenuView()
-                    var available = false
-                    collectLayoutPages(textChapter) { page ->
-                        val index = page.index
-                        if (!available && page.containPos(durChapterPos)) {
-                            if (upContent) {
-                                callBack?.upContent(offset, resetPageOffset)
-                            }
-                            available = true
-                        }
-                        if (upContent && isScroll) {
-                            if (max(index - 3, 0) < durPageIndex) {
-                                callBack?.upContent(offset, false)
-                            }
-                        }
-                        callBack?.onLayoutPageCompleted(index, page)
-                    }
-                    if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
-                    curPageChanged()
-                    callBack?.contentLoadFinish()
-                }
-
-                -1 -> {
-                    prevTextChapter?.cancelLayout()
-                    withContext(Main) {
-                        prevTextChapter = textChapter
-                    }
-                    collectLayoutPages(textChapter) {}
-                    if (upContent) callBack?.upContent(offset, resetPageOffset)
-                }
-
-                1 -> {
-                    nextTextChapter?.cancelLayout()
-                    withContext(Main) {
-                        nextTextChapter = textChapter
-                    }
-                    collectLayoutPages(textChapter) { page ->
-                        if (page.index > 1) return@collectLayoutPages
-                        if (upContent) callBack?.upContent(offset, resetPageOffset)
-                    }
-                }
-            }
-
-            return
-        }.onFailure {
-            if (it is CancellationException) {
-                return@onFailure
-            }
-            AppLog.put("ChapterProvider ERROR", it)
-            appCtx.toastOnUi("ChapterProvider ERROR:\n${it.stackTraceStr}")
-        }
+        }.await()
     }
 
     @Synchronized
@@ -1279,6 +1477,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             if (simulatedChapterSize > 0 && durChapterIndex > simulatedChapterSize - 1) {
                 durChapterIndex = simulatedChapterSize - 1
             }
+            requestWholeBookPageEstimate()
             if (callBack == null) {
                 clearTextChapter()
             } else {
@@ -1288,16 +1487,27 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     private fun clearExpiredChapterLoadingJob(clearAll: Boolean = false) {
-        val iterator = chapterLoadingJobs.iterator()
-        while (iterator.hasNext()) {
-            val (index, job) = iterator.next()
-            if (clearAll || index !in durChapterIndex - 1..durChapterIndex + 1) {
-                job.cancel()
-                iterator.remove()
+        if (clearAll) {
+            chapterLayoutScheduler.cancelAll()
+        } else {
+            chapterLayoutScheduler.cancelIf { key ->
+                key.chapterIndex !in durChapterIndex - 1..durChapterIndex + 1
             }
         }
     }
 
+    private fun BookChapter.taskKey() = ChapterLayoutTaskKey(
+        bookUrl = bookUrl,
+        chapterId = url,
+        chapterIndex = index,
+    )
+
+    private data class ChapterLayoutTaskKey(
+        val bookUrl: String,
+        val chapterId: String,
+        val chapterIndex: Int,
+    )
+
     /**
      * 注册回调
      */
@@ -1306,6 +1516,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         callBack = cb
     }
 
+
     /**
      * 取消注册回调
      */

+ 64 - 0
app/src/main/java/io/legado/app/service/FullBookPageService.kt

@@ -0,0 +1,64 @@
+package io.legado.app.service
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.R
+import io.legado.app.base.BaseService
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.IntentAction
+import io.legado.app.constant.NotificationId
+import io.legado.app.model.FullBookPaginator
+import io.legado.app.model.ReadBook
+import io.legado.app.utils.servicePendingIntent
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import splitties.systemservices.notificationManager
+
+class FullBookPageService : BaseService() {
+    companion object {
+        fun start(context: Context) {
+            context.startForegroundService(Intent(context, FullBookPageService::class.java))
+        }
+    }
+
+    private val notificationBuilder by lazy {
+        NotificationCompat.Builder(this, AppConst.channelIdDownload)
+            .setSmallIcon(R.drawable.ic_book_has)
+            .setOngoing(true)
+            .setOnlyAlertOnce(true)
+            .setContentTitle(getString(R.string.full_book_pagination))
+            .addAction(
+                R.drawable.ic_stop_black_24dp,
+                getString(R.string.cancel),
+                servicePendingIntent<FullBookPageService>(IntentAction.stop),
+            )
+    }
+
+    override fun onCreate() {
+        super.onCreate()
+        lifecycleScope.launch {
+            FullBookPaginator.state.collectLatest { state ->
+                if (!state.isRunning) {
+                    stopSelf()
+                    return@collectLatest
+                }
+                notificationBuilder
+                    .setContentTitle("${getString(R.string.full_book_pagination)}: ${ReadBook.book?.name.orEmpty()}")
+                    .setContentText("${state.completed} / ${state.total}")
+                    .setProgress(state.total, state.completed, state.total == 0)
+                notificationManager.notify(NotificationId.FullBookPageService, notificationBuilder.build())
+            }
+        }
+    }
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        if (intent?.action == IntentAction.stop) FullBookPaginator.stop()
+        return super.onStartCommand(intent, flags, startId)
+    }
+
+    override fun startForegroundNotification() {
+        startForeground(NotificationId.FullBookPageService, notificationBuilder.build())
+    }
+}

+ 14 - 7
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -1023,6 +1023,7 @@ sealed interface ConfigUpdateAction {
     data object UpdateChapterStyle : ConfigUpdateAction
     data object InvalidateTextPage : ConfigUpdateAction
     data object UpdateLayout : ConfigUpdateAction
+    data object RebuildWholeBookPageIndex : ConfigUpdateAction
     data object SubmitRenderTask : ConfigUpdateAction
     data object UpdatePageAnim : ConfigUpdateAction
 }
@@ -1066,7 +1067,10 @@ sealed interface ConfigUpdate {
 
     // --- Title style ---
     data class TitleMode(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(
+            ConfigUpdateAction.RebuildWholeBookPageIndex,
+            ConfigUpdateAction.ReloadContent,
+        )
     }
     data class TitleBold(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.UpdateContent)
@@ -1178,6 +1182,7 @@ sealed interface ConfigUpdate {
         override val actions = setOf(
             ConfigUpdateAction.UpdateBackground,
             ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.RebuildWholeBookPageIndex,
             ConfigUpdateAction.ReloadContent,
             ConfigUpdateAction.UpdateSystemUi,
             ConfigUpdateAction.UpdatePageAnim
@@ -1187,6 +1192,7 @@ sealed interface ConfigUpdate {
         override val actions = setOf(
             ConfigUpdateAction.UpdateBackground,
             ConfigUpdateAction.UpdateStyle,
+            ConfigUpdateAction.RebuildWholeBookPageIndex,
             ConfigUpdateAction.ReloadContent,
             ConfigUpdateAction.UpdatePageAnim
         )
@@ -1195,6 +1201,7 @@ sealed interface ConfigUpdate {
         override val actions = setOf(
             ConfigUpdateAction.UpdateBackground,
             ConfigUpdateAction.UpdatePageAnim,
+            ConfigUpdateAction.RebuildWholeBookPageIndex,
             ConfigUpdateAction.ReloadContent
         )
     }
@@ -1461,22 +1468,22 @@ sealed interface ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
     data class ReadBodyToLh(val value: Boolean) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(ConfigUpdateAction.RebuildWholeBookPageIndex, ConfigUpdateAction.ReloadContent)
     }
     data class DefaultSourceChangeAll(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
     data class TextFullJustify(val value: Boolean) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(ConfigUpdateAction.RebuildWholeBookPageIndex, ConfigUpdateAction.ReloadContent)
     }
     data class TextBottomJustify(val value: Boolean) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(ConfigUpdateAction.RebuildWholeBookPageIndex, ConfigUpdateAction.ReloadContent)
     }
     data class AdaptSpecialStyle(val value: Boolean) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(ConfigUpdateAction.RebuildWholeBookPageIndex, ConfigUpdateAction.ReloadContent)
     }
     data class UseZhLayout(val value: Boolean) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(ConfigUpdateAction.RebuildWholeBookPageIndex, ConfigUpdateAction.ReloadContent)
     }
     data class ShowBrightnessView(val value: String) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
@@ -1564,6 +1571,6 @@ sealed interface ConfigUpdate {
 
     // --- Chinese converter ---
     data class ChineseConverterType(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.ReloadContent)
+        override val actions = setOf(ConfigUpdateAction.RebuildWholeBookPageIndex, ConfigUpdateAction.ReloadContent)
     }
 }

+ 10 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -843,9 +843,17 @@ class ReadBookController(
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
                         ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) ReadBook.relayoutContent()
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
-                        ConfigUpdateAction.UpdateChapterStyle -> ChapterProvider.upStyle()
+                        ConfigUpdateAction.UpdateChapterStyle -> {
+                            ChapterProvider.upStyle()
+                            ReadBook.requestWholeBookPageEstimate()
+                        }
                         ConfigUpdateAction.InvalidateTextPage -> r.readView.invalidateTextPage()
-                        ConfigUpdateAction.UpdateLayout -> ChapterProvider.upLayout()
+                        ConfigUpdateAction.UpdateLayout -> {
+                            ChapterProvider.upLayout()
+                            ReadBook.requestWholeBookPageEstimate()
+                        }
+                        ConfigUpdateAction.RebuildWholeBookPageIndex ->
+                            ReadBook.requestWholeBookPageEstimate()
                         ConfigUpdateAction.SubmitRenderTask -> r.readView.submitRenderTask()
                         ConfigUpdateAction.UpdatePageAnim -> r.readView.upPageAnim()
                     }

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

@@ -2143,6 +2143,13 @@ class ReadBookViewModel(
                         else -> null
                     }
                 }.toSet()
+                    .let { actions ->
+                        if (5 in values) {
+                            setOf(ConfigUpdateAction.RebuildWholeBookPageIndex) + actions
+                        } else {
+                            actions
+                        }
+                    }
                 if (actions.isNotEmpty()) {
                     emitEffectWhenSubscribed(ReadBookEffect.UpdateReadViewConfig(actions))
                 }

+ 44 - 0
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -62,6 +62,8 @@ class PageView(
     private var tvTotalProgress: BatteryView? = null
     private var tvTotalProgress1: BatteryView? = null
     private var tvPageAndTotal: BatteryView? = null
+    private var tvWholeBookPage: BatteryView? = null
+    private var tvWholeBookPageAndProgress: BatteryView? = null
     private var tvBookName: BatteryView? = null
     private var tvTimeBattery: BatteryView? = null
     private var tvTimeBatteryP: BatteryView? = null
@@ -362,6 +364,19 @@ class PageView(
             typeface = tipTypeface
             textSize = tipTextSize
         }
+        tvWholeBookPage = getTipView(ReadBookConfig.tipWholeBookPage)?.apply {
+            tag = ReadBookConfig.tipWholeBookPage
+            batteryMode = BatteryView.BatteryMode.NO_BATTERY
+            typeface = tipTypeface
+            textSize = tipTextSize
+        }
+        tvWholeBookPageAndProgress =
+            getTipView(ReadBookConfig.tipWholeBookPageAndProgress)?.apply {
+                tag = ReadBookConfig.tipWholeBookPageAndProgress
+                batteryMode = BatteryView.BatteryMode.NO_BATTERY
+                typeface = tipTypeface
+                textSize = tipTextSize
+            }
         tvBookName = getTipView(ReadBookConfig.tipBookName)?.apply {
             tag = ReadBookConfig.tipBookName
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
@@ -533,6 +548,9 @@ class PageView(
             if (pageSizeInt <= 0) "-" else "~$pageSizeInt"
         }
         val readProgressDisplay = textPage.readProgress
+        val wholeBookPage = ReadBook.getWholeBookPageState(textPage.chapterIndex, textPage.index)
+        val fullPageIndexDisplay = wholeBookPage?.currentPage?.toString() ?: pageIndexDisplay
+        val fullPageSizeDisplay = wholeBookPage?.totalPages?.toString() ?: pageSizeDisplay
         // 使用本 PageView 实例持有的电池字段,值由 [upBattery] 在系统电量变化时持续刷新。
         val batteryPercentDisplay = "$battery%"
         var result = template
@@ -547,6 +565,8 @@ class PageView(
                 CustomTipPlaceholder.PAGE_INDEX -> pageIndexDisplay
                 CustomTipPlaceholder.PAGE_SIZE -> pageSizeDisplay
                 CustomTipPlaceholder.READ_PROGRESS -> readProgressDisplay
+                CustomTipPlaceholder.FULL_PAGE_INDEX -> fullPageIndexDisplay
+                CustomTipPlaceholder.FULL_PAGE_SIZE -> fullPageSizeDisplay
             }
             result = result.replace(placeholder.token, replacement)
         }
@@ -592,6 +612,24 @@ class PageView(
         tvTitleArrow?.setTextIfNotEqual(textPage.title)
         tvTitleArrowClassic?.setTextIfNotEqual(textPage.title)
         val readProgress = readProgress
+        val wholeBookPage = ReadBook.getWholeBookPageState(chapterIndex, index)
+        val pageDisplay = wholeBookPage?.let {
+            if (!it.estimated && it.allPreviousChaptersExact) {
+                context.getString(R.string.whole_book_page_format, it.currentPage, it.totalPages)
+            } else {
+                val currentPage = if (it.allPreviousChaptersExact) {
+                    it.currentPage.toString()
+                } else {
+                    "≈${it.currentPage}"
+                }
+                val totalPages = if (it.estimated) "≈${it.totalPages}" else it.totalPages.toString()
+                context.getString(
+                    R.string.whole_book_page_estimated_format,
+                    currentPage,
+                    totalPages,
+                )
+            }
+        }
         tvTotalProgress?.setTextIfNotEqual(readProgress)
         tvTotalProgress1?.setTextIfNotEqual("${chapterIndex.plus(1)}/${chapterSize}")
         if (textChapter.isCompleted) {
@@ -603,6 +641,12 @@ class PageView(
             tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
             tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
         }
+        val wholeBookPageDisplay = pageDisplay
+            ?: context.getString(R.string.whole_book_page_unavailable)
+        tvWholeBookPage?.setTextIfNotEqual(wholeBookPageDisplay)
+        tvWholeBookPageAndProgress?.setTextIfNotEqual(
+            "$wholeBookPageDisplay  $readProgress"
+        )
         upCustomTip(textPage)
         this@PageView.layoutSync()
     }

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt

@@ -71,6 +71,8 @@ data class TextChapter(
 
     var isCompleted = false
 
+    var pageEstimateGeneration = 0L
+
     val paragraphs by lazy {
         paragraphsInternal
     }

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -1108,6 +1108,7 @@ object ChapterProvider {
         viewWidth = width
         viewHeight = height
         upLayout()
+        ReadBook.requestWholeBookPageEstimate()
         postEvent(EventBus.UP_CONFIG, arrayListOf(12))
     }
 

+ 29 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/ChapterPageEstimator.kt

@@ -0,0 +1,29 @@
+package io.legado.app.ui.book.read.pageestimate
+
+fun interface ChapterPageEstimator {
+    /**
+     * 返回**连续**页数:不取整、不设下限。取整和本地校准统一由 [WholeBookPageCoordinator]
+     * 配合 [PageEstimateCalibrationStore] 完成 —— 对已经 ceil 过的整数做回归会把取整偏置
+     * 算两遍,拟合出来的截距是错的。
+     */
+    fun estimate(input: ChapterPageEstimateInput): Float
+}
+
+data class ChapterPageEstimateInput(
+    val readerType: Int,
+    val titleLength: Int,
+    val includeTitle: Boolean,
+    val contentLength: Int,
+    val textSizePx: Float,
+    val textHeightPx: Float,
+    val lineSpacingPx: Float,
+    val paragraphSpacingPx: Float,
+    val titleTextSizePx: Float,
+    val titleTextHeightPx: Float,
+    val titleLineSpacingPx: Float,
+    val titleTopSpacingPx: Float,
+    val titleBottomSpacingPx: Float,
+    val endPaddingPx: Float,
+    val contentWidthPx: Int,
+    val contentHeightPx: Int,
+)

+ 81 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/ExactChapterPageCountStore.kt

@@ -0,0 +1,81 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.ExactChapterPageCountEntity
+
+data class ExactChapterPageCount(
+    val bookId: String,
+    val chapterId: String,
+    val chapterIndex: Int,
+    val contentHash: Long,
+    val layoutSignature: Long,
+    val engineVersion: Int,
+    val pageCount: Int,
+    val updatedAt: Long,
+)
+
+interface ExactChapterPageCountStore {
+    suspend fun load(
+        bookId: String,
+        layoutSignature: Long,
+        engineVersion: Int,
+    ): List<ExactChapterPageCount>
+
+    suspend fun save(value: ExactChapterPageCount)
+
+    suspend fun deleteChapter(bookId: String, chapterId: String)
+
+    companion object {
+        val None = object : ExactChapterPageCountStore {
+            override suspend fun load(
+                bookId: String,
+                layoutSignature: Long,
+                engineVersion: Int,
+            ) = emptyList<ExactChapterPageCount>()
+
+            override suspend fun save(value: ExactChapterPageCount) = Unit
+
+            override suspend fun deleteChapter(bookId: String, chapterId: String) = Unit
+        }
+    }
+}
+
+object RoomExactChapterPageCountStore : ExactChapterPageCountStore {
+    override suspend fun load(
+        bookId: String,
+        layoutSignature: Long,
+        engineVersion: Int,
+    ): List<ExactChapterPageCount> = appDb.exactChapterPageCountDao
+        .getByLayout(bookId, layoutSignature, engineVersion)
+        .map(ExactChapterPageCountEntity::toModel)
+
+    override suspend fun save(value: ExactChapterPageCount) {
+        appDb.exactChapterPageCountDao.upsert(value.toEntity())
+    }
+
+    override suspend fun deleteChapter(bookId: String, chapterId: String) {
+        appDb.exactChapterPageCountDao.deleteChapter(bookId, chapterId)
+    }
+}
+
+private fun ExactChapterPageCountEntity.toModel() = ExactChapterPageCount(
+    bookId = bookId,
+    chapterId = chapterId,
+    chapterIndex = chapterIndex,
+    contentHash = contentHash,
+    layoutSignature = layoutSignature,
+    engineVersion = engineVersion,
+    pageCount = pageCount,
+    updatedAt = updatedAt,
+)
+
+private fun ExactChapterPageCount.toEntity() = ExactChapterPageCountEntity(
+    bookId = bookId,
+    chapterId = chapterId,
+    chapterIndex = chapterIndex,
+    contentHash = contentHash,
+    layoutSignature = layoutSignature,
+    engineVersion = engineVersion,
+    pageCount = pageCount,
+    updatedAt = updatedAt,
+)

+ 39 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/HeuristicPageEstimator.kt

@@ -0,0 +1,39 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import kotlin.math.ceil
+
+class HeuristicPageEstimator(
+    private val capacityScale: Float = 0.82f,
+) : ChapterPageEstimator {
+
+    override fun estimate(input: ChapterPageEstimateInput): Float {
+        val glyphWidth = input.textSizePx * 0.95f
+        val lineHeight = input.textHeightPx + input.lineSpacingPx
+        val charsPerLine = input.contentWidthPx / glyphWidth.coerceAtLeast(1f)
+        val linesPerPage = input.contentHeightPx / lineHeight.coerceAtLeast(1f)
+        val capacity = charsPerLine * linesPerPage * capacityScale * calculateParagraphLoss(input)
+        val bodyPageFraction = input.contentLength.coerceAtLeast(0) / capacity.coerceAtLeast(1f)
+        val titlePageFraction = calculateTitleHeight(input) / input.contentHeightPx
+        val endPaddingPageFraction = input.endPaddingPx / input.contentHeightPx
+
+        return bodyPageFraction + titlePageFraction + endPaddingPageFraction
+    }
+
+    private fun calculateTitleHeight(input: ChapterPageEstimateInput): Float {
+        if (!input.includeTitle) return 0f
+        val titleGlyphWidth = input.titleTextSizePx * 0.95f
+        val titleCharsPerLine = input.contentWidthPx / titleGlyphWidth.coerceAtLeast(1f)
+        val titleLineCount = ceil(input.titleLength / titleCharsPerLine.coerceAtLeast(1f))
+            .coerceAtLeast(1f)
+        val titleLineHeight = input.titleTextHeightPx + input.titleLineSpacingPx
+        return input.titleTopSpacingPx +
+            titleLineCount * titleLineHeight.coerceAtLeast(1f) +
+            input.titleBottomSpacingPx
+    }
+
+    private fun calculateParagraphLoss(input: ChapterPageEstimateInput): Float {
+        val ratio = input.paragraphSpacingPx /
+            (input.textHeightPx + input.lineSpacingPx).coerceAtLeast(1f)
+        return (1f - ratio * 0.08f).coerceIn(0.7f, 1f)
+    }
+}

+ 158 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/PageEstimateCalibrationStore.kt

@@ -0,0 +1,158 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import android.content.Context
+import splitties.init.appCtx
+import kotlin.math.ceil
+import kotlin.math.roundToInt
+
+/**
+ * 桶内的仿射修正:`真实页数 ≈ slope × 估算连续页数 + intercept`。
+ *
+ * 一个桶内字号、行距、段距、版心宽高全是常数(见 [PageEstimateConfig.calibrationBucket]),
+ * 所以待拟合的自由度只剩两个:[slope] 学段落断行的实际损耗,[intercept] 学标题、章末留白
+ * 和取整偏置。后者是乘性系数表达不出来的 —— 这也是原来单个 EMA scale 修不好短章节的原因。
+ *
+ * 默认值 `slope=1, intercept=0.5` 让未拟合时的行为等价于 `ceil(估算值)`,即冷启动不退化。
+ */
+data class PageEstimateCalibration(
+    val slope: Float = 1f,
+    val intercept: Float = 0f,
+    val sampleCount: Int = 0,
+    /** 显式标记而不是由 [sampleCount] 推导:样本够但拟合被判越界时也必须退回冷启动。 */
+    val fitted: Boolean = false,
+) {
+    /**
+     * 未拟合时用 `ceil` —— 和引入回归之前的行为逐位一致,冷启动不退化。
+     * 拟合后用 `round`,因为取整偏置已经被 [intercept] 学进去了,再 ceil 会算两遍。
+     */
+    fun apply(estimatedPages: Float): Int {
+        if (!estimatedPages.isFinite()) return 1
+        if (!fitted) return ceil(estimatedPages).toInt().coerceAtLeast(1)
+        return (slope * estimatedPages + intercept)
+            .takeIf(Float::isFinite)
+            ?.roundToInt()
+            ?.coerceAtLeast(1)
+            ?: 1
+    }
+
+    companion object {
+        const val MIN_SAMPLES = 4
+    }
+}
+
+interface PageEstimateCalibrationStore {
+    fun get(bucket: Long): PageEstimateCalibration
+
+    /** 累积一条样本并返回该桶更新后的拟合结果。 */
+    fun record(bucket: Long, estimatedPages: Float, realPages: Int): PageEstimateCalibration
+
+    companion object {
+        val None = object : PageEstimateCalibrationStore {
+            override fun get(bucket: Long) = PageEstimateCalibration()
+
+            override fun record(bucket: Long, estimatedPages: Float, realPages: Int) =
+                PageEstimateCalibration()
+        }
+    }
+}
+
+/**
+ * 流式最小二乘的累加器。只留 5 个标量(n, Σx, Σy, Σx², Σxy),不保留任何原始样本 ——
+ * 几十字节,也就没有"数据集"可言,自然不需要服务器。
+ */
+internal data class PageEstimateSamples(
+    val count: Int = 0,
+    val sumX: Double = 0.0,
+    val sumY: Double = 0.0,
+    val sumXX: Double = 0.0,
+    val sumXY: Double = 0.0,
+) {
+    fun plus(x: Double, y: Double): PageEstimateSamples {
+        val retention = if (count >= MAX_EFFECTIVE_SAMPLES) {
+            (MAX_EFFECTIVE_SAMPLES - 1).toDouble() / count
+        } else {
+            1.0
+        }
+        return PageEstimateSamples(
+            count = (count + 1).coerceAtMost(MAX_EFFECTIVE_SAMPLES),
+            sumX = sumX * retention + x,
+            sumY = sumY * retention + y,
+            sumXX = sumXX * retention + x * x,
+            sumXY = sumXY * retention + x * y,
+        )
+    }
+
+    /** 样本不足、x 无变化(行列式退化)或结果越界时退回冷启动,由调用方 `ceil` 兜底。 */
+    fun fit(): PageEstimateCalibration {
+        val cold = PageEstimateCalibration(sampleCount = count)
+        if (count < PageEstimateCalibration.MIN_SAMPLES) return cold
+
+        val determinant = count * sumXX - sumX * sumX
+        if (determinant <= DEGENERATE_DETERMINANT) return cold
+
+        val slope = (count * sumXY - sumX * sumY) / determinant
+        val intercept = (sumY - slope * sumX) / count
+        if (!slope.isFinite() || !intercept.isFinite()) return cold
+        if (slope < MIN_SLOPE || slope > MAX_SLOPE) return cold
+        if (intercept < MIN_INTERCEPT || intercept > MAX_INTERCEPT) return cold
+
+        return PageEstimateCalibration(
+            slope = slope.toFloat(),
+            intercept = intercept.toFloat(),
+            sampleCount = count,
+            fitted = true,
+        )
+    }
+
+    private companion object {
+        const val MAX_EFFECTIVE_SAMPLES = 256
+        const val DEGENERATE_DETERMINANT = 1e-6
+        const val MIN_SLOPE = 0.3
+        const val MAX_SLOPE = 3.0
+        const val MIN_INTERCEPT = -2.0
+        const val MAX_INTERCEPT = 5.0
+    }
+}
+
+object LocalPageEstimateCalibrationStore : PageEstimateCalibrationStore {
+    private const val STORE_NAME = "page_estimate_calibration"
+
+    private val preferences by lazy {
+        appCtx.getSharedPreferences(STORE_NAME, Context.MODE_PRIVATE)
+    }
+
+    @Synchronized
+    override fun get(bucket: Long): PageEstimateCalibration = read(bucket).fit()
+
+    @Synchronized
+    override fun record(
+        bucket: Long,
+        estimatedPages: Float,
+        realPages: Int,
+    ): PageEstimateCalibration {
+        if (!estimatedPages.isFinite() || estimatedPages <= 0f || realPages <= 0) {
+            return read(bucket).fit()
+        }
+        val updated = read(bucket).plus(estimatedPages.toDouble(), realPages.toDouble())
+        val key = bucket.toULong().toString(16)
+        preferences.edit()
+            .putInt("n_$key", updated.count)
+            .putFloat("sx_$key", updated.sumX.toFloat())
+            .putFloat("sy_$key", updated.sumY.toFloat())
+            .putFloat("sxx_$key", updated.sumXX.toFloat())
+            .putFloat("sxy_$key", updated.sumXY.toFloat())
+            .apply()
+        return updated.fit()
+    }
+
+    private fun read(bucket: Long): PageEstimateSamples {
+        val key = bucket.toULong().toString(16)
+        return PageEstimateSamples(
+            count = preferences.getInt("n_$key", 0).coerceAtLeast(0),
+            sumX = preferences.getFloat("sx_$key", 0f).toDouble(),
+            sumY = preferences.getFloat("sy_$key", 0f).toDouble(),
+            sumXX = preferences.getFloat("sxx_$key", 0f).toDouble(),
+            sumXY = preferences.getFloat("sxy_$key", 0f).toDouble(),
+        )
+    }
+}

+ 157 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/PageEstimateConfig.kt

@@ -0,0 +1,157 @@
+package io.legado.app.ui.book.read.pageestimate
+
+data class PageEstimateConfig(
+    val readerType: Int,
+    val textSizePx: Float,
+    val textHeightPx: Float,
+    val lineSpacingPx: Float,
+    val paragraphSpacingPx: Float,
+    val titleTextSizePx: Float,
+    val titleTextHeightPx: Float,
+    val titleLineSpacingPx: Float,
+    val titleTopSpacingPx: Float,
+    val titleBottomSpacingPx: Float,
+    val endPaddingPx: Float,
+    val contentWidthPx: Int,
+    val contentHeightPx: Int,
+    val fontKey: String = "",
+    val titleFontKey: String = "",
+    val letterSpacing: Float = 0f,
+    val paragraphIndent: String = "",
+    val titleMode: Int = 0,
+    val doublePage: Boolean = false,
+    val useZhLayout: Boolean = false,
+    val textFullJustify: Boolean = false,
+    val contentKey: String = "",
+    val textEngineVersion: Int = TEXT_ENGINE_VERSION,
+) {
+    init {
+        require(contentWidthPx > 0)
+        require(contentHeightPx > 0)
+    }
+
+    val layoutSignature: Long
+        get() = stableHash(includeContentKey = true)
+
+    val calibrationBucket: Long
+        get() = stableHash(includeContentKey = false)
+
+    private fun stableHash(includeContentKey: Boolean): Long = StablePageHash().apply {
+        add(readerType)
+        add(textSizePx)
+        add(textHeightPx)
+        add(lineSpacingPx)
+        add(paragraphSpacingPx)
+        add(titleTextSizePx)
+        add(titleTextHeightPx)
+        add(titleLineSpacingPx)
+        add(titleTopSpacingPx)
+        add(titleBottomSpacingPx)
+        add(endPaddingPx)
+        add(contentWidthPx)
+        add(contentHeightPx)
+        add(fontKey)
+        add(titleFontKey)
+        add(letterSpacing)
+        add(paragraphIndent)
+        add(titleMode)
+        add(doublePage)
+        add(useZhLayout)
+        add(textFullJustify)
+        add(textEngineVersion)
+        if (includeContentKey) add(contentKey)
+    }.value
+
+    private companion object {
+        const val TEXT_ENGINE_VERSION = 1
+    }
+}
+
+private class StablePageHash {
+    var value: Long = FNV_OFFSET_BASIS
+        private set
+
+    fun add(value: Boolean) = add(if (value) 1 else 0)
+
+    fun add(value: Float) = add(value.toRawBits())
+
+    fun add(value: Int) {
+        repeat(Int.SIZE_BYTES) { byteIndex ->
+            mix((value ushr (byteIndex * Byte.SIZE_BITS)) and 0xff)
+        }
+    }
+
+    fun add(value: Long) {
+        repeat(Long.SIZE_BYTES) { byteIndex ->
+            mix(((value ushr (byteIndex * Byte.SIZE_BITS)) and 0xff).toInt())
+        }
+    }
+
+    fun add(value: String) {
+        value.encodeToByteArray().forEach { mix(it.toInt() and 0xff) }
+        mix(0xff)
+    }
+
+    private fun mix(byte: Int) {
+        value = (value xor byte.toLong()) * FNV_PRIME
+    }
+
+    private companion object {
+        const val FNV_OFFSET_BASIS = -3750763034362895579L
+        const val FNV_PRIME = 1099511628211L
+    }
+}
+
+data class ChapterLengthInfo(
+    val chapterIndex: Int,
+    val chapterId: String,
+    val titleLength: Int,
+    val includeTitle: Boolean = true,
+    val contentLength: Int?,
+    val contentHash: Long? = null,
+)
+
+object ChapterContentHasher {
+    private const val PREFIX_LENGTH = 1_024
+
+    fun fromContent(content: String): Long = fromLengthAndPrefix(
+        contentLength = content.length,
+        prefix = content.take(PREFIX_LENGTH),
+    )
+
+    fun fromLengthAndPrefix(contentLength: Int, prefix: String): Long =
+        StablePageHash().apply {
+            add(contentLength)
+            add(prefix.take(PREFIX_LENGTH))
+        }.value
+
+    fun fromLocalOffsets(start: Long?, end: Long?): Long? {
+        if (start == null || end == null || end <= start) return null
+        return StablePageHash().apply {
+            add(start)
+            add(end)
+        }.value
+    }
+}
+
+internal fun ChapterLengthInfo.toInput(
+    config: PageEstimateConfig,
+    fallbackContentLength: Int,
+) = ChapterPageEstimateInput(
+    readerType = config.readerType,
+    titleLength = titleLength,
+    includeTitle = includeTitle,
+    contentLength = contentLength ?: fallbackContentLength,
+    textSizePx = config.textSizePx,
+    textHeightPx = config.textHeightPx,
+    lineSpacingPx = config.lineSpacingPx,
+    paragraphSpacingPx = config.paragraphSpacingPx,
+    titleTextSizePx = config.titleTextSizePx,
+    titleTextHeightPx = config.titleTextHeightPx,
+    titleLineSpacingPx = config.titleLineSpacingPx,
+    titleTopSpacingPx = config.titleTopSpacingPx,
+    titleBottomSpacingPx = config.titleBottomSpacingPx,
+    endPaddingPx = config.endPaddingPx,
+    contentWidthPx = config.contentWidthPx,
+    contentHeightPx = config.contentHeightPx,
+)

+ 110 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/PageEstimateMetrics.kt

@@ -0,0 +1,110 @@
+package io.legado.app.ui.book.read.pageestimate
+
+sealed interface PageEstimateMetric {
+    val generation: Long
+    val layoutSignature: Long
+
+    data class EstimateCompleted(
+        override val generation: Long,
+        override val layoutSignature: Long,
+        val calibration: PageEstimateCalibration,
+        val chapterCount: Int,
+        val knownLengthCount: Int,
+        val totalPages: Int,
+        val durationMillis: Long,
+    ) : PageEstimateMetric
+
+    data class ChapterCorrected(
+        override val generation: Long,
+        override val layoutSignature: Long,
+        val chapterId: String,
+        val chapterIndex: Int,
+        val contentLength: Int,
+        /** 未校准的连续估算值,也就是最小二乘的 x。 */
+        val rawEstimate: Float,
+        val estimatedPages: Int,
+        val realPages: Int,
+        val absoluteError: Int,
+        val relativeError: Float,
+        val calibrationBefore: PageEstimateCalibration,
+        val calibrationAfter: PageEstimateCalibration,
+    ) : PageEstimateMetric
+}
+
+fun interface PageEstimateMetrics {
+    fun record(metric: PageEstimateMetric)
+
+    companion object {
+        val None = PageEstimateMetrics {}
+    }
+}
+
+/**
+ * 仅在当前进程内保留最近的诊断记录,供用户主动导出;不落盘、不上传。
+ */
+object LocalPageEstimateMetrics : PageEstimateMetrics {
+    private const val MAX_ENTRIES = 200
+    private val entries = ArrayDeque<TimedPageEstimateMetric>()
+
+    @Synchronized
+    override fun record(metric: PageEstimateMetric) {
+        if (entries.size == MAX_ENTRIES) entries.removeFirst()
+        entries.addLast(TimedPageEstimateMetric(System.currentTimeMillis(), metric))
+    }
+
+    @Synchronized
+    fun size(): Int = entries.size
+
+    @Synchronized
+    fun export(): String = buildString {
+        appendLine("Legado whole-book page diagnostics")
+        appendLine("localOnly=true\tentryCount=${entries.size}")
+        appendLine("time\ttype\tgeneration\testimator\tlayoutSignature\tdetails")
+        entries.forEach { entry ->
+            val metric = entry.metric
+            append(entry.recordedAt)
+            append('\t')
+            append(if (metric is PageEstimateMetric.EstimateCompleted) "estimate" else "correction")
+            append('\t').append(metric.generation)
+            append('\t').append(metric.layoutSignature.toULong().toString(16))
+            append('\t').append(metric.details())
+            appendLine()
+        }
+    }
+
+    @Synchronized
+    internal fun clear() {
+        entries.clear()
+    }
+}
+
+private data class TimedPageEstimateMetric(
+    val recordedAt: Long,
+    val metric: PageEstimateMetric,
+)
+
+private fun PageEstimateMetric.details(): String = when (this) {
+    is PageEstimateMetric.EstimateCompleted -> buildString {
+        append("chapters=").append(chapterCount)
+        append(",knownLengths=").append(knownLengthCount)
+        append(",totalPages=").append(totalPages)
+        append(",durationMs=").append(durationMillis)
+        append(",slope=").append(calibration.slope)
+        append(",intercept=").append(calibration.intercept)
+        append(",samples=").append(calibration.sampleCount)
+        append(",fitted=").append(calibration.fitted)
+    }
+
+    is PageEstimateMetric.ChapterCorrected -> buildString {
+        append("chapterIndex=").append(chapterIndex)
+        append(",contentLength=").append(contentLength)
+        append(",rawEstimate=").append(rawEstimate)
+        append(",estimatedPages=").append(estimatedPages)
+        append(",realPages=").append(realPages)
+        append(",absoluteError=").append(absoluteError)
+        append(",relativeError=").append(relativeError)
+        append(",slopeBefore=").append(calibrationBefore.slope)
+        append(",slopeAfter=").append(calibrationAfter.slope)
+        append(",samplesAfter=").append(calibrationAfter.sampleCount)
+    }
+}

+ 381 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageCoordinator.kt

@@ -0,0 +1,381 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.math.abs
+import kotlin.math.ceil
+
+class WholeBookPageCoordinator(
+    private val scope: CoroutineScope,
+    /** 仅用于测试,生产路径使用 [HeuristicPageEstimator]。 */
+    private val estimator: ChapterPageEstimator? = null,
+    private val calibrationStore: PageEstimateCalibrationStore = PageEstimateCalibrationStore.None,
+    private val exactPageCountStore: ExactChapterPageCountStore = ExactChapterPageCountStore.None,
+    private val metrics: PageEstimateMetrics = PageEstimateMetrics.None,
+    private val onStateChanged: () -> Unit = {},
+) {
+    private val lock = Any()
+    private var pageIndex: WholeBookPageIndex? = null
+    private val pendingCorrections = mutableMapOf<Int, PendingCorrection>()
+    private val pendingLengthUpdates = mutableMapOf<Int, PendingLengthUpdate>()
+    private var estimateJob: Job? = null
+    private var activeEstimate: ActiveEstimate? = null
+
+    @Volatile
+    var generation: Long = 0L
+        private set
+
+    fun requestEstimate(
+        config: PageEstimateConfig,
+        bookId: String,
+        fallbackContentLength: Int? = null,
+        chapterLoader: suspend () -> List<ChapterLengthInfo>,
+    ): Long {
+        val taskGeneration: Long
+        val requestStartedAt = System.nanoTime()
+        val layoutSignature = config.layoutSignature
+        val layoutBucket = config.calibrationBucket
+        val bookBucket = bookCalibrationBucket(bookId, layoutBucket)
+        // 段落密度是逐书的属性(对话型 / 叙述型差 40%),所以优先用本书自己的拟合,
+        // 样本不够时才退回排版级的聚合值做冷启动先验。
+        val calibration = calibrationStore.get(bookBucket)
+            .takeIf(PageEstimateCalibration::fitted)
+            ?: calibrationStore.get(layoutBucket)
+        val chapterEstimator = estimator ?: HeuristicPageEstimator()
+        synchronized(lock) {
+            generation++
+            taskGeneration = generation
+            estimateJob?.cancel()
+            pageIndex = null
+            activeEstimate = null
+            pendingCorrections.clear()
+            pendingLengthUpdates.clear()
+        }
+        estimateJob = scope.launch(Dispatchers.Default) {
+            val chapters = withContext(Dispatchers.IO) { chapterLoader() }
+                .sortedBy(ChapterLengthInfo::chapterIndex)
+            ensureActive()
+            if (chapters.isEmpty()) return@launch
+
+            val knownLengths = chapters.mapNotNull(ChapterLengthInfo::contentLength)
+                .filter { it > 0 }
+                .sorted()
+            val fallbackLength = knownLengths.getOrNull(knownLengths.size / 2)
+                ?: fallbackContentLength?.takeIf { it > 0 }
+                ?: DEFAULT_CHAPTER_LENGTH
+            // 保留未校准的连续估算值:校正到来时它就是最小二乘的 x。
+            val rawEstimates = FloatArray(chapters.size) { index ->
+                ensureActive()
+                chapterEstimator.estimate(chapters[index].toInput(config, fallbackLength))
+            }
+            val pageCounts = IntArray(chapters.size) { index ->
+                calibration.apply(rawEstimates[index])
+            }
+            val cachedExactCounts = withContext(Dispatchers.IO) {
+                runCatching {
+                    exactPageCountStore.load(
+                        bookId = bookId,
+                        layoutSignature = layoutSignature,
+                        engineVersion = config.textEngineVersion,
+                    )
+                }.getOrDefault(emptyList())
+            }
+            ensureActive()
+            val exactChapters = mutableSetOf<Int>()
+            val staleChapterIds = mutableSetOf<String>()
+            val chapterIndicesById = chapters
+                .mapIndexed { index, chapter -> chapter.chapterId to index }
+                .toMap()
+            cachedExactCounts.forEach { cached ->
+                val chapterIndex = chapterIndicesById[cached.chapterId]
+                val chapter = chapterIndex?.let(chapters::getOrNull)
+                if (chapterIndex != null &&
+                    chapter?.contentHash != null &&
+                    chapter.contentHash == cached.contentHash
+                ) {
+                    pageCounts[chapterIndex] = cached.pageCount.coerceAtLeast(1)
+                    exactChapters += chapterIndex
+                } else {
+                    staleChapterIds += cached.chapterId
+                }
+            }
+            if (staleChapterIds.isNotEmpty()) {
+                scope.launch(Dispatchers.IO) {
+                    staleChapterIds.forEach { chapterId ->
+                        runCatching { exactPageCountStore.deleteChapter(bookId, chapterId) }
+                    }
+                }
+            }
+
+            val changed = synchronized(lock) {
+                if (generation != taskGeneration) return@synchronized false
+                val newIndex = WholeBookPageIndex(chapters.size)
+                newIndex.initialize(pageCounts, exactChapters)
+                pageIndex = newIndex
+                activeEstimate = ActiveEstimate(
+                    generation = taskGeneration,
+                    bookId = bookId,
+                    layoutSignature = layoutSignature,
+                    bookBucket = bookBucket,
+                    layoutBucket = layoutBucket,
+                    calibration = calibration,
+                    config = config,
+                    estimator = chapterEstimator,
+                    fallbackContentLength = fallbackLength,
+                    chapters = chapters.toMutableList(),
+                    rawEstimates = rawEstimates,
+                )
+                pendingLengthUpdates.values
+                    .filter { it.generation == taskGeneration }
+                    .forEach { update ->
+                        applyLengthUpdateLocked(
+                            pageIndex = newIndex,
+                            estimate = checkNotNull(activeEstimate),
+                            chapterIndex = update.chapterIndex,
+                            actualLength = update.actualLength,
+                            contentHash = update.contentHash,
+                        )
+                    }
+                pendingLengthUpdates.clear()
+                pendingCorrections.values
+                    .filter { it.generation == taskGeneration }
+                    .forEach { correction ->
+                        applyCorrectionLocked(
+                            pageIndex = newIndex,
+                            estimate = checkNotNull(activeEstimate),
+                            chapterIndex = correction.chapterIndex,
+                            realPageCount = correction.realPageCount,
+                        )
+                    }
+                pendingCorrections.clear()
+                true
+            }
+            if (changed) {
+                metrics.record(
+                    PageEstimateMetric.EstimateCompleted(
+                        generation = taskGeneration,
+                        layoutSignature = layoutSignature,
+                        calibration = calibration,
+                        chapterCount = chapters.size,
+                        knownLengthCount = knownLengths.size,
+                        totalPages = pageCounts.sum(),
+                        durationMillis = (System.nanoTime() - requestStartedAt) / 1_000_000L,
+                    )
+                )
+                onStateChanged()
+            }
+        }
+        return taskGeneration
+    }
+
+    fun correctChapter(chapterIndex: Int, realPageCount: Int, layoutGeneration: Long) {
+        val changed = synchronized(lock) {
+            if (layoutGeneration != generation) return@synchronized false
+            val index = pageIndex
+            if (index == null || !index.isInitialized) {
+                pendingCorrections[chapterIndex] = PendingCorrection(
+                    chapterIndex = chapterIndex,
+                    realPageCount = realPageCount.coerceAtLeast(1),
+                    generation = layoutGeneration,
+                )
+                return@synchronized false
+            }
+            val estimate = activeEstimate ?: return@synchronized false
+            applyCorrectionLocked(index, estimate, chapterIndex, realPageCount)
+        }
+        if (changed) onStateChanged()
+    }
+
+    fun updateChapterLength(chapterIndex: Int, actualLength: Int, layoutGeneration: Long) {
+        updateChapterContent(
+            chapterIndex = chapterIndex,
+            actualLength = actualLength,
+            contentHash = null,
+            layoutGeneration = layoutGeneration,
+        )
+    }
+
+    fun updateChapterContent(
+        chapterIndex: Int,
+        actualLength: Int,
+        contentHash: Long?,
+        layoutGeneration: Long,
+    ) {
+        if (actualLength < 0) return
+        val changed = synchronized(lock) {
+            if (layoutGeneration != generation) return@synchronized false
+            val index = pageIndex
+            if (index == null || !index.isInitialized) {
+                pendingLengthUpdates[chapterIndex] = PendingLengthUpdate(
+                    chapterIndex = chapterIndex,
+                    actualLength = actualLength,
+                    contentHash = contentHash,
+                    generation = layoutGeneration,
+                )
+                return@synchronized false
+            }
+            val estimate = activeEstimate ?: return@synchronized false
+            applyLengthUpdateLocked(index, estimate, chapterIndex, actualLength, contentHash)
+        }
+        if (changed) onStateChanged()
+    }
+
+    fun getState(chapterIndex: Int, localPageIndex: Int): WholeBookPageState? =
+        synchronized(lock) {
+            pageIndex?.getState(chapterIndex, localPageIndex)
+        }
+
+    fun clear() {
+        synchronized(lock) {
+            generation++
+            estimateJob?.cancel()
+            estimateJob = null
+            pageIndex = null
+            activeEstimate = null
+            pendingCorrections.clear()
+            pendingLengthUpdates.clear()
+        }
+    }
+
+    private data class PendingCorrection(
+        val chapterIndex: Int,
+        val realPageCount: Int,
+        val generation: Long,
+    )
+
+    private data class PendingLengthUpdate(
+        val chapterIndex: Int,
+        val actualLength: Int,
+        val contentHash: Long?,
+        val generation: Long,
+    )
+
+    private class ActiveEstimate(
+        val generation: Long,
+        val bookId: String,
+        val layoutSignature: Long,
+        val bookBucket: Long,
+        val layoutBucket: Long,
+        val calibration: PageEstimateCalibration,
+        val config: PageEstimateConfig,
+        val estimator: ChapterPageEstimator,
+        val fallbackContentLength: Int,
+        val chapters: MutableList<ChapterLengthInfo>,
+        val rawEstimates: FloatArray,
+    )
+
+    private fun applyLengthUpdateLocked(
+        pageIndex: WholeBookPageIndex,
+        estimate: ActiveEstimate,
+        chapterIndex: Int,
+        actualLength: Int,
+        contentHash: Long? = null,
+    ): Boolean {
+        val chapter = estimate.chapters.getOrNull(chapterIndex) ?: return false
+        if (chapter.contentLength == actualLength &&
+            (contentHash == null || chapter.contentHash == contentHash)
+        ) return false
+
+        val updatedChapter = chapter.copy(
+            contentLength = actualLength,
+            contentHash = contentHash ?: chapter.contentHash,
+        )
+        estimate.chapters[chapterIndex] = updatedChapter
+        val rawEstimate = estimate.estimator.estimate(
+            updatedChapter.toInput(estimate.config, estimate.fallbackContentLength)
+        )
+        estimate.rawEstimates[chapterIndex] = rawEstimate
+        if (pageIndex.isExact(chapterIndex)) {
+            if (contentHash == null || contentHash == chapter.contentHash) return false
+            scope.launch(Dispatchers.IO) {
+                runCatching {
+                    exactPageCountStore.deleteChapter(estimate.bookId, chapter.chapterId)
+                }
+            }
+            return pageIndex.invalidateExact(
+                chapterIndex,
+                estimate.calibration.apply(rawEstimate),
+            )
+        }
+        return pageIndex.updateEstimatedPageCount(
+            chapterIndex,
+            estimate.calibration.apply(rawEstimate),
+        )
+    }
+
+    private fun applyCorrectionLocked(
+        pageIndex: WholeBookPageIndex,
+        estimate: ActiveEstimate,
+        chapterIndex: Int,
+        realPageCount: Int,
+    ): Boolean {
+        val estimatedPages = pageIndex.getChapterPageCount(chapterIndex) ?: return false
+        val firstExactResult = !pageIndex.isExact(chapterIndex)
+        val correctedPages = realPageCount.coerceAtLeast(1)
+        val changed = pageIndex.correct(chapterIndex, correctedPages)
+        if (!firstExactResult) return changed
+
+        val chapter = estimate.chapters.getOrNull(chapterIndex) ?: return changed
+        val rawEstimate = estimate.rawEstimates.getOrNull(chapterIndex) ?: return changed
+        val calibrationBefore = calibrationStore.get(estimate.bookBucket)
+        // 逐书桶用来估这本书自己的段落密度,排版级桶用来给下一本新书做冷启动先验。
+        val updatedCalibration = calibrationStore.record(
+            bucket = estimate.bookBucket,
+            estimatedPages = rawEstimate,
+            realPages = correctedPages,
+        )
+        calibrationStore.record(
+            bucket = estimate.layoutBucket,
+            estimatedPages = rawEstimate,
+            realPages = correctedPages,
+        )
+        val absoluteError = abs(correctedPages - estimatedPages)
+        metrics.record(
+            PageEstimateMetric.ChapterCorrected(
+                generation = estimate.generation,
+                layoutSignature = estimate.layoutSignature,
+                chapterId = chapter.chapterId,
+                chapterIndex = chapterIndex,
+                contentLength = chapter.contentLength ?: -1,
+                rawEstimate = rawEstimate,
+                estimatedPages = estimatedPages,
+                realPages = correctedPages,
+                absoluteError = absoluteError,
+                relativeError = absoluteError.toFloat() / correctedPages,
+                calibrationBefore = calibrationBefore,
+                calibrationAfter = updatedCalibration,
+            )
+        )
+        chapter.contentHash?.let { contentHash ->
+            scope.launch(Dispatchers.IO) {
+                runCatching {
+                    exactPageCountStore.save(
+                        ExactChapterPageCount(
+                            bookId = estimate.bookId,
+                            chapterId = chapter.chapterId,
+                            chapterIndex = chapterIndex,
+                            contentHash = contentHash,
+                            layoutSignature = estimate.layoutSignature,
+                            engineVersion = estimate.config.textEngineVersion,
+                            pageCount = correctedPages,
+                            updatedAt = System.currentTimeMillis(),
+                        )
+                    )
+                }
+            }
+        }
+        return changed
+    }
+
+    private companion object {
+        const val DEFAULT_CHAPTER_LENGTH = 2_000
+
+        /** 逐书桶 = 书 × 排版,改字号后本书的旧拟合自然失效。 */
+        fun bookCalibrationBucket(bookId: String, layoutBucket: Long): Long =
+            layoutBucket * 1099511628211L xor bookId.hashCode().toLong()
+    }
+}

+ 132 - 0
app/src/main/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageIndex.kt

@@ -0,0 +1,132 @@
+package io.legado.app.ui.book.read.pageestimate
+
+class WholeBookPageIndex(
+    private val chapterCount: Int,
+) {
+    private var cumulativePages: IntArray? = null
+    private val exactFlags = BooleanArray(chapterCount)
+    private var exactChapterCount = 0
+    private var firstInexactIndex = 0
+
+    val isInitialized: Boolean
+        get() = cumulativePages != null
+
+    fun initialize(pageCounts: IntArray, exactChapters: Set<Int> = emptySet()) {
+        require(pageCounts.size == chapterCount)
+        var total = 0
+        cumulativePages = IntArray(chapterCount) { index ->
+            total += pageCounts[index].coerceAtLeast(1)
+            total
+        }
+        exactFlags.fill(false)
+        exactChapterCount = 0
+        exactChapters.forEach { index ->
+            if (index in exactFlags.indices && !exactFlags[index]) {
+                exactFlags[index] = true
+                exactChapterCount++
+            }
+        }
+        firstInexactIndex = 0
+        advanceFirstInexactIndex()
+    }
+
+    fun correct(chapterIndex: Int, realPageCount: Int): Boolean {
+        val pages = cumulativePages ?: return false
+        if (chapterIndex !in pages.indices) return false
+
+        val pageOffset = if (chapterIndex == 0) 0 else pages[chapterIndex - 1]
+        val oldPageCount = pages[chapterIndex] - pageOffset
+        val correctedPageCount = realPageCount.coerceAtLeast(1)
+        val delta = correctedPageCount - oldPageCount
+        val becameExact = !exactFlags[chapterIndex]
+        if (becameExact) {
+            exactFlags[chapterIndex] = true
+            exactChapterCount++
+            if (chapterIndex == firstInexactIndex) advanceFirstInexactIndex()
+        }
+        if (delta != 0) {
+            for (index in chapterIndex until pages.size) {
+                pages[index] += delta
+            }
+        }
+        return becameExact || delta != 0
+    }
+
+    fun updateEstimatedPageCount(chapterIndex: Int, estimatedPageCount: Int): Boolean {
+        val pages = cumulativePages ?: return false
+        if (chapterIndex !in pages.indices || exactFlags[chapterIndex]) return false
+
+        val pageOffset = if (chapterIndex == 0) 0 else pages[chapterIndex - 1]
+        val oldPageCount = pages[chapterIndex] - pageOffset
+        val delta = estimatedPageCount.coerceAtLeast(1) - oldPageCount
+        if (delta == 0) return false
+
+        for (index in chapterIndex until pages.size) {
+            pages[index] += delta
+        }
+        return true
+    }
+
+    fun invalidateExact(chapterIndex: Int, estimatedPageCount: Int): Boolean {
+        val pages = cumulativePages ?: return false
+        if (chapterIndex !in pages.indices || !exactFlags[chapterIndex]) return false
+
+        exactFlags[chapterIndex] = false
+        exactChapterCount--
+        firstInexactIndex = minOf(firstInexactIndex, chapterIndex)
+
+        val pageOffset = if (chapterIndex == 0) 0 else pages[chapterIndex - 1]
+        val oldPageCount = pages[chapterIndex] - pageOffset
+        val delta = estimatedPageCount.coerceAtLeast(1) - oldPageCount
+        if (delta != 0) {
+            for (index in chapterIndex until pages.size) {
+                pages[index] += delta
+            }
+        }
+        return true
+    }
+
+    fun getChapterPageCount(chapterIndex: Int): Int? {
+        val pages = cumulativePages ?: return null
+        if (chapterIndex !in pages.indices) return null
+        val pageOffset = if (chapterIndex == 0) 0 else pages[chapterIndex - 1]
+        return pages[chapterIndex] - pageOffset
+    }
+
+    fun isExact(chapterIndex: Int): Boolean =
+        chapterIndex in exactFlags.indices && exactFlags[chapterIndex]
+
+    fun getState(chapterIndex: Int, localPageIndex: Int): WholeBookPageState? {
+        val pages = cumulativePages ?: return null
+        if (chapterIndex !in pages.indices) return null
+        val pageOffset = if (chapterIndex == 0) 0 else pages[chapterIndex - 1]
+        val chapterPageCount = pages[chapterIndex] - pageOffset
+        val totalPages = pages.lastOrNull() ?: return null
+        return WholeBookPageState(
+            currentPage = (pageOffset + localPageIndex.coerceAtLeast(0) + 1)
+                .coerceAtMost(totalPages),
+            totalPages = totalPages,
+            chapterPage = localPageIndex.coerceAtLeast(0) + 1,
+            chapterPageCount = chapterPageCount,
+            currentChapterExact = exactFlags[chapterIndex],
+            allPreviousChaptersExact = chapterIndex <= firstInexactIndex,
+            estimated = exactChapterCount < chapterCount,
+        )
+    }
+
+    private fun advanceFirstInexactIndex() {
+        while (firstInexactIndex < chapterCount && exactFlags[firstInexactIndex]) {
+            firstInexactIndex++
+        }
+    }
+}
+
+data class WholeBookPageState(
+    val currentPage: Int,
+    val totalPages: Int,
+    val chapterPage: Int,
+    val chapterPageCount: Int,
+    val currentChapterExact: Boolean,
+    val allPreviousChaptersExact: Boolean,
+    val estimated: Boolean,
+)

+ 5 - 1
app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigContract.kt

@@ -6,11 +6,15 @@ import io.legado.app.domain.model.settings.LabSettings
 @Stable
 data class LabConfigUiState(
     val settings: LabSettings = LabSettings(),
+    val pageEstimateDiagnosticCount: Int = 0,
 )
 
 sealed interface LabConfigIntent {
     data class SetEnabled(val value: Boolean) : LabConfigIntent
     data class SetEInkDisplay(val value: Boolean) : LabConfigIntent
+    data object ExportPageEstimateDiagnostics : LabConfigIntent
 }
 
-sealed interface LabConfigEffect
+sealed interface LabConfigEffect {
+    data class SharePageEstimateDiagnostics(val text: String) : LabConfigEffect
+}

+ 39 - 0
app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigScreen.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.config.labConfig
 
+import android.content.Intent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -9,9 +10,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
@@ -20,16 +23,37 @@ import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import org.koin.androidx.compose.koinViewModel
+import kotlinx.coroutines.flow.collectLatest
 
 @Composable
 fun LabConfigRouteScreen(
     onBackClick: () -> Unit,
     viewModel: LabConfigViewModel = koinViewModel(),
 ) {
+    val context = LocalContext.current
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is LabConfigEffect.SharePageEstimateDiagnostics -> {
+                    val sendIntent = Intent(Intent.ACTION_SEND).apply {
+                        putExtra(Intent.EXTRA_TEXT, effect.text)
+                        type = "text/plain"
+                    }
+                    context.startActivity(
+                        Intent.createChooser(
+                            sendIntent,
+                            context.getString(R.string.lab_page_estimate_diagnostics_share_title),
+                        )
+                    )
+                }
+            }
+        }
+    }
     LabConfigScreen(
         state = viewModel.uiState.collectAsStateWithLifecycle().value,
         onIntent = viewModel::onIntent,
@@ -85,6 +109,21 @@ fun LabConfigScreen(
                         }
                     }
                 }
+                SplicedColumnGroup(title = stringResource(R.string.lab_diagnostics)) {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.lab_page_estimate_diagnostics_title),
+                        description = stringResource(
+                            R.string.lab_page_estimate_diagnostics_summary
+                        ),
+                        option = stringResource(
+                            R.string.lab_page_estimate_diagnostics_count,
+                            state.pageEstimateDiagnosticCount,
+                        ),
+                        onClick = {
+                            onIntent(LabConfigIntent.ExportPageEstimateDiagnostics)
+                        },
+                    )
+                }
             }
         }
     }

+ 12 - 1
app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigViewModel.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.config.labConfig
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.domain.gateway.LabSettingsGateway
+import io.legado.app.ui.book.read.pageestimate.LocalPageEstimateMetrics
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -14,7 +15,10 @@ class LabConfigViewModel(
     private val settingsGateway: LabSettingsGateway,
 ) : ViewModel() {
     private val _uiState = MutableStateFlow(
-        LabConfigUiState(settings = settingsGateway.currentSettings)
+        LabConfigUiState(
+            settings = settingsGateway.currentSettings,
+            pageEstimateDiagnosticCount = LocalPageEstimateMetrics.size(),
+        )
     )
     val uiState = _uiState.asStateFlow()
 
@@ -30,11 +34,18 @@ class LabConfigViewModel(
     }
 
     fun onIntent(intent: LabConfigIntent) {
+        if (intent is LabConfigIntent.ExportPageEstimateDiagnostics) {
+            _effects.tryEmit(
+                LabConfigEffect.SharePageEstimateDiagnostics(LocalPageEstimateMetrics.export())
+            )
+            return
+        }
         viewModelScope.launch {
             settingsGateway.update { settings ->
                 when (intent) {
                     is LabConfigIntent.SetEnabled -> settings.copy(enabled = intent.value)
                     is LabConfigIntent.SetEInkDisplay -> settings.copy(eInkDisplay = intent.value)
+                    LabConfigIntent.ExportPageEstimateDiagnostics -> settings
                 }
             }
         }

+ 2 - 0
app/src/main/res/values-zh-rCN/arrays.xml

@@ -104,6 +104,8 @@
         <item>时间及电量</item>
         <item>经典电量及时间</item>
         <item>时间及电量%</item>
+        <item>全文页数</item>
+        <item>全文页数及进度</item>
         <item>自定义</item>
     </string-array>
 

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

@@ -2210,6 +2210,11 @@
     <string name="lab_eink_display_title">墨水屏显示</string>
     <string name="lab_eink_display_summary">在平板界面显示墨水屏选项</string>
     <string name="lab_eink_display_hint">可在 主题 → 显示 中调整墨水屏设置</string>
+    <string name="lab_diagnostics">诊断</string>
+    <string name="lab_page_estimate_diagnostics_title">导出全文页码诊断</string>
+    <string name="lab_page_estimate_diagnostics_summary">分享本机最近的估算与校正记录,不会自动上传。</string>
+    <string name="lab_page_estimate_diagnostics_count">%1$d 条</string>
+    <string name="lab_page_estimate_diagnostics_share_title">分享全文页码诊断</string>
 
     <!-- 护眼模式 -->
     <string name="eye_protection">护眼模式</string>
@@ -2382,6 +2387,9 @@
     <string name="placeholder_page_index">当前页码</string>
     <string name="placeholder_page_size">本章页数</string>
     <string name="placeholder_read_progress">阅读进度</string>
+    <string name="placeholder_full_page_index">全文页码</string>
+    <string name="placeholder_full_page_size">全文页数</string>
+    <string name="full_book_pagination">全文分页</string>
     <!-- Missing zh-rTW/HK translations (683 keys) -->
 
     <!-- 日夜间/深浅色主题切换提醒 -->
@@ -2871,4 +2879,7 @@
     <string name="ai_prompt_task_identify_characters">人物识别</string>
     <string name="ai_prompt_task_identify_characters_desc">读取已下载章节并建议稳定的人物档案</string>
     <string name="ai_prompt_default_identify_characters">使用只读工具查看已下载的本地章节和既有人物。只返回 JSON:{"characters":[{"name":string,"aliases":[string],"voiceGender":"male|female|unknown","voiceAgeBand":"child|teen|young_adult|adult|elderly|unknown","role":"male_lead|female_lead|male_supporting|female_supporting|","personality":string,"summary":string,"evidence":string,"confidence":number}]}。不要把代词、泛称谓或一次性路人识别为人物;不确定时使用 unknown;不要与已有姓名或别名重复。</string>
+    <string name="whole_book_page_format">全文 %1$d / %2$d</string>
+    <string name="whole_book_page_estimated_format">全文 %1$s / %2$s</string>
+    <string name="whole_book_page_unavailable">全文 - / -</string>
 </resources>

+ 2 - 0
app/src/main/res/values-zh-rHK/arrays.xml

@@ -73,6 +73,8 @@
         <item>時間同埋電量</item>
         <item>時間同埋電量經典</item>
         <item>時間同埋電量%</item>
+        <item>全書頁數</item>
+        <item>全書頁數及進度</item>
         <item>自訂</item>
     </string-array>
 

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

@@ -2659,4 +2659,12 @@
     <string name="character_delete_relations">同時刪除關聯關係</string>
     <string name="character_delete_events">同時刪除人物事件</string>
     <string name="character_deleted">已刪除人物</string>
+    <string name="whole_book_page_format">全書 %1$d / %2$d</string>
+    <string name="whole_book_page_estimated_format">全書 %1$s / %2$s</string>
+    <string name="lab_diagnostics">診斷</string>
+    <string name="lab_page_estimate_diagnostics_title">匯出全文頁碼診斷</string>
+    <string name="lab_page_estimate_diagnostics_summary">分享本機最近的估算與校正記錄,不會自動上傳。</string>
+    <string name="lab_page_estimate_diagnostics_count">%1$d 條</string>
+    <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
+    <string name="whole_book_page_unavailable">全書 - / -</string>
 </resources>

+ 2 - 0
app/src/main/res/values-zh-rTW/arrays.xml

@@ -110,6 +110,8 @@
         <item>時間及電量</item>
         <item>時間及電量经典</item>
         <item>時間及電量%</item>
+        <item>全書頁數</item>
+        <item>全書頁數及進度</item>
         <item>自訂</item>
     </string-array>
 

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

@@ -2664,4 +2664,12 @@
     <string name="character_delete_relations">同時刪除關聯關係</string>
     <string name="character_delete_events">同時刪除人物事件</string>
     <string name="character_deleted">已刪除人物</string>
+    <string name="whole_book_page_format">全書 %1$d / %2$d</string>
+    <string name="whole_book_page_estimated_format">全書 %1$s / %2$s</string>
+    <string name="lab_diagnostics">診斷</string>
+    <string name="lab_page_estimate_diagnostics_title">匯出全文頁碼診斷</string>
+    <string name="lab_page_estimate_diagnostics_summary">分享本機最近的估算與校正記錄,不會自動上傳。</string>
+    <string name="lab_page_estimate_diagnostics_count">%1$d 筆</string>
+    <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
+    <string name="whole_book_page_unavailable">全書 - / -</string>
 </resources>

+ 2 - 0
app/src/main/res/values/arrays.xml

@@ -201,6 +201,8 @@
 		<item>Time and Battery</item>
 		<item>Time and Battery Classic</item>
 		<item>Time and Battery%</item>
+		<item>Whole-book pages</item>
+		<item>Whole-book pages and progress</item>
 		<item>Custom</item>
 	</string-array>
 

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

@@ -1213,6 +1213,9 @@
     <string name="placeholder_page_index">PageIndex</string>
     <string name="placeholder_page_size">PageSize</string>
     <string name="placeholder_read_progress">ReadProgress</string>
+    <string name="placeholder_full_page_index">FullPageIndex</string>
+    <string name="placeholder_full_page_size">FullPageSize</string>
+    <string name="full_book_pagination">Full-book pagination</string>
     <string name="sort_by_size">Sort by size</string>
     <string name="welcome_style">Launch interface style</string>
     <string name="welcome_style_summary">Startup screen image and whether to display text, etc.</string>
@@ -2231,6 +2234,11 @@
     <string name="lab_eink_display_title">E-Ink Display</string>
     <string name="lab_eink_display_summary">Show E-Ink display option in the tablet interface</string>
     <string name="lab_eink_display_hint">You can adjust E-Ink display settings in Theme → Display options</string>
+    <string name="lab_diagnostics">Diagnostics</string>
+    <string name="lab_page_estimate_diagnostics_title">Export page estimate diagnostics</string>
+    <string name="lab_page_estimate_diagnostics_summary">Share the latest local-only estimate and correction records. Nothing is uploaded automatically.</string>
+    <string name="lab_page_estimate_diagnostics_count">%1$d records</string>
+    <string name="lab_page_estimate_diagnostics_share_title">Share page estimate diagnostics</string>
 
     <!-- Eye Protection -->
     <string name="eye_protection">Eye Protection</string>
@@ -2882,4 +2890,7 @@
     <string name="ai_prompt_task_identify_characters">Character Identification</string>
     <string name="ai_prompt_task_identify_characters_desc">Read downloaded chapters and suggest stable character profiles</string>
     <string name="ai_prompt_default_identify_characters">Use read-only tools to inspect downloaded local chapters and existing characters. Return JSON only: {"characters":[{"name":string,"aliases":[string],"voiceGender":"male|female|unknown","voiceAgeBand":"child|teen|young_adult|adult|elderly|unknown","role":"male_lead|female_lead|male_supporting|female_supporting|","personality":string,"summary":string,"evidence":string,"confidence":number}]}. Do not create pronouns, generic titles, or one-off passers-by as characters. Use unknown rather than guessing. Do not duplicate existing names or aliases.</string>
+    <string name="whole_book_page_format">Book %1$d / %2$d</string>
+    <string name="whole_book_page_estimated_format">Book %1$s / %2$s</string>
+    <string name="whole_book_page_unavailable">Book - / -</string>
 </resources>

+ 72 - 0
app/src/test/java/io/legado/app/model/LatestChapterTaskSchedulerTest.kt

@@ -0,0 +1,72 @@
+package io.legado.app.model
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LatestChapterTaskSchedulerTest {
+
+    @Test
+    fun `same chapter keeps running task and only latest pending task`() = runBlocking {
+        val scheduler = LatestChapterTaskScheduler<String>(this)
+        val gate = CompletableDeferred<Unit>()
+        val runningStarted = CompletableDeferred<Unit>()
+        val events = mutableListOf<String>()
+
+        scheduler.submit("chapter") {
+            events += "running-start"
+            runningStarted.complete(Unit)
+            gate.await()
+            events += "running-end"
+        }
+        withTimeout(2_000) { runningStarted.await() }
+
+        val replaced = scheduler.submit("chapter") { events += "replaced" }
+        val latest = scheduler.submit("chapter") { events += "latest" }
+        assertTrue(replaced.isCancelled)
+        assertEquals(
+            LatestChapterTaskScheduler.TaskState(running = true, pending = true),
+            scheduler.stateOf("chapter"),
+        )
+
+        gate.complete(Unit)
+        withTimeout(2_000) { latest.await() }
+
+        assertEquals(listOf("running-start", "running-end", "latest"), events)
+        assertEquals(LatestChapterTaskScheduler.TaskState(), scheduler.stateOf("chapter"))
+    }
+
+    @Test
+    fun `different chapters can run independently`() = runBlocking {
+        val scheduler = LatestChapterTaskScheduler<String>(this)
+        val firstStarted = CompletableDeferred<Unit>()
+        val secondStarted = CompletableDeferred<Unit>()
+
+        scheduler.submit("first") {
+            firstStarted.complete(Unit)
+            awaitCancellation()
+        }
+        scheduler.submit("second") {
+            secondStarted.complete(Unit)
+            awaitCancellation()
+        }
+        withTimeout(2_000) {
+            firstStarted.await()
+            secondStarted.await()
+        }
+
+        assertTrue(firstStarted.isCompleted)
+        assertTrue(secondStarted.isCompleted)
+        assertTrue(scheduler.stateOf("first").running)
+        assertTrue(scheduler.stateOf("second").running)
+
+        scheduler.cancelAll()
+        assertFalse(scheduler.stateOf("first").running)
+        assertFalse(scheduler.stateOf("second").running)
+    }
+}

+ 32 - 0
app/src/test/java/io/legado/app/ui/book/read/WholeBookPageConfigInvalidationTest.kt

@@ -0,0 +1,32 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WholeBookPageConfigInvalidationTest {
+
+    @Test
+    fun `pagination-affecting updates rebuild whole-book page index`() {
+        val updates = listOf(
+            ConfigUpdate.TitleMode(2),
+            ConfigUpdate.PageAnim(1),
+            ConfigUpdate.TextFullJustify(true),
+            ConfigUpdate.ChineseConverterType(1),
+        )
+
+        updates.forEach { update ->
+            assertTrue(
+                update.javaClass.simpleName,
+                ConfigUpdateAction.RebuildWholeBookPageIndex in update.actions,
+            )
+        }
+    }
+
+    @Test
+    fun `color-only updates do not rebuild whole-book page index`() {
+        val update = ConfigUpdate.TextColor(0x123456)
+
+        assertFalse(ConfigUpdateAction.RebuildWholeBookPageIndex in update.actions)
+    }
+}

+ 93 - 0
app/src/test/java/io/legado/app/ui/book/read/pageestimate/HeuristicPageEstimatorTest.kt

@@ -0,0 +1,93 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HeuristicPageEstimatorTest {
+
+    private val estimator = HeuristicPageEstimator()
+
+    /**
+     * 估算器现在返回**连续**页数,取整和下限由 [PageEstimateCalibration.apply] 负责 ——
+     * 空章节只剩标题和章末留白那一点,走完取整仍是 1 页。
+     */
+    @Test
+    fun `empty content occupies only the fixed chrome`() {
+        val fraction = estimator.estimate(input(contentLength = 0))
+
+        assertTrue("expected 0f..1f but was $fraction", fraction in 0f..1f)
+        assertEquals(1, PageEstimateCalibration().apply(fraction))
+    }
+
+    @Test
+    fun `larger text estimates more pages`() {
+        val smallText = estimator.estimate(input(contentLength = 10_000, textSizePx = 24f))
+        val largeText = estimator.estimate(input(contentLength = 10_000, textSizePx = 48f))
+
+        assertTrue(largeText > smallText)
+    }
+
+    @Test
+    fun `larger real font height estimates more pages`() {
+        val compactFont = estimator.estimate(input(contentLength = 10_000, textHeightPx = 32f))
+        val tallFont = estimator.estimate(input(contentLength = 10_000, textHeightPx = 48f))
+
+        assertTrue(tallFont > compactFont)
+    }
+
+    /** 正文不足一页,但标题和章末留白把它顶过 1.0,取整后成为第 2 页。 */
+    @Test
+    fun `fixed chapter chrome can push content onto another page`() {
+        val fraction = estimator.estimate(
+            input(
+                contentLength = 42,
+                titleLength = 1,
+                textSizePx = 10f,
+                textHeightPx = 10f,
+                lineSpacingPx = 0f,
+                paragraphSpacingPx = 0f,
+                contentWidthPx = 100,
+                contentHeightPx = 100,
+                titleTopSpacingPx = 10f,
+                titleBottomSpacingPx = 10f,
+                endPaddingPx = 20f,
+            )
+        )
+
+        assertTrue("expected >1f but was $fraction", fraction > 1f)
+        assertEquals(2, PageEstimateCalibration().apply(fraction))
+    }
+
+    private fun input(
+        contentLength: Int,
+        titleLength: Int = 10,
+        textSizePx: Float = 32f,
+        textHeightPx: Float = textSizePx * 1.2f,
+        lineSpacingPx: Float = 8f,
+        paragraphSpacingPx: Float = 4f,
+        contentWidthPx: Int = 1080,
+        contentHeightPx: Int = 1920,
+        titleTopSpacingPx: Float = 20f,
+        titleBottomSpacingPx: Float = 20f,
+        endPaddingPx: Float = 20f,
+    ) =
+        ChapterPageEstimateInput(
+            readerType = 0,
+            titleLength = titleLength,
+            includeTitle = true,
+            contentLength = contentLength,
+            textSizePx = textSizePx,
+            textHeightPx = textHeightPx,
+            lineSpacingPx = lineSpacingPx,
+            paragraphSpacingPx = paragraphSpacingPx,
+            titleTextSizePx = textSizePx * 1.25f,
+            titleTextHeightPx = textHeightPx * 1.25f,
+            titleLineSpacingPx = 0f,
+            titleTopSpacingPx = titleTopSpacingPx,
+            titleBottomSpacingPx = titleBottomSpacingPx,
+            endPaddingPx = endPaddingPx,
+            contentWidthPx = contentWidthPx,
+            contentHeightPx = contentHeightPx,
+        )
+}

+ 49 - 0
app/src/test/java/io/legado/app/ui/book/read/pageestimate/PageEstimateConfigTest.kt

@@ -0,0 +1,49 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class PageEstimateConfigTest {
+
+    private val config = PageEstimateConfig(
+        readerType = 0,
+        textSizePx = 32f,
+        textHeightPx = 38f,
+        lineSpacingPx = 8f,
+        paragraphSpacingPx = 4f,
+        titleTextSizePx = 42f,
+        titleTextHeightPx = 48f,
+        titleLineSpacingPx = 8f,
+        titleTopSpacingPx = 16f,
+        titleBottomSpacingPx = 12f,
+        endPaddingPx = 20f,
+        contentWidthPx = 1080,
+        contentHeightPx = 1920,
+        fontKey = "font-a",
+        contentKey = "rule-a",
+    )
+
+    @Test
+    fun `layout signature is stable for equal configuration`() {
+        assertEquals(config.layoutSignature, config.copy().layoutSignature)
+        assertEquals(config.calibrationBucket, config.copy().calibrationBucket)
+    }
+
+    @Test
+    fun `content processing affects strict signature but not calibration bucket`() {
+        val changed = config.copy(contentKey = "rule-b")
+
+        assertNotEquals(config.layoutSignature, changed.layoutSignature)
+        assertEquals(config.calibrationBucket, changed.calibrationBucket)
+    }
+
+
+    @Test
+    fun `layout dimensions affect signature and calibration bucket`() {
+        val changed = config.copy(contentWidthPx = 900)
+
+        assertNotEquals(config.layoutSignature, changed.layoutSignature)
+        assertNotEquals(config.calibrationBucket, changed.calibrationBucket)
+    }
+}

+ 45 - 0
app/src/test/java/io/legado/app/ui/book/read/pageestimate/PageEstimateMetricsTest.kt

@@ -0,0 +1,45 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class PageEstimateMetricsTest {
+
+    @Before
+    fun clearMetrics() {
+        LocalPageEstimateMetrics.clear()
+    }
+
+    @Test
+    fun `keeps only recent diagnostics and exports no chapter id`() {
+        repeat(205) { index ->
+            LocalPageEstimateMetrics.record(
+                PageEstimateMetric.ChapterCorrected(
+                    generation = index.toLong(),
+                    layoutSignature = 10L,
+                    chapterId = "private://chapter-$index",
+                    chapterIndex = index,
+                    contentLength = 1_000,
+                    rawEstimate = 5f,
+                    estimatedPages = 5,
+                    realPages = 6,
+                    absoluteError = 1,
+                    relativeError = 1f / 6f,
+                    calibrationBefore = PageEstimateCalibration(),
+                    calibrationAfter = PageEstimateCalibration(),
+                )
+            )
+        }
+
+        val exported = LocalPageEstimateMetrics.export()
+
+        assertEquals(200, LocalPageEstimateMetrics.size())
+        assertTrue(exported.contains("entryCount=200"))
+        assertTrue(exported.contains("chapterIndex=204"))
+        assertFalse(exported.contains("chapterIndex=0,"))
+        assertFalse(exported.contains("private://"))
+    }
+}

+ 108 - 0
app/src/test/java/io/legado/app/ui/book/read/pageestimate/PageEstimateSamplesTest.kt

@@ -0,0 +1,108 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.math.ceil
+import kotlin.math.roundToInt
+
+class PageEstimateSamplesTest {
+
+    private fun samplesOf(vararg points: Pair<Double, Double>) =
+        points.fold(PageEstimateSamples()) { acc, (x, y) -> acc.plus(x, y) }
+
+    @Test
+    fun `recovers a known affine relation`() {
+        // y = 1.4x + 0.5
+        val samples = samplesOf(
+            1.0 to 1.9, 2.0 to 3.3, 4.0 to 6.1, 8.0 to 11.7, 12.0 to 17.3,
+        )
+
+        val calibration = samples.fit()
+
+        assertTrue(calibration.fitted)
+        assertEquals(1.4f, calibration.slope, 1e-4f)
+        assertEquals(0.5f, calibration.intercept, 1e-4f)
+        assertEquals(5, calibration.sampleCount)
+    }
+
+    @Test
+    fun `too few samples stay unfitted`() {
+        val samples = samplesOf(1.0 to 2.0, 2.0 to 3.0, 3.0 to 4.0)
+
+        assertFalse(samples.fit().fitted)
+        assertEquals(3, samples.fit().sampleCount)
+    }
+
+    /** 所有章节估算值相同(行列式退化)时斜率无定义,必须退回冷启动而不是产出 NaN。 */
+    @Test
+    fun `identical estimates cannot produce a fit`() {
+        val samples = samplesOf(5.0 to 6.0, 5.0 to 7.0, 5.0 to 5.0, 5.0 to 8.0, 5.0 to 6.0)
+
+        val calibration = samples.fit()
+
+        assertFalse(calibration.fitted)
+        assertTrue(calibration.slope.isFinite())
+    }
+
+    @Test
+    fun `implausible fits are rejected`() {
+        // 斜率约 12,远超 MAX_SLOPE
+        val steep = samplesOf(1.0 to 12.0, 2.0 to 24.0, 3.0 to 36.0, 4.0 to 48.0)
+        assertFalse(steep.fit().fitted)
+
+        // 截距约 -20,低于 MIN_INTERCEPT
+        val shifted = samplesOf(10.0 to 1.0, 11.0 to 2.0, 12.0 to 3.0, 13.0 to 4.0)
+        assertFalse(shifted.fit().fitted)
+    }
+
+    @Test
+    fun `unfitted calibration is exactly the old ceil behaviour`() {
+        val cold = PageEstimateCalibration()
+
+        listOf(0.2f, 1f, 2.1f, 6.99f, 12f).forEach { estimate ->
+            assertEquals(
+                ceil(estimate).toInt().coerceAtLeast(1),
+                cold.apply(estimate),
+            )
+        }
+    }
+
+    @Test
+    fun `fitted calibration rounds instead of ceiling`() {
+        val fitted = PageEstimateCalibration(
+            slope = 1.5f,
+            intercept = 0.4f,
+            sampleCount = 8,
+            fitted = true,
+        )
+
+        listOf(2f, 3.3f, 7.8f).forEach { estimate ->
+            assertEquals((1.5f * estimate + 0.4f).roundToInt(), fitted.apply(estimate))
+        }
+        assertEquals(1, fitted.apply(0f))
+    }
+
+    @Test
+    fun `non finite input never escapes`() {
+        assertEquals(1, PageEstimateCalibration().apply(Float.NaN))
+        assertEquals(1, PageEstimateCalibration().apply(Float.POSITIVE_INFINITY))
+    }
+
+    @Test
+    fun `effective sample window is capped while fit follows recent data`() {
+        var samples = PageEstimateSamples()
+        repeat(400) { index ->
+            val x = (index % 20 + 1).toDouble()
+            samples = samples.plus(x, 1.2 * x + 0.5)
+        }
+
+        val calibration = samples.fit()
+
+        assertEquals(256, samples.count)
+        assertTrue(calibration.fitted)
+        assertEquals(1.2f, calibration.slope, 1e-3f)
+        assertEquals(0.5f, calibration.intercept, 1e-3f)
+    }
+}

+ 379 - 0
app/src/test/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageCoordinatorTest.kt

@@ -0,0 +1,379 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WholeBookPageCoordinatorTest {
+
+    private val config = PageEstimateConfig(
+        readerType = 0,
+        textSizePx = 32f,
+        textHeightPx = 38.4f,
+        lineSpacingPx = 8f,
+        paragraphSpacingPx = 4f,
+        titleTextSizePx = 40f,
+        titleTextHeightPx = 48f,
+        titleLineSpacingPx = 0f,
+        titleTopSpacingPx = 20f,
+        titleBottomSpacingPx = 20f,
+        endPaddingPx = 20f,
+        contentWidthPx = 1080,
+        contentHeightPx = 1920,
+    )
+
+    @Test
+    fun `pending exact correction is replayed after estimate`() = runBlocking {
+        val changed = CompletableDeferred<Unit>()
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { 3f }) {
+            changed.complete(Unit)
+        }
+        val loaderGate = CompletableDeferred<Unit>()
+        val generation = coordinator.requestEstimate(config, BOOK_ID) {
+            loaderGate.await()
+            chapters(2)
+        }
+
+        coordinator.correctChapter(0, realPageCount = 7, layoutGeneration = generation)
+        loaderGate.complete(Unit)
+        withTimeout(2_000) { changed.await() }
+
+        val state = requireNotNull(coordinator.getState(0, 0))
+        assertEquals(10, state.totalPages)
+        assertEquals(7, state.chapterPageCount)
+    }
+
+    @Test
+    fun `older generation cannot publish or correct newer result`() = runBlocking {
+        val oldLoader = CompletableDeferred<Unit>()
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
+        val oldGeneration = coordinator.requestEstimate(config, BOOK_ID) {
+            oldLoader.await()
+            chapters(1, contentLength = 9)
+        }
+        val newGeneration = coordinator.requestEstimate(config, BOOK_ID) {
+            chapters(1, contentLength = 4)
+        }
+
+        awaitState(coordinator)
+        coordinator.correctChapter(0, 20, oldGeneration)
+        oldLoader.complete(Unit)
+        delay(50)
+
+        assertEquals(4, coordinator.getState(0, 0)?.totalPages)
+        coordinator.clear()
+        assertNull(coordinator.getState(0, 0))
+        assertEquals(newGeneration + 1, coordinator.generation)
+    }
+
+    @Test
+    fun `book average length is fallback when every chapter length is unknown`() = runBlocking {
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
+        coordinator.requestEstimate(config, BOOK_ID, fallbackContentLength = 5_000) {
+            chapters(2, contentLength = null)
+        }
+
+        awaitState(coordinator)
+
+        assertEquals(10_000, coordinator.getState(0, 0)?.totalPages)
+    }
+
+    @Test
+    fun `known chapter median is preferred as unknown length fallback`() = runBlocking {
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
+        coordinator.requestEstimate(config, BOOK_ID, fallbackContentLength = 9_000) {
+            listOf(
+                ChapterLengthInfo(0, "chapter-0", 10, contentLength = 100),
+                ChapterLengthInfo(1, "chapter-1", 10, contentLength = null),
+                ChapterLengthInfo(2, "chapter-2", 10, contentLength = 300),
+            )
+        }
+
+        awaitState(coordinator)
+
+        assertEquals(700, coordinator.getState(0, 0)?.totalPages)
+    }
+
+    @Test
+    fun `loaded content length updates only that estimated chapter`() = runBlocking {
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
+        val generation = coordinator.requestEstimate(config, BOOK_ID) { chapters(3, contentLength = 100) }
+        awaitState(coordinator)
+
+        coordinator.updateChapterLength(1, actualLength = 250, layoutGeneration = generation)
+
+        assertEquals(450, coordinator.getState(0, 0)?.totalPages)
+        assertEquals(250, coordinator.getState(1, 0)?.chapterPageCount)
+        assertEquals(100, coordinator.getState(2, 0)?.chapterPageCount)
+    }
+
+    @Test
+    fun `length update arriving before estimate is replayed`() = runBlocking {
+        val loaderGate = CompletableDeferred<Unit>()
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
+        val generation = coordinator.requestEstimate(config, BOOK_ID) {
+            loaderGate.await()
+            chapters(2, contentLength = null)
+        }
+
+        coordinator.updateChapterLength(0, actualLength = 400, layoutGeneration = generation)
+        loaderGate.complete(Unit)
+        awaitState(coordinator)
+
+        assertEquals(2_400, coordinator.getState(0, 0)?.totalPages)
+        assertEquals(400, coordinator.getState(0, 0)?.chapterPageCount)
+    }
+
+    @Test
+    fun `length update cannot replace exact page count`() = runBlocking {
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
+        val generation = coordinator.requestEstimate(config, BOOK_ID) { chapters(1, contentLength = 100) }
+        awaitState(coordinator)
+        coordinator.correctChapter(0, realPageCount = 7, layoutGeneration = generation)
+
+        coordinator.updateChapterLength(0, actualLength = 500, layoutGeneration = generation)
+
+        assertEquals(7, coordinator.getState(0, 0)?.totalPages)
+        assertEquals(7, coordinator.getState(0, 0)?.chapterPageCount)
+    }
+
+    @Test
+    fun `older generation length update is ignored`() = runBlocking {
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
+        val oldGeneration = coordinator.requestEstimate(config, BOOK_ID) { chapters(1, contentLength = 100) }
+        awaitState(coordinator)
+        coordinator.requestEstimate(config, BOOK_ID) { chapters(1, contentLength = 200) }
+        awaitTotalPages(coordinator, expected = 200)
+
+        coordinator.updateChapterLength(0, actualLength = 500, layoutGeneration = oldGeneration)
+
+        assertEquals(200, coordinator.getState(0, 0)?.totalPages)
+    }
+
+    @Test
+    fun `exact result records metrics and both calibration buckets`() = runBlocking {
+        val calibrationStore = FakeCalibrationStore()
+        val recordedMetrics = mutableListOf<PageEstimateMetric>()
+        val coordinator = WholeBookPageCoordinator(
+            scope = this,
+            estimator = ChapterPageEstimator { 10f },
+            calibrationStore = calibrationStore,
+            metrics = PageEstimateMetrics { recordedMetrics += it },
+        )
+        val generation = coordinator.requestEstimate(config, BOOK_ID) { chapters(1) }
+        awaitState(coordinator)
+
+        coordinator.correctChapter(0, realPageCount = 20, layoutGeneration = generation)
+
+        val correction = recordedMetrics.filterIsInstance<PageEstimateMetric.ChapterCorrected>()
+            .single()
+        assertEquals(10f, correction.rawEstimate, 0.0001f)
+        assertEquals(10, correction.estimatedPages)
+        assertEquals(20, correction.realPages)
+        assertEquals(0.5f, correction.relativeError, 0.0001f)
+        // 逐书桶用于本书自己的拟合,排版桶用于下一本新书的冷启动先验,两边都要落。
+        assertEquals(2, calibrationStore.recorded.size)
+        assertEquals(setOf(10f to 20), calibrationStore.recorded.values.toSet())
+    }
+
+    /** 本书已有拟合时优先用它;样本不足才退回排版级先验。 */
+    @Test
+    fun `book scoped fit wins over the layout wide prior`() = runBlocking {
+        val calibrationStore = FakeCalibrationStore()
+        val coordinator = WholeBookPageCoordinator(
+            scope = this,
+            estimator = ChapterPageEstimator { 4f },
+            calibrationStore = calibrationStore,
+        )
+        calibrationStore.fitEverything(slope = 3f, intercept = 0f)
+
+        coordinator.requestEstimate(config, BOOK_ID) { chapters(2) }
+        awaitTotalPages(coordinator, expected = 24)
+
+        assertEquals(24, coordinator.getState(0, 0)?.totalPages)
+    }
+
+    /** 未拟合时必须逐位等价于引入回归之前的 ceil 行为。 */
+    @Test
+    fun `cold start still ceils the continuous estimate`() = runBlocking {
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { 2.1f })
+
+        coordinator.requestEstimate(config, BOOK_ID) { chapters(3) }
+        awaitState(coordinator)
+
+        assertEquals(9, coordinator.getState(0, 0)?.totalPages)
+    }
+
+    @Test
+    fun `matching cached exact count overrides calibrated estimate without recording OLS`() = runBlocking {
+        val calibrationStore = FakeCalibrationStore().apply {
+            fitEverything(slope = 2f, intercept = 0f)
+        }
+        val exactStore = FakeExactPageCountStore().apply {
+            values += exactCount(chapterIndex = 0, contentHash = 11L, pageCount = 7)
+        }
+        val coordinator = WholeBookPageCoordinator(
+            scope = this,
+            estimator = ChapterPageEstimator { 10f },
+            calibrationStore = calibrationStore,
+            exactPageCountStore = exactStore,
+        )
+
+        val generation = coordinator.requestEstimate(config, BOOK_ID) {
+            listOf(
+                ChapterLengthInfo(0, "chapter-0", 10, contentLength = 100, contentHash = 11L),
+                ChapterLengthInfo(1, "chapter-1", 10, contentLength = 100, contentHash = 12L),
+            )
+        }
+        awaitState(coordinator)
+
+        assertEquals(27, coordinator.getState(0, 0)?.totalPages)
+        assertTrue(coordinator.getState(0, 0)?.currentChapterExact == true)
+        coordinator.correctChapter(0, realPageCount = 7, layoutGeneration = generation)
+        assertTrue(calibrationStore.recorded.isEmpty())
+        assertEquals(1, exactStore.values.size)
+    }
+
+    @Test
+    fun `content hash mismatch drops stale exact count`() = runBlocking {
+        val exactStore = FakeExactPageCountStore().apply {
+            values += exactCount(chapterIndex = 0, contentHash = 11L, pageCount = 7)
+        }
+        val coordinator = WholeBookPageCoordinator(
+            scope = this,
+            estimator = ChapterPageEstimator { 10f },
+            exactPageCountStore = exactStore,
+        )
+
+        coordinator.requestEstimate(config, BOOK_ID) {
+            listOf(ChapterLengthInfo(0, "chapter-0", 10, contentLength = 100, contentHash = 99L))
+        }
+        awaitState(coordinator)
+
+        assertEquals(10, coordinator.getState(0, 0)?.totalPages)
+        assertFalse(coordinator.getState(0, 0)?.currentChapterExact == true)
+        assertTrue(exactStore.values.isEmpty())
+    }
+
+    @Test
+    fun `new exact result is persisted with strict layout identity`() = runBlocking {
+        val exactStore = FakeExactPageCountStore()
+        val coordinator = WholeBookPageCoordinator(
+            scope = this,
+            estimator = ChapterPageEstimator { 10f },
+            exactPageCountStore = exactStore,
+        )
+        val generation = coordinator.requestEstimate(config, BOOK_ID) {
+            listOf(ChapterLengthInfo(0, "chapter-0", 10, contentLength = 100, contentHash = 11L))
+        }
+        awaitState(coordinator)
+
+        coordinator.correctChapter(0, realPageCount = 7, layoutGeneration = generation)
+        withTimeout(2_000) {
+            while (exactStore.values.isEmpty()) delay(10)
+        }
+
+        val saved = exactStore.values.single()
+        assertEquals(config.layoutSignature, saved.layoutSignature)
+        assertEquals(config.textEngineVersion, saved.engineVersion)
+        assertEquals(11L, saved.contentHash)
+        assertEquals(7, saved.pageCount)
+    }
+
+    private suspend fun awaitState(coordinator: WholeBookPageCoordinator) {
+        withTimeout(2_000) {
+            while (coordinator.getState(0, 0) == null) delay(10)
+        }
+    }
+
+    private suspend fun awaitTotalPages(
+        coordinator: WholeBookPageCoordinator,
+        expected: Int,
+    ) {
+        withTimeout(2_000) {
+            while (coordinator.getState(0, 0)?.totalPages != expected) delay(10)
+        }
+    }
+
+    private companion object {
+        const val BOOK_ID = "book://test"
+    }
+
+    private fun chapters(count: Int, contentLength: Int? = 1_000) =
+        List(count) { index ->
+            ChapterLengthInfo(index, "chapter-$index", 10, contentLength = contentLength)
+        }
+
+    private fun exactCount(
+        chapterIndex: Int,
+        contentHash: Long,
+        pageCount: Int,
+    ) = ExactChapterPageCount(
+        bookId = BOOK_ID,
+        chapterId = "chapter-$chapterIndex",
+        chapterIndex = chapterIndex,
+        contentHash = contentHash,
+        layoutSignature = config.layoutSignature,
+        engineVersion = config.textEngineVersion,
+        pageCount = pageCount,
+        updatedAt = 1L,
+    )
+
+    /** 只验协调器的接线(用哪个桶、往哪些桶写),最小二乘本身在 PageEstimateSamplesTest 里测。 */
+    private class FakeCalibrationStore : PageEstimateCalibrationStore {
+        private var fit: PageEstimateCalibration? = null
+        val recorded = mutableMapOf<Long, Pair<Float, Int>>()
+
+        override fun get(bucket: Long): PageEstimateCalibration =
+            fit ?: PageEstimateCalibration()
+
+        override fun record(
+            bucket: Long,
+            estimatedPages: Float,
+            realPages: Int,
+        ): PageEstimateCalibration {
+            recorded[bucket] = estimatedPages to realPages
+            return get(bucket)
+        }
+
+        fun fitEverything(slope: Float, intercept: Float) {
+            fit = PageEstimateCalibration(
+                slope = slope,
+                intercept = intercept,
+                sampleCount = PageEstimateCalibration.MIN_SAMPLES,
+                fitted = true,
+            )
+        }
+    }
+
+    private class FakeExactPageCountStore : ExactChapterPageCountStore {
+        val values = mutableListOf<ExactChapterPageCount>()
+
+        override suspend fun load(
+            bookId: String,
+            layoutSignature: Long,
+            engineVersion: Int,
+        ) = values.filter {
+            it.bookId == bookId && it.layoutSignature == layoutSignature &&
+                it.engineVersion == engineVersion
+        }
+
+        override suspend fun save(value: ExactChapterPageCount) {
+            values.removeAll {
+                it.bookId == value.bookId && it.chapterId == value.chapterId &&
+                    it.layoutSignature == value.layoutSignature
+            }
+            values += value
+        }
+
+        override suspend fun deleteChapter(bookId: String, chapterId: String) {
+            values.removeAll { it.bookId == bookId && it.chapterId == chapterId }
+        }
+    }
+}

+ 90 - 0
app/src/test/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageIndexTest.kt

@@ -0,0 +1,90 @@
+package io.legado.app.ui.book.read.pageestimate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WholeBookPageIndexTest {
+
+    @Test
+    fun `query uses cumulative page counts`() {
+        val index = WholeBookPageIndex(3)
+        index.initialize(intArrayOf(3, 5, 2))
+
+        val state = requireNotNull(index.getState(chapterIndex = 1, localPageIndex = 2))
+
+        assertEquals(6, state.currentPage)
+        assertEquals(10, state.totalPages)
+        assertEquals(3, state.chapterPage)
+        assertEquals(5, state.chapterPageCount)
+        assertTrue(state.estimated)
+    }
+
+    @Test
+    fun `correction shifts current and following cumulative totals`() {
+        val index = WholeBookPageIndex(3)
+        index.initialize(intArrayOf(3, 5, 2))
+
+        assertTrue(index.correct(chapterIndex = 1, realPageCount = 8))
+
+        val corrected = requireNotNull(index.getState(chapterIndex = 1, localPageIndex = 0))
+        val following = requireNotNull(index.getState(chapterIndex = 2, localPageIndex = 0))
+        assertEquals(13, corrected.totalPages)
+        assertEquals(8, corrected.chapterPageCount)
+        assertEquals(12, following.currentPage)
+        assertTrue(corrected.currentChapterExact)
+        assertFalse(corrected.allPreviousChaptersExact)
+    }
+
+    @Test
+    fun `estimated length update shifts suffix without marking chapter exact`() {
+        val index = WholeBookPageIndex(3)
+        index.initialize(intArrayOf(3, 5, 2))
+
+        assertTrue(index.updateEstimatedPageCount(chapterIndex = 1, estimatedPageCount = 8))
+
+        val updated = requireNotNull(index.getState(chapterIndex = 1, localPageIndex = 0))
+        assertEquals(13, updated.totalPages)
+        assertEquals(8, updated.chapterPageCount)
+        assertFalse(updated.currentChapterExact)
+        assertTrue(updated.estimated)
+    }
+
+    @Test
+    fun `estimated length update cannot replace exact chapter`() {
+        val index = WholeBookPageIndex(1)
+        index.initialize(intArrayOf(3))
+        index.correct(chapterIndex = 0, realPageCount = 5)
+
+        assertFalse(index.updateEstimatedPageCount(chapterIndex = 0, estimatedPageCount = 8))
+        assertEquals(5, index.getState(chapterIndex = 0, localPageIndex = 0)?.totalPages)
+    }
+
+    @Test
+    fun `all exact chapters clear estimated flag`() {
+        val index = WholeBookPageIndex(2)
+        index.initialize(intArrayOf(2, 2))
+
+        index.correct(1, 2)
+        index.correct(0, 2)
+
+        val state = requireNotNull(index.getState(1, 0))
+        assertFalse(state.estimated)
+        assertTrue(state.allPreviousChaptersExact)
+    }
+
+    @Test
+    fun `invalidating changed exact chapter restores estimated suffix`() {
+        val index = WholeBookPageIndex(2)
+        index.initialize(intArrayOf(7, 3), exactChapters = setOf(0))
+
+        assertTrue(index.invalidateExact(chapterIndex = 0, estimatedPageCount = 4))
+
+        val state = requireNotNull(index.getState(0, 0))
+        assertEquals(7, state.totalPages)
+        assertEquals(4, state.chapterPageCount)
+        assertFalse(state.currentChapterExact)
+        assertTrue(state.estimated)
+    }
+}

+ 4 - 4
app/src/test/java/io/legado/app/ui/main/MainNavigatorReadAloudTest.kt

@@ -11,9 +11,9 @@ class MainNavigatorReadAloudTest {
         val reader = MainRouteReadBook(bookUrl = "book")
         val backStack = mutableListOf<NavKey>(MainRouteHome, reader)
 
-        MainNavigator.navigateToRoute(backStack, MainRouteCloudTtsEngines)
+        MainNavigator.navigateToRoute(backStack, MainRouteCloudTtsEngines())
 
-        assertEquals(listOf(MainRouteHome, reader, MainRouteCloudTtsEngines), backStack)
+        assertEquals(listOf(MainRouteHome, reader, MainRouteCloudTtsEngines()), backStack)
     }
 
     @Test
@@ -23,8 +23,8 @@ class MainNavigatorReadAloudTest {
             MainRouteSettings,
         )
 
-        MainNavigator.navigateToRoute(backStack, MainRouteCloudTtsEngines)
+        MainNavigator.navigateToRoute(backStack, MainRouteCloudTtsEngines())
 
-        assertEquals(listOf(MainRouteHome, MainRouteCloudTtsEngines), backStack)
+        assertEquals(listOf(MainRouteHome, MainRouteCloudTtsEngines()), backStack)
     }
 }