Эх сурвалжийг харах

feat: 笔记功能和书签标识自定义喵喵喵

HapeLee 3 сар өмнө
parent
commit
a130dddc40
61 өөрчлөгдсөн 7952 нэмэгдсэн , 112 устгасан
  1. 4782 0
      app/schemas/io.legado.app.data.AppDatabase/102.json
  2. 2 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 9 3
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  4. 67 0
      app/src/main/java/io/legado/app/data/dao/BookMarkingDao.kt
  5. 3 1
      app/src/main/java/io/legado/app/data/entities/Book.kt
  6. 3 0
      app/src/main/java/io/legado/app/data/entities/BookContentProcess.kt
  7. 46 0
      app/src/main/java/io/legado/app/data/entities/BookMarking.kt
  8. 3 0
      app/src/main/java/io/legado/app/data/entities/Bookmark.kt
  9. 43 0
      app/src/main/java/io/legado/app/data/repository/BookMarkingRepository.kt
  10. 6 0
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  11. 39 1
      app/src/main/java/io/legado/app/di/appDatabaseModule.kt
  12. 17 5
      app/src/main/java/io/legado/app/di/appModule.kt
  13. 20 0
      app/src/main/java/io/legado/app/domain/gateway/BookMarkingGateway.kt
  14. 20 0
      app/src/main/java/io/legado/app/domain/model/BookContentProcessEngine.kt
  15. 53 0
      app/src/main/java/io/legado/app/domain/model/BookContentProcessModels.kt
  16. 2 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  17. 46 0
      app/src/main/java/io/legado/app/domain/usecase/RelocateMarkingTargetUseCase.kt
  18. 147 0
      app/src/main/java/io/legado/app/domain/usecase/SaveMarkingUseCase.kt
  19. 49 0
      app/src/main/java/io/legado/app/domain/usecase/VerifyBookmarkTargetUseCase.kt
  20. 49 3
      app/src/main/java/io/legado/app/help/book/ContentProcessor.kt
  21. 91 0
      app/src/main/java/io/legado/app/ui/book/read/BookmarkBadgeDelegate.kt
  22. 26 0
      app/src/main/java/io/legado/app/ui/book/read/MarkingContract.kt
  23. 209 0
      app/src/main/java/io/legado/app/ui/book/read/MarkingDelegate.kt
  24. 33 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  25. 13 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  26. 23 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  27. 40 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  28. 117 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  29. 3 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt
  30. 135 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkNavigateDelegate.kt
  31. 11 0
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt
  32. 4 1
      app/src/main/java/io/legado/app/ui/book/read/ReadContentProcessDelegate.kt
  33. 8 1
      app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt
  34. 44 12
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  35. 41 23
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  36. 91 0
      app/src/main/java/io/legado/app/ui/book/read/page/ReaderBookmarkBadge.kt
  37. 2 1
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt
  38. 1 0
      app/src/main/java/io/legado/app/ui/book/read/page/provider/CharStyle.kt
  39. 68 11
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt
  40. 7 3
      app/src/main/java/io/legado/app/ui/book/read/sheet/ContentProcessesSheet.kt
  41. 440 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/MarkingSheet.kt
  42. 34 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt
  43. 172 25
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt
  44. 111 2
      app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
  45. 72 1
      app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt
  46. 2 2
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  47. 4 2
      app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
  48. 6 7
      app/src/main/java/io/legado/app/ui/widget/components/tabRow/CardTabRow.kt
  49. 34 1
      app/src/main/res/values-zh-rCN/strings.xml
  50. 34 1
      app/src/main/res/values-zh-rHK/strings.xml
  51. 34 1
      app/src/main/res/values-zh-rTW/strings.xml
  52. 1 0
      app/src/main/res/values/ids.xml
  53. 33 0
      app/src/main/res/values/strings.xml
  54. 6 2
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  55. 85 0
      app/src/test/java/io/legado/app/domain/model/BookContentProcessEngineTest.kt
  56. 102 0
      app/src/test/java/io/legado/app/domain/model/MarkingEffectTest.kt
  57. 33 0
      app/src/test/java/io/legado/app/domain/usecase/RelocateMarkingTargetUseCaseTest.kt
  58. 235 0
      app/src/test/java/io/legado/app/domain/usecase/SaveMarkingUseCaseTest.kt
  59. 73 0
      app/src/test/java/io/legado/app/domain/usecase/VerifyBookmarkTargetUseCaseTest.kt
  60. 3 0
      app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt
  61. 65 3
      app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

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

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

+ 2 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -182,6 +182,8 @@ object PreferKey {
     const val clickImgWay = "clickImgWay"
     const val keyPageOnLongPress = "keyPageOnLongPress"
     const val swipeToAddBookmark = "swipeToAddBookmark"
+    const val bookmarkBadgeImage = "bookmarkBadgeImage"
+    const val bookmarkBadgeSize = "bookmarkBadgeSize"
     const val volumeKeyPage = "volumeKeyPage"
     const val volumeKeyPageOnPlay = "volumeKeyPageOnPlay"
     const val mouseWheelPage = "mouseWheelPage"

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

@@ -17,6 +17,7 @@ import io.legado.app.data.dao.BookContentProcessDao
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.dao.BookGroupDao
 import io.legado.app.data.dao.BookKnowledgeDao
+import io.legado.app.data.dao.BookMarkingDao
 import io.legado.app.data.dao.BookSourceDao
 import io.legado.app.data.dao.BookmarkDao
 import io.legado.app.data.dao.CacheDao
@@ -61,6 +62,7 @@ import io.legado.app.data.entities.BookCharacterRelation
 import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookKnowledgeEntry
+import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.BookOutlineNode
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
@@ -110,7 +112,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 101,
+    version = 102,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -126,7 +128,8 @@ val appDb by lazy {
         BookCharacterEvent::class, BookCharacterRelation::class, BookKnowledgeEntry::class,
         BookOutlineNode::class, ReadAloudVoiceEntity::class, BookVoiceBindingEntity::class,
         ChapterSpeechAnalysisEntity::class, ChapterSpeechSegmentEntity::class,
-        CloudTtsEngineEntity::class, ExactChapterPageCountEntity::class],
+        CloudTtsEngineEntity::class, ExactChapterPageCountEntity::class,
+        BookMarking::class],
     views = [BookSourcePart::class],
     autoMigrations = [
         AutoMigration(from = 43, to = 44),
@@ -184,7 +187,9 @@ val appDb by lazy {
         AutoMigration(from = 95, to = 96),
         AutoMigration(from = 96, to = 97),
         AutoMigration(from = 97, to = 98),
-        AutoMigration(from = 100, to = 101, spec = DatabaseMigrations.Migration_100_101::class)
+        AutoMigration(from = 100, to = 101, spec = DatabaseMigrations.Migration_100_101::class),
+        // book_marks 新表:Room AutoMigration 支持新增表,自动 CREATE TABLE
+        AutoMigration(from = 101, to = 102)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {
@@ -203,6 +208,7 @@ abstract class AppDatabase : RoomDatabase() {
     abstract val searchKeywordDao: SearchKeywordDao
     abstract val rssSourceDao: RssSourceDao
     abstract val bookmarkDao: BookmarkDao
+    abstract val bookMarkingDao: BookMarkingDao
     abstract val rssArticleDao: RssArticleDao
     abstract val rssStarDao: RssStarDao
     abstract val rssReadRecordDao: RssReadRecordDao

+ 67 - 0
app/src/main/java/io/legado/app/data/dao/BookMarkingDao.kt

@@ -0,0 +1,67 @@
+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.BookMarking
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface BookMarkingDao {
+
+    /**
+     * 按创建时的源(bookUrl)查:渲染只画当前源能对上正文的标记。
+     * 与 bookmarks 不同,book_marks 认「书名+作者」跨源关联,列表见 [flowByBook]。
+     */
+    @Query(
+        """
+        select * from book_marks
+        where bookUrl = :bookUrl
+          and (:chapterIndex is null or chapterIndex is null or chapterIndex = :chapterIndex)
+        order by createdAt
+        """
+    )
+    fun getForChapterSync(bookUrl: String, chapterIndex: Int?): List<BookMarking>
+
+    /** 按「书名+作者」查(含跨源全部标记),供保存去重/定位;chapterIndex 可空。 */
+    @Query(
+        """
+        select * from book_marks
+        where bookName = :bookName and bookAuthor = :bookAuthor
+          and (:chapterIndex is null or chapterIndex is null or chapterIndex = :chapterIndex)
+        order by createdAt
+        """
+    )
+    suspend fun getByBook(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int?
+    ): List<BookMarking>
+
+    /** 按「书名+作者」流式订阅全部章节的标记,供目录 Sheet 笔记页跨源展示。 */
+    @Query(
+        """
+        select * from book_marks
+        where bookName = :bookName and bookAuthor = :bookAuthor
+        order by chapterIndex, createdAt
+        """
+    )
+    fun flowByBook(bookName: String, bookAuthor: String): Flow<List<BookMarking>>
+
+    @Query("select * from book_marks where id = :id")
+    suspend fun getById(id: String): BookMarking?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsert(bookMarking: BookMarking)
+
+    @Query("update book_marks set enabled = :enabled, updatedAt = :updatedAt where id = :id")
+    suspend fun setEnabled(
+        id: String,
+        enabled: Boolean,
+        updatedAt: Long = System.currentTimeMillis()
+    )
+
+    @Query("delete from book_marks where id = :id")
+    suspend fun delete(id: String)
+}

+ 3 - 1
app/src/main/java/io/legado/app/data/entities/Book.kt

@@ -1,7 +1,6 @@
 package io.legado.app.data.entities
 
 import android.os.Parcelable
-import io.legado.app.help.book.applyTagGroupRulesForBook
 import androidx.room.ColumnInfo
 import androidx.room.Entity
 import androidx.room.Ignore
@@ -15,6 +14,7 @@ import io.legado.app.constant.PageAnim
 import io.legado.app.data.appDb
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.book.applyTagGroupRulesForBook
 import io.legado.app.help.book.getFolderNameNoCache
 import io.legado.app.help.book.isEpub
 import io.legado.app.help.book.isImage
@@ -393,6 +393,8 @@ data class Book(
         return Bookmark(
             bookName = name,
             bookAuthor = author,
+            // 源指纹:创建时的书源,跳转校验用(换源后位置可能偏移)
+            bookUrl = bookUrl,
         )
     }
 

+ 3 - 0
app/src/main/java/io/legado/app/data/entities/BookContentProcess.kt

@@ -36,6 +36,9 @@ data class BookContentProcess(
     companion object {
         const val KIND_AI_CLEAN = "ai_clean"
         const val KIND_AI_REWRITE = "ai_rewrite"
+
+        // 用户划线/高亮标记:book_marks 无 kind 列(样式即类型),渲染桥从 styleJson
+        // 推导出这两个合成 kind 之一,供引擎/渲染层识别「这是标记、别改文本」。
         const val KIND_USER_UNDERLINE = "user_underline"
         const val KIND_USER_HIGHLIGHT = "user_highlight"
 

+ 46 - 0
app/src/main/java/io/legado/app/data/entities/BookMarking.kt

@@ -0,0 +1,46 @@
+package io.legado.app.data.entities
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+/**
+ * 用户划线/高亮笔记(标记)。与书签、AI 正文处理完全独立:
+ * - 书签只做页面导航,不产生视觉标记;
+ * - AI 改写净化仍走 [BookContentProcess],本表只承载用户划线/高亮。
+ *
+ * [anchorJson] 复用 [io.legado.app.domain.model.TextProcessAnchor] 做章节内定位,
+ * [styleJson] 复用 [io.legado.app.domain.model.TextProcessStyle] 存线段/颜色,
+ * [note] 是用户备注。渲染时由 `ContentProcessor` 把启用行转成合成
+ * [BookContentProcess] 混进现有渲染管线,TextChapterLayout 无需感知本表。
+ *
+ * 没有 kind 列:样式([TextProcessStyle])即类型,单实线/波浪线/虚线/背景色/
+ * 字体色 全部由 styleJson 表达,渲染桥按需推导合成 [BookContentProcess] 的 kind。
+ */
+@Entity(
+    tableName = "book_marks",
+    indices = [
+        Index(value = ["bookUrl", "chapterIndex"]),
+    ]
+)
+data class BookMarking(
+    @PrimaryKey
+    val id: String,
+    /** 创建时的源(源指纹):跳转校验用。book_marks 与书签一样认「书名+作者」跨源关联。 */
+    val bookUrl: String,
+    /** 跨源关联键:与 bookmarks 一致,换源后笔记仍可见可管理。 */
+    @ColumnInfo(defaultValue = "")
+    val bookName: String = "",
+    @ColumnInfo(defaultValue = "")
+    val bookAuthor: String = "",
+    val chapterIndex: Int? = null,
+    val anchorJson: String,
+    val styleJson: String? = null,
+    val note: String = "",
+    /** 章节标题,目录 Sheet 笔记页展示用(book_marks 无 toc 外键,故冗余存储)。 */
+    val chapterName: String = "",
+    val enabled: Boolean = true,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)

+ 3 - 0
app/src/main/java/io/legado/app/data/entities/Bookmark.kt

@@ -18,6 +18,9 @@ data class Bookmark(
     val bookName: String = "",
     @ColumnInfo(defaultValue = "")
     val bookAuthor: String = "",
+    /** 创建时的源(源指纹):跳转校验用。bookmarks 仍按书名+作者关联,换源后保留。 */
+    @ColumnInfo(defaultValue = "")
+    val bookUrl: String = "",
     var chapterIndex: Int = 0,
     var chapterPos: Int = 0,
     var chapterName: String = "",

+ 43 - 0
app/src/main/java/io/legado/app/data/repository/BookMarkingRepository.kt

@@ -0,0 +1,43 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.BookMarkingDao
+import io.legado.app.data.entities.BookMarking
+import io.legado.app.domain.gateway.BookMarkingGateway
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+
+class BookMarkingRepository(
+    private val dao: BookMarkingDao,
+) : BookMarkingGateway {
+
+    override suspend fun getByBook(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int?,
+    ): List<BookMarking> = withContext(Dispatchers.IO) {
+        dao.getByBook(bookName, bookAuthor, chapterIndex)
+    }
+
+    override fun flowByBook(
+        bookName: String,
+        bookAuthor: String,
+    ): Flow<List<BookMarking>> = dao.flowByBook(bookName, bookAuthor).flowOn(Dispatchers.IO)
+
+    override suspend fun getById(id: String): BookMarking? = withContext(Dispatchers.IO) {
+        dao.getById(id)
+    }
+
+    override suspend fun upsert(bookMarking: BookMarking) = withContext(Dispatchers.IO) {
+        dao.upsert(bookMarking)
+    }
+
+    override suspend fun setEnabled(id: String, enabled: Boolean) = withContext(Dispatchers.IO) {
+        dao.setEnabled(id, enabled)
+    }
+
+    override suspend fun delete(id: String) = withContext(Dispatchers.IO) {
+        dao.delete(id)
+    }
+}

+ 6 - 0
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -363,6 +363,8 @@ class ReadSettingsRepository(
             volumeKeyPageOnPlay = compatDsValue(Keys.VolumeKeyPageOnPlay, true),
             keyPageOnLongPress = compatDsValue(Keys.KeyPageOnLongPress, false),
             swipeToAddBookmark = compatDsValue(Keys.SwipeToAddBookmark, false),
+            bookmarkBadgeImage = compatDsValue(Keys.BookmarkBadgeImage, ""),
+            bookmarkBadgeSize = compatDsValue(Keys.BookmarkBadgeSize, 10),
             pageTouchSlop = compatDsValue(Keys.PageTouchSlop, 0),
             sliderVibrator = compatDsValue(Keys.SliderVibrator, false),
             useNewTocSheet = compatDsValue(Keys.UseNewTocSheet, true),
@@ -479,6 +481,8 @@ class ReadSettingsRepository(
         val VolumeKeyPageOnPlay = booleanPreferencesKey(PreferKey.volumeKeyPageOnPlay)
         val KeyPageOnLongPress = booleanPreferencesKey(PreferKey.keyPageOnLongPress)
         val SwipeToAddBookmark = booleanPreferencesKey(PreferKey.swipeToAddBookmark)
+        val BookmarkBadgeImage = stringPreferencesKey(PreferKey.bookmarkBadgeImage)
+        val BookmarkBadgeSize = intPreferencesKey(PreferKey.bookmarkBadgeSize)
         val PageTouchSlop = intPreferencesKey(PreferKey.pageTouchSlop)
         val SliderVibrator = booleanPreferencesKey(PreferKey.sliderVibrator)
         val UseNewTocSheet = booleanPreferencesKey(PreferKey.useNewTocSheet)
@@ -605,6 +609,8 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
     PreferKey.keyPageOnLongPress to keyPageOnLongPress,
     PreferKey.swipeToAddBookmark to swipeToAddBookmark,
+    PreferKey.bookmarkBadgeImage to bookmarkBadgeImage,
+    PreferKey.bookmarkBadgeSize to bookmarkBadgeSize,
     PreferKey.pageTouchSlop to pageTouchSlop,
     PreferKey.sliderVibrator to sliderVibrator,
     PreferKey.useNewTocSheet to useNewTocSheet,

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

@@ -2,7 +2,44 @@ package io.legado.app.di
 
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.appDb
-import io.legado.app.data.dao.*
+import io.legado.app.data.dao.AiArtifactDao
+import io.legado.app.data.dao.AiChatDao
+import io.legado.app.data.dao.AiMemoryDao
+import io.legado.app.data.dao.AiProfileDao
+import io.legado.app.data.dao.AiPromptPresetDao
+import io.legado.app.data.dao.BookChapterDao
+import io.legado.app.data.dao.BookContentProcessDao
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.BookGroupDao
+import io.legado.app.data.dao.BookKnowledgeDao
+import io.legado.app.data.dao.BookMarkingDao
+import io.legado.app.data.dao.BookSourceDao
+import io.legado.app.data.dao.BookmarkDao
+import io.legado.app.data.dao.CacheDao
+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
+import io.legado.app.data.dao.HomepageModuleDao
+import io.legado.app.data.dao.HttpTTSDao
+import io.legado.app.data.dao.KeyboardAssistsDao
+import io.legado.app.data.dao.ReadAloudVoiceDao
+import io.legado.app.data.dao.ReadRecordDao
+import io.legado.app.data.dao.ReplaceRuleDao
+import io.legado.app.data.dao.RssArticleDao
+import io.legado.app.data.dao.RssReadRecordDao
+import io.legado.app.data.dao.RssSourceDao
+import io.legado.app.data.dao.RssStarDao
+import io.legado.app.data.dao.RuleSubDao
+import io.legado.app.data.dao.SearchBookDao
+import io.legado.app.data.dao.SearchKeywordDao
+import io.legado.app.data.dao.ServerDao
+import io.legado.app.data.dao.TagGroupRuleDao
+import io.legado.app.data.dao.TxtTocRuleDao
 import org.koin.dsl.module
 
 /**
@@ -25,6 +62,7 @@ val appDatabaseModule = module {
     factory<BookSourceDao> { get<AppDatabase>().bookSourceDao }
     factory<BookChapterDao> { get<AppDatabase>().bookChapterDao }
     factory<BookContentProcessDao> { get<AppDatabase>().bookContentProcessDao }
+    factory<BookMarkingDao> { get<AppDatabase>().bookMarkingDao }
     factory<BookKnowledgeDao> { get<AppDatabase>().bookKnowledgeDao }
     factory<ReadAloudVoiceDao> { get<AppDatabase>().readAloudVoiceDao }
     factory<ChapterSpeechDao> { get<AppDatabase>().chapterSpeechDao }

+ 17 - 5
app/src/main/java/io/legado/app/di/appModule.kt

@@ -27,15 +27,16 @@ import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookExportSettingsRepository
 import io.legado.app.data.repository.BookGroupMutationRepository
 import io.legado.app.data.repository.BookGroupRepository
-import io.legado.app.data.repository.BookshelfAutoGroupRepository
-import io.legado.app.data.repository.BookshelfAutoGroupPromptRepository
 import io.legado.app.data.repository.BookImportRepository
 import io.legado.app.data.repository.BookKnowledgeRepository
+import io.legado.app.data.repository.BookMarkingRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceCallbackRepository
 import io.legado.app.data.repository.BookSourceCheckRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookmarkRepository
+import io.legado.app.data.repository.BookshelfAutoGroupPromptRepository
+import io.legado.app.data.repository.BookshelfAutoGroupRepository
 import io.legado.app.data.repository.BookshelfRepository
 import io.legado.app.data.repository.BookshelfSettingsRepository
 import io.legado.app.data.repository.CacheBookDownloadRepository
@@ -115,11 +116,12 @@ import io.legado.app.domain.gateway.BookContentProcessGateway
 import io.legado.app.domain.gateway.BookExportSettingsGateway
 import io.legado.app.domain.gateway.BookGroupMutationGateway
 import io.legado.app.domain.gateway.BookKnowledgeGateway
-import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
-import io.legado.app.domain.gateway.BookshelfAutoGroupPromptGateway
+import io.legado.app.domain.gateway.BookMarkingGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.BookSourceCheckGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupPromptGateway
 import io.legado.app.domain.gateway.BookshelfSettingsGateway
 import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
 import io.legado.app.domain.gateway.ChapterSpeechGateway
@@ -161,8 +163,8 @@ import io.legado.app.domain.usecase.AiTaskManager
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
 import io.legado.app.domain.usecase.AiToolAwareGenerationUseCase
 import io.legado.app.domain.usecase.AnalyzeChapterSpeechUseCase
-import io.legado.app.domain.usecase.ApplyBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
+import io.legado.app.domain.usecase.ApplyBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.BuildSpeechPlanUseCase
@@ -186,10 +188,12 @@ import io.legado.app.domain.usecase.ImportBookshelfUseCase
 import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
 import io.legado.app.domain.usecase.RefineSpeechWithAiUseCase
 import io.legado.app.domain.usecase.RefreshTocUseCase
+import io.legado.app.domain.usecase.RelocateMarkingTargetUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.ResolveLocalSpeakersUseCase
 import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
+import io.legado.app.domain.usecase.SaveMarkingUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
@@ -198,6 +202,7 @@ import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
 import io.legado.app.domain.usecase.TranslateChapterUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
+import io.legado.app.domain.usecase.VerifyBookmarkTargetUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
 import io.legado.app.help.coil.CoverFetcher
@@ -447,6 +452,7 @@ val appModule = module {
     single<HomepageModulesGateway> { HomepageModulesRepository(get(), get()) }
     single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
     single<BookContentProcessGateway> { BookContentProcessRepository(get()) }
+    single<BookMarkingGateway> { BookMarkingRepository(get()) }
     single<BookKnowledgeGateway> { BookKnowledgeRepository(get()) }
     single<ReadAloudVoiceGateway> { ReadAloudVoiceRepository(get()) }
     singleOf(::CloudTtsCredentialCipher)
@@ -478,6 +484,9 @@ val appModule = module {
     singleOf(::ApplyBookshelfAutoGroupPlanUseCase)
     singleOf(::CleanSelectedTextUseCase)
     singleOf(::SaveBookContentProcessUseCase)
+    singleOf(::SaveMarkingUseCase)
+    singleOf(::VerifyBookmarkTargetUseCase)
+    singleOf(::RelocateMarkingTargetUseCase)
     singleOf(::ReplaceRuleRepository)
     single<DictionaryGateway> { DictionaryRepositoryImpl() }
     singleOf(::TranslateChapterUseCase)
@@ -691,6 +700,9 @@ val appModule = module {
             cleanSelectedTextUseCase = get(),
             aiTextFactoryUseCase = get(),
             saveBookContentProcessUseCase = get(),
+            saveMarkingUseCase = get(),
+            verifyBookmarkTargetUseCase = get(),
+            relocateMarkingTargetUseCase = get(),
             bookContentProcessGateway = get(),
             aiArtifactGateway = get(),
             aiPromptPresetGateway = get(),

+ 20 - 0
app/src/main/java/io/legado/app/domain/gateway/BookMarkingGateway.kt

@@ -0,0 +1,20 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.BookMarking
+import kotlinx.coroutines.flow.Flow
+
+interface BookMarkingGateway {
+    /** 按「书名+作者」查,供保存去重/定位;chapterIndex 可空。 */
+    suspend fun getByBook(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int?
+    ): List<BookMarking>
+
+    /** 按「书名+作者」流式订阅全部章节,供目录 Sheet 笔记页跨源展示。 */
+    fun flowByBook(bookName: String, bookAuthor: String): Flow<List<BookMarking>>
+    suspend fun getById(id: String): BookMarking?
+    suspend fun upsert(bookMarking: BookMarking)
+    suspend fun setEnabled(id: String, enabled: Boolean)
+    suspend fun delete(id: String)
+}

+ 20 - 0
app/src/main/java/io/legado/app/domain/model/BookContentProcessEngine.kt

@@ -28,6 +28,19 @@ object BookContentProcessEngine {
                     it.stage == BookContentProcess.STAGE_CONTENT
             }
             .forEach { process ->
+                if (process.kind == BookContentProcess.KIND_USER_UNDERLINE ||
+                    process.kind == BookContentProcess.KIND_USER_HIGHLIGHT
+                ) {
+                    // 用户划线/高亮标记:不改文本。锚点能在正文里解析到说明标记仍有效,
+                    // 计入 effectiveProcesses 供渲染层把样式应用到区间。
+                    val anchor = GSON.fromJsonObject<TextProcessAnchor>(process.anchorJson)
+                        .getOrNull()
+                        ?: return@forEach
+                    if (findTargetRange(output, anchor) != null) {
+                        effectiveProcesses.add(process)
+                    }
+                    return@forEach
+                }
                 val anchor = GSON.fromJsonObject<TextProcessAnchor>(process.anchorJson)
                     .getOrNull()
                     ?: return@forEach
@@ -70,6 +83,13 @@ object BookContentProcessEngine {
         return ApplyResult(output, effectiveProcesses)
     }
 
+    /**
+     * 在给定正文里解析锚点对应的字符区间,供渲染层把用户划线/高亮应用到该区间。
+     * 容错:按文本匹配 + 就近章节位置取最近命中,跨空白归一化。
+     */
+    fun resolveRange(content: String, anchor: TextProcessAnchor): IntRange? =
+        findTargetRange(content, anchor)
+
     fun normalizeProcessText(text: String): String {
         return text.lines()
             .joinToString("\n") { line ->

+ 53 - 0
app/src/main/java/io/legado/app/domain/model/BookContentProcessModels.kt

@@ -24,6 +24,9 @@ data class TextProcessAction(
         const val TYPE_INSERT_BEFORE = "insert_before"
         const val TYPE_INSERT_AFTER = "insert_after"
 
+        /** 用户划线/高亮标记:不改文本,样式由 styleJson 承载,文本仅供锚点定位。 */
+        const val TYPE_MARK = "mark"
+
         fun replace(replacement: String): TextProcessAction {
             return TextProcessAction(TYPE_REPLACE, replacement = replacement)
         }
@@ -44,3 +47,53 @@ data class TextProcessStyle(
     val underlineOffset: Float = 2f,
     val underlineSvgPath: String? = null,
 )
+
+/**
+ * 用户划线/高亮笔记的 5 种效果(5x1 互斥):单实线/波浪线/虚线/背景色/字体色。
+ *
+ * 样式即类型:book_marks 不再存 kind 列,效果由 [TextProcessStyle] 推导/生成,
+ * 渲染引擎按 styleJson 画线,效果本身与「划线 vs 高亮」的老二元 kind 等价。
+ */
+@Keep
+enum class MarkingEffect {
+    SOLID, WAVE, DASHED, BG, TEXT;
+
+    /** 是否属于下划线类效果(对应 underlineMode != 0)。 */
+    val isUnderline: Boolean
+        get() = this == SOLID || this == WAVE || this == DASHED
+
+    /**
+     * 由效果 + 选中颜色生成样式。背景色自动半透明(约 20% alpha),
+     * 避免不透明背景盖住正文;下划线/字体色用原色。
+     */
+    fun toStyle(color: Int): TextProcessStyle = when (this) {
+        SOLID -> TextProcessStyle(underlineMode = 1, underlineColor = color)
+        WAVE -> TextProcessStyle(underlineMode = 3, underlineColor = color)
+        DASHED -> TextProcessStyle(underlineMode = 2, underlineColor = color)
+        BG -> TextProcessStyle(bgColor = (color and 0x00FFFFFF) or 0x33000000)
+        TEXT -> TextProcessStyle(textColor = color)
+    }
+
+    companion object {
+        /** 标记默认颜色(绿色)。 */
+        const val DEFAULT_COLOR = 0xFF63C37D.toInt()
+
+        /** 从样式反推效果:编辑已有标记时预填 5x1 格。未知下划线模式回退单实线。 */
+        fun fromStyle(style: TextProcessStyle?): MarkingEffect = when {
+            style?.underlineMode == 1 -> SOLID
+            style?.underlineMode == 3 -> WAVE
+            style?.underlineMode == 2 -> DASHED
+            style?.bgColor != null -> BG
+            style?.textColor != null -> TEXT
+            else -> SOLID
+        }
+
+        /** 取样式的「展示色」:下划线取线色,背景剥掉 alpha 取底色,字体取字色。 */
+        fun colorOf(style: TextProcessStyle?): Int = when {
+            style?.underlineColor != null -> style.underlineColor
+            style?.bgColor != null -> (style.bgColor and 0x00FFFFFF) or 0xFF000000.toInt()
+            style?.textColor != null -> style.textColor
+            else -> DEFAULT_COLOR
+        }
+    }
+}

+ 2 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt

@@ -30,6 +30,8 @@ data class ReadSettings(
     val volumeKeyPageOnPlay: Boolean = true,
     val keyPageOnLongPress: Boolean = false,
     val swipeToAddBookmark: Boolean = false,
+    val bookmarkBadgeImage: String = "",
+    val bookmarkBadgeSize: Int = 10,
     val pageTouchSlop: Int = 0,
     val sliderVibrator: Boolean = false,
     val useNewTocSheet: Boolean = true,

+ 46 - 0
app/src/main/java/io/legado/app/domain/usecase/RelocateMarkingTargetUseCase.kt

@@ -0,0 +1,46 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.model.TextProcessAnchor
+
+/** Resolves a marking against content from the currently selected source. */
+class RelocateMarkingTargetUseCase {
+
+    data class Candidate(val chapterIndex: Int, val content: String)
+    data class Target(val chapterIndex: Int, val chapterPosition: Int)
+
+    /** Returns only a unique best match, leaving ambiguous targets for user confirmation. */
+    fun locate(anchor: TextProcessAnchor, candidates: List<Candidate>): Target? {
+        val matches = candidates.flatMap { candidate ->
+            occurrences(candidate.content, anchor.selectedText).map { position ->
+                Match(Target(candidate.chapterIndex, position), score(candidate, position, anchor))
+            }
+        }
+        val best = matches.maxByOrNull { it.score } ?: return null
+        return best.target.takeIf { target -> matches.count { it.score == best.score } == 1 }
+    }
+
+    private fun occurrences(content: String, text: String): List<Int> {
+        if (text.isBlank()) return emptyList()
+        val result = mutableListOf<Int>()
+        var position = content.indexOf(text)
+        while (position >= 0) {
+            result += position
+            position = content.indexOf(text, position + text.length)
+        }
+        return result
+    }
+
+    private fun score(candidate: Candidate, position: Int, anchor: TextProcessAnchor): Int {
+        val end = position + anchor.selectedText.length
+        var score = 0
+        if (anchor.contextBefore.isNotBlank() &&
+            candidate.content.substring(0, position).endsWith(anchor.contextBefore)
+        ) score += 4
+        if (anchor.contextAfter.isNotBlank() &&
+            candidate.content.substring(end).startsWith(anchor.contextAfter)
+        ) score += 4
+        return score
+    }
+
+    private data class Match(val target: Target, val score: Int)
+}

+ 147 - 0
app/src/main/java/io/legado/app/domain/usecase/SaveMarkingUseCase.kt

@@ -0,0 +1,147 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.BookMarking
+import io.legado.app.domain.gateway.BookMarkingGateway
+import io.legado.app.domain.model.BookContentProcessEngine
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.utils.GSON
+import io.legado.app.utils.MD5Utils
+import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.util.UUID
+
+/**
+ * 用户划线/高亮笔记的落库(book_marks 表)。与书签、AI 正文处理完全独立:
+ * 划线不建书签、删书签不碰划线。
+ *
+ * 与书签一样认「书名+作者」跨源关联:换源后笔记仍可见可管理;[bookUrl] 只作源指纹
+ * (跳转校验用),渲染仍按当前源查。
+ *
+ * 同锚点(章节 + 章节内位置 + 归一化文本)已存在标记时**原地更新**(保留 id 与
+ * createdAt,更新 style / note / 锚点 / 源指纹),而不是删了重建。
+ *
+ * 没有 kind 参数:book_marks 无 kind 列,样式([TextProcessStyle])即类型——
+ * 单实线/波浪线/虚线/背景色/字体色 全部由 style 表达。
+ */
+class SaveMarkingUseCase(
+    private val bookMarkingGateway: BookMarkingGateway,
+) {
+
+    /**
+     * 串行化「查旧 → 更新/新增」整段:快速双击保存会并发执行,各自查到「无旧标记」
+     * 就会重复划线。锁住后两次保存退化成正确的先更新再更新。
+     */
+    private val saveMutex = Mutex()
+
+    /**
+     * 保存一条标记。同锚点已有则更新,否则新增。
+     *
+     * @param bookName/bookAuthor 跨源关联键(与书签一致)
+     * @param bookUrl            当前源(源指纹)。编辑旧源笔记时由调用方传入原值以保留源
+     * @param chapterPosition    章节内字符位置(与普通书签 chapterPos 同语义,排版坐标)
+     * @param selectedText       选中的原文(会做 trim 归一化)
+     * @param chapterName        章节标题(目录 Sheet 笔记页展示用)
+     * @param note               用户备注(笔记)
+     */
+    suspend fun save(
+        bookName: String,
+        bookAuthor: String,
+        bookUrl: String,
+        chapterIndex: Int,
+        chapterPosition: Int,
+        selectedText: String,
+        style: TextProcessStyle,
+        chapterName: String = "",
+        note: String = "",
+        contextBefore: String = "",
+        contextAfter: String = "",
+    ): BookMarking = withContext(Dispatchers.IO) {
+        saveMutex.withLock {
+            val normalized = BookContentProcessEngine.normalizeProcessText(selectedText)
+            require(normalized.isNotBlank()) { "Selected text is empty" }
+            val existing = find(
+                bookName = bookName,
+                bookAuthor = bookAuthor,
+                chapterIndex = chapterIndex,
+                chapterPosition = chapterPosition,
+                selectedText = normalized,
+            )
+            val anchor = TextProcessAnchor(
+                chapterIndex = chapterIndex,
+                chapterPosition = chapterPosition,
+                selectedText = normalized,
+                contextBefore = contextBefore,
+                contextAfter = contextAfter,
+                normalizedTextHash = MD5Utils.md5Encode(normalized),
+            )
+            val now = System.currentTimeMillis()
+            if (existing != null) {
+                val updated = existing.copy(
+                    bookUrl = bookUrl,
+                    anchorJson = GSON.toJson(anchor),
+                    styleJson = GSON.toJson(style),
+                    note = note,
+                    chapterName = chapterName.ifBlank { existing.chapterName },
+                    enabled = true,
+                    updatedAt = now,
+                )
+                bookMarkingGateway.upsert(updated)
+                return@withContext updated
+            }
+            val mark = BookMarking(
+                id = UUID.randomUUID().toString(),
+                bookUrl = bookUrl,
+                bookName = bookName,
+                bookAuthor = bookAuthor,
+                chapterIndex = chapterIndex,
+                anchorJson = GSON.toJson(anchor),
+                styleJson = GSON.toJson(style),
+                note = note,
+                chapterName = chapterName,
+                enabled = true,
+                createdAt = now,
+                updatedAt = now,
+            )
+            bookMarkingGateway.upsert(mark)
+            mark
+        }
+    }
+
+    /**
+     * 按锚点查找已有标记(供划线 Sheet 打开时预填样式与备注)。
+     */
+    suspend fun find(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int,
+        chapterPosition: Int,
+        selectedText: String,
+    ): BookMarking? = withContext(Dispatchers.IO) {
+        val normalized = BookContentProcessEngine.normalizeProcessText(selectedText)
+        bookMarkingGateway.getByBook(bookName, bookAuthor, chapterIndex)
+            .firstOrNull { mark ->
+                val anchor = mark.anchor()
+                anchor != null &&
+                        anchor.chapterPosition == chapterPosition &&
+                        anchor.selectedText == normalized
+            }
+    }
+
+    /**
+     * 按 id 取一条标记(编辑模式:从目录 Sheet 进入时用)。
+     */
+    suspend fun findById(id: String): BookMarking? = withContext(Dispatchers.IO) {
+        bookMarkingGateway.getById(id)
+    }
+
+    suspend fun delete(id: String) {
+        bookMarkingGateway.delete(id)
+    }
+
+    private fun BookMarking.anchor(): TextProcessAnchor? =
+        GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
+}

+ 49 - 0
app/src/main/java/io/legado/app/domain/usecase/VerifyBookmarkTargetUseCase.kt

@@ -0,0 +1,49 @@
+package io.legado.app.domain.usecase
+
+/**
+ * 书签/笔记跳转前的定位校验结果。
+ */
+sealed interface BookmarkTargetVerdict {
+    /** 一切正常,直接跳。 */
+    data object Match : BookmarkTargetVerdict
+
+    /** 创建于其他书源(源指纹 != 当前源),章节定位很可能偏移。 */
+    data class SourceChanged(val storedBookUrl: String) : BookmarkTargetVerdict
+
+    /** 目标章节标题与存储的不符(同源目录重排,或换源后章节错位)。 */
+    data object TitleMismatch : BookmarkTargetVerdict
+}
+
+/**
+ * 跳转前校验书签/笔记的目标是否仍可靠。
+ *
+ * 换源后 `bookUrl` 会变、目录会重排,书签/笔记的 `chapterIndex`/`chapterPos` 是创建时
+ * 源里的坐标,可能偏移。校验结果决定是否弹「仍跳转」确认框。
+ */
+class VerifyBookmarkTargetUseCase {
+
+    /**
+     * @param currentBookUrl      当前书源(当前书行 url)
+     * @param targetChapterTitle  当前目录里目标章节的标题(调用方按 chapterIndex 解析,
+     *                            拿不到(index 越界/未加载)传 null 跳过标题校验)
+     * @param storedBookUrl       创建时的源指纹(旧数据为空 → 跳过源校验)
+     * @param storedChapterName   创建时的章节标题
+     */
+    fun verify(
+        currentBookUrl: String,
+        targetChapterTitle: String?,
+        storedBookUrl: String,
+        storedChapterName: String,
+    ): BookmarkTargetVerdict {
+        if (storedBookUrl.isNotBlank() && storedBookUrl != currentBookUrl) {
+            return BookmarkTargetVerdict.SourceChanged(storedBookUrl)
+        }
+        if (targetChapterTitle != null &&
+            storedChapterName.isNotBlank() &&
+            storedChapterName != targetChapterTitle
+        ) {
+            return BookmarkTargetVerdict.TitleMismatch
+        }
+        return BookmarkTargetVerdict.Match
+    }
+}

+ 49 - 3
app/src/main/java/io/legado/app/help/book/ContentProcessor.kt

@@ -8,13 +8,18 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookContentProcess
+import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.ReplaceRule
-import io.legado.app.exception.RegexTimeoutException
 import io.legado.app.domain.model.BookContentProcessEngine
+import io.legado.app.domain.model.TextProcessAction
+import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.exception.RegexTimeoutException
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.utils.ChineseUtils
+import io.legado.app.utils.GSON
 import io.legado.app.utils.escapeRegex
+import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.replace
 import io.legado.app.utils.stackTraceStr
 import io.legado.app.utils.toastOnUi
@@ -199,8 +204,15 @@ class ContentProcessor private constructor(
                 bookUrl = book.bookUrl,
                 chapterIndex = chapter.index,
             )
-            if (contentProcesses.isNotEmpty()) {
-                val applyResult = BookContentProcessEngine.apply(mContent, contentProcesses)
+            // 用户划线/高亮笔记独立存于 book_marks,渲染时转成合成 BookContentProcess
+            // 混进现有管线(锚点/样式不变,引擎对标记类不改文本)。
+            val markings = appDb.bookMarkingDao.getForChapterSync(
+                bookUrl = book.bookUrl,
+                chapterIndex = chapter.index,
+            ).map { it.toRenderProcess() }
+            if (contentProcesses.isNotEmpty() || markings.isNotEmpty()) {
+                val applyResult =
+                    BookContentProcessEngine.apply(mContent, contentProcesses + markings)
                 mContent = applyResult.text
                 effectiveContentProcesses = applyResult.effectiveProcesses
             }
@@ -232,4 +244,38 @@ class ContentProcessor private constructor(
         return BookContent(sameTitleRemoved, contents, effectiveReplaceRules, effectiveContentProcesses)
     }
 
+    /**
+     * 把用户划线/高亮笔记转成「仅供渲染」的合成正文处理记录。
+     *
+     * 引擎对标记类(KIND_USER_UNDERLINE/HIGHLIGHT)不改文本,只按锚点能否解析决定
+     * 是否计入 effectiveProcesses;渲染层用 anchorJson + styleJson 画线。id 加
+     * `mark:` 前缀避免与真实正文处理记录冲突。
+     */
+    private fun BookMarking.toRenderProcess(): BookContentProcess {
+        // book_marks 无 kind 列(样式即类型):按 styleJson 推导,供引擎/渲染层识别
+        val style = styleJson?.let { GSON.fromJsonObject<TextProcessStyle>(it).getOrNull() }
+        val derivedKind = if (style?.underlineMode != 0) {
+            BookContentProcess.KIND_USER_UNDERLINE
+        } else {
+            BookContentProcess.KIND_USER_HIGHLIGHT
+        }
+        return BookContentProcess(
+            id = "mark:$id",
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            kind = derivedKind,
+            stage = BookContentProcess.STAGE_CONTENT,
+            target = BookContentProcess.TARGET_SELECTION,
+            anchorJson = anchorJson,
+            actionJson = GSON.toJson(TextProcessAction(TextProcessAction.TYPE_MARK)),
+            styleJson = styleJson,
+            source = BookContentProcess.SOURCE_USER,
+            enabled = enabled,
+            status = BookContentProcess.STATUS_ACTIVE,
+            sortOrder = 0,
+            createdAt = createdAt,
+            updatedAt = updatedAt,
+        )
+    }
+
 }

+ 91 - 0
app/src/main/java/io/legado/app/ui/book/read/BookmarkBadgeDelegate.kt

@@ -0,0 +1,91 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.data.repository.ReadSettingsRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.FileNotFoundException
+
+/**
+ * 自定义书签角标的落盘与清除。
+ *
+ * 选择图片后把 content:// 拷贝进应用私有目录(免 SAF 授权、重启不失效),再写进
+ * [ReadSettingsRepository];因为写盘是异步入队,刷新视图前先用 `preferences.first{}`
+ * 等新值落地,否则 `upStyle` 会读到旧路径回退成默认书签。
+ */
+class BookmarkBadgeDelegate(
+    private val scope: CoroutineScope,
+    private val context: Context,
+    private val readSettingsRepository: ReadSettingsRepository,
+    private val emitEffect: (ReadBookEffect) -> Unit,
+) {
+
+    fun applyBadgeImage(uri: Uri) {
+        scope.launch(Dispatchers.IO) {
+            runCatching {
+                val path = copyToAppStorage(uri)
+                readSettingsRepository.update { it.copy(bookmarkBadgeImage = path) }
+                path
+            }.onSuccess { path ->
+                readSettingsRepository.preferences.first { it.bookmarkBadgeImage == path }
+                emitEffect(ReadBookEffect.UpdateReadViewConfig(setOf(ConfigUpdateAction.UpdateStyle)))
+                emitEffect(ReadBookEffect.ShowToast(context.getString(R.string.success)))
+            }.onFailure { throwable ->
+                AppLog.put("选择书签角标失败", throwable)
+                emitEffect(
+                    ReadBookEffect.LongToast(
+                        throwable.localizedMessage ?: context.getString(R.string.error)
+                    )
+                )
+            }
+        }
+    }
+
+    fun clearBadgeImage() {
+        scope.launch(Dispatchers.IO) {
+            val oldPath = runCatching {
+                readSettingsRepository.preferences.first().bookmarkBadgeImage
+            }.getOrDefault("")
+            oldPath.takeIf { it.isNotBlank() }?.let { runCatching { File(it).delete() } }
+            readSettingsRepository.update { it.copy(bookmarkBadgeImage = "") }
+            readSettingsRepository.preferences.first { it.bookmarkBadgeImage.isEmpty() }
+            emitEffect(ReadBookEffect.UpdateReadViewConfig(setOf(ConfigUpdateAction.UpdateStyle)))
+        }
+    }
+
+    private fun copyToAppStorage(uri: Uri): String {
+        val name = queryDisplayName(uri)
+        val ext = name?.substringAfterLast('.', "")
+            ?.lowercase()
+            ?.takeIf { it.isNotBlank() && it.length <= 6 } ?: "img"
+        val target = File(context.filesDir, "bookmark_badge.$ext")
+        context.contentResolver.openInputStream(uri)?.use { input ->
+            target.outputStream().use { output -> input.copyTo(output) }
+        } ?: throw FileNotFoundException(uri.toString())
+        return target.absolutePath
+    }
+
+    private fun queryDisplayName(uri: Uri): String? {
+        return context.contentResolver.query(
+            uri,
+            arrayOf(OpenableColumns.DISPLAY_NAME),
+            null,
+            null,
+            null
+        )?.use { cursor ->
+            if (cursor.moveToFirst()) {
+                val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+                if (index >= 0) cursor.getString(index) else null
+            } else {
+                null
+            }
+        }
+    }
+}

+ 26 - 0
app/src/main/java/io/legado/app/ui/book/read/MarkingContract.kt

@@ -0,0 +1,26 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.BookMarking
+import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.entities.HighlightRule
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * 划线/高亮笔记域的状态契约。
+ *
+ * 该域无独立屏幕,只承载一次「选中文段 → 配置样式/备注 → 保存」的临时会话:
+ * [selection] 是当前选中的文段(与普通书签同构),[highlightRules] 供 Sheet 复用样式预设,
+ * [editing] 是同一锚点已有的标记(再次选中划线时预填样式与备注进入编辑模式)。
+ */
+@Immutable
+@Stable
+data class MarkingUiState(
+    val selection: Bookmark? = null,
+    val editing: BookMarking? = null,
+    val highlightRules: ImmutableList<HighlightRule> = persistentListOf(),
+    /** 编辑模式异步加载标记期间的占位,避免 Sheet 先空再弹内容。 */
+    val loading: Boolean = false,
+)

+ 209 - 0
app/src/main/java/io/legado/app/ui/book/read/MarkingDelegate.kt

@@ -0,0 +1,209 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import io.legado.app.R
+import io.legado.app.data.entities.BookMarking
+import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.repository.HighlightRuleRepository
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.domain.usecase.SaveMarkingUseCase
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.model.ReadBook
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * 划线/高亮笔记域:承载一次「选中 → 配置样式/备注 → 保存」的会话。
+ *
+ * 落库走 [SaveMarkingUseCase](book_marks 表),与书签、AI 正文处理完全独立。
+ * 再次选中同一段文字划线时,按锚点查到已有标记进入编辑模式(预填样式与备注)。
+ */
+class MarkingDelegate(
+    private val scope: CoroutineScope,
+    private val context: Context,
+    private val highlightRuleRepository: HighlightRuleRepository,
+    private val saveMarkingUseCase: SaveMarkingUseCase,
+    private val host: Host,
+) {
+
+    interface Host {
+        fun reloadCurrentChapter()
+        fun dismissMarkingSheet()
+        fun showToast(message: String)
+    }
+
+    private val _uiState = MutableStateFlow(MarkingUiState())
+    val uiState = _uiState.asStateFlow()
+
+    fun open(selection: Bookmark) {
+        val book = ReadBook.book
+        _uiState.update {
+            it.copy(
+                selection = selection,
+                editing = null,
+                highlightRules = persistentListOf(),
+                loading = true,
+            )
+        }
+        scope.launch(IO) {
+            val rules = runCatching {
+                highlightRuleRepository.load(ReadBookConfig.durConfig.name)
+            }.getOrDefault(emptyList())
+            val existing = if (book != null) {
+                runCatching {
+                    saveMarkingUseCase.find(
+                        bookName = book.name,
+                        bookAuthor = book.author,
+                        chapterIndex = selection.chapterIndex,
+                        chapterPosition = selection.chapterPos,
+                        selectedText = selection.bookText,
+                    )
+                }.getOrNull()
+            } else {
+                null
+            }
+            _uiState.update {
+                it.copy(
+                    highlightRules = rules.toImmutableList(),
+                    editing = existing,
+                    loading = false,
+                )
+            }
+        }
+    }
+
+    /** 从目录 Sheet 点标记项进入编辑模式:按 id 取完整标记预填。 */
+    fun openForEdit(markingId: String) {
+        val book = ReadBook.book
+        _uiState.update {
+            it.copy(
+                selection = null,
+                editing = null,
+                highlightRules = persistentListOf(),
+                loading = true,
+            )
+        }
+        scope.launch(IO) {
+            val rules = runCatching {
+                highlightRuleRepository.load(ReadBookConfig.durConfig.name)
+            }.getOrDefault(emptyList())
+            val marking = if (book != null) {
+                runCatching { saveMarkingUseCase.findById(markingId) }.getOrNull()
+            } else {
+                null
+            }
+            _uiState.update {
+                it.copy(
+                    highlightRules = rules.toImmutableList(),
+                    editing = marking,
+                    loading = false,
+                )
+            }
+        }
+    }
+
+    fun save(style: TextProcessStyle, note: String) {
+        val current = _uiState.value
+        val book = ReadBook.book ?: return
+        scope.launch(IO) {
+            runCatching {
+                if (current.editing != null) {
+                    // 编辑模式:锚点与源指纹沿用已有标记(保留原源),只改 style/note
+                    val mark = current.editing
+                    val anchor = mark.anchor() ?: error("marking anchor missing")
+                    saveMarkingUseCase.save(
+                        bookName = book.name,
+                        bookAuthor = book.author,
+                        bookUrl = mark.bookUrl,
+                        chapterIndex = mark.chapterIndex ?: anchor.chapterIndex,
+                        chapterPosition = anchor.chapterPosition ?: 0,
+                        selectedText = anchor.selectedText,
+                        style = style,
+                        contextBefore = anchor.contextBefore,
+                        contextAfter = anchor.contextAfter,
+                        chapterName = mark.chapterName,
+                        note = note,
+                    )
+                } else {
+                    val selection = current.selection ?: return@runCatching
+                    val (contextBefore, contextAfter) = selectionContext(selection)
+                    saveMarkingUseCase.save(
+                        bookName = book.name,
+                        bookAuthor = book.author,
+                        bookUrl = book.bookUrl,
+                        chapterIndex = selection.chapterIndex,
+                        chapterPosition = selection.chapterPos,
+                        selectedText = selection.bookText,
+                        style = style,
+                        contextBefore = contextBefore,
+                        contextAfter = contextAfter,
+                        chapterName = selection.chapterName,
+                        note = note,
+                    )
+                }
+            }.onSuccess {
+                host.reloadCurrentChapter()
+                host.dismissMarkingSheet()
+            }.onFailure { error ->
+                host.showToast(error.localizedMessage ?: context.getString(R.string.error))
+            }
+        }
+    }
+
+    /** 编辑模式下删除当前标记。 */
+    fun deleteCurrent() {
+        val editing = _uiState.value.editing ?: return
+        scope.launch(IO) {
+            runCatching {
+                saveMarkingUseCase.delete(editing.id)
+            }.onSuccess {
+                host.reloadCurrentChapter()
+                host.dismissMarkingSheet()
+            }.onFailure { error ->
+                host.showToast(error.localizedMessage ?: context.getString(R.string.error))
+            }
+        }
+    }
+
+    fun onSheetDismissed() {
+        _uiState.value = MarkingUiState()
+    }
+
+    private fun BookMarking.anchor(): TextProcessAnchor? =
+        GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
+
+    /**
+     * 将选中文本附近的正文一并存入锚点,供换源后的文本匹配消歧。
+     * 位置来自当前已排版章节;若选区位置和合成正文略有偏差,则在附近窗口寻找选中文本。
+     */
+    private fun selectionContext(selection: Bookmark): Pair<String, String> {
+        val chapter = ReadBook.curTextChapter
+            ?.takeIf { it.chapter.index == selection.chapterIndex }
+            ?: return "" to ""
+        val content = chapter.getContent()
+        val expectedStart = selection.chapterPos.coerceIn(0, content.length)
+        val windowStart = max(0, expectedStart - CONTEXT_SEARCH_WINDOW)
+        val nearStart = content.indexOf(selection.bookText, windowStart)
+            .takeIf { it >= 0 && it <= min(content.length, expectedStart + CONTEXT_SEARCH_WINDOW) }
+            ?: expectedStart
+        val end = min(content.length, nearStart + selection.bookText.length)
+        return content.substring(max(0, nearStart - MARKING_CONTEXT_CHARS), nearStart) to
+                content.substring(end, min(content.length, end + MARKING_CONTEXT_CHARS))
+    }
+
+    private companion object {
+        const val MARKING_CONTEXT_CHARS = 48
+        const val CONTEXT_SEARCH_WINDOW = 256
+    }
+}

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

@@ -7,14 +7,17 @@ import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurStyle
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.repository.ReadAloudSettingsRepository
+import io.legado.app.domain.model.TextProcessStyle
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.settings.ReadStyleItem
+import io.legado.app.domain.usecase.BookmarkTargetVerdict
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -192,6 +195,14 @@ data class ReadSheetConfigUiState(
     val configNames: ImmutableList<String> = persistentListOf(),
 )
 
+/** 书签/笔记跳转前校验未通过的目标,弹「仍跳转」确认框。 */
+@Stable
+data class PendingBookmarkTarget(
+    val chapterIndex: Int,
+    val chapterPos: Int,
+    val verdict: BookmarkTargetVerdict,
+)
+
 @Stable
 data class ReadBookUiState(
     val book: Book? = null,
@@ -244,6 +255,8 @@ data class ReadBookUiState(
     // Active sheet / dialog
     val activeSheet: ReadBookSheet? = null,
     val activeDialog: ReadBookDialog? = null,
+    /** 书签/笔记跳转前校验不通过时的待确认目标(弹确认框)。 */
+    val pendingBookmarkTarget: PendingBookmarkTarget? = null,
     // Menu state (for overflow menu)
     val isLocalTxt: Boolean = false,
     val isEpub: Boolean = false,
@@ -599,6 +612,8 @@ sealed interface ReadBookIntent {
     data object OpenReadStyleExport : ReadBookIntent
     data class ReadStyleImageSelected(val uri: Uri) : ReadBookIntent
     data class ReadStyleImageSelectedForMode(val uri: Uri, val isNight: Boolean) : ReadBookIntent
+    data class BookmarkBadgeImageSelected(val uri: Uri) : ReadBookIntent
+    data object ClearBookmarkBadgeImage : ReadBookIntent
     data class ReadStyleConfigImportSelected(val uri: Uri) : ReadBookIntent
     data class ReadStyleConfigExportSelected(val uri: Uri) : ReadBookIntent
     data object SaveReadStyleConfig : ReadBookIntent
@@ -664,6 +679,19 @@ sealed interface ReadBookIntent {
     // Text action menu (moved from Activity)
     data class TextActionAloud(val text: String, val selectStartPos: TextPos?) : ReadBookIntent
     data class TextActionBookmark(val bookmark: Bookmark) : ReadBookIntent
+    data class OpenMarking(val selection: Bookmark) : ReadBookIntent
+
+    /** 从正文处理 Sheet 点标记项进入编辑模式。 */
+    data class EditMarking(val id: String) : ReadBookIntent
+    data object DismissMarking : ReadBookIntent
+    data class SaveMarking(val style: TextProcessStyle, val note: String) : ReadBookIntent
+    data object DeleteMarking : ReadBookIntent
+
+    /** 书签/笔记跳转:先校验定位(源/标题),不通过则弹确认框。 */
+    data class NavigateToBookmark(val bookmark: Bookmark) : ReadBookIntent
+    data class NavigateToMarking(val marking: BookMarking) : ReadBookIntent
+    data object ConfirmBookmarkTargetJump : ReadBookIntent
+    data object CancelBookmarkTargetJump : ReadBookIntent
     data class TextActionReplace(val text: String) : ReadBookIntent
     data class TextActionSearchContent(val text: String) : ReadBookIntent
     data class TextActionDict(val text: String) : ReadBookIntent
@@ -951,6 +979,7 @@ sealed interface ReadBookSheet {
     data object FontSelect : ReadBookSheet
     data object TitleFontSelect : ReadBookSheet
     data object HighlightRuleConfig : ReadBookSheet
+    data object Marking : ReadBookSheet
     data object MoreConfig : ReadBookSheet
     data object BgTextConfig : ReadBookSheet
     data object ReadAloudConfig : ReadBookSheet
@@ -1529,6 +1558,10 @@ sealed interface ConfigUpdate {
     data class SwipeToAddBookmark(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
+
+    data class BookmarkBadgeSize(val value: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class SliderVibrator(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }

+ 13 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -583,6 +583,10 @@ class ReadBookController(
     override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean =
         refs?.readView?.onTouchEvent(event) ?: false
 
+    override fun onMarkingClick(markingId: String) {
+        viewModel.onIntent(ReadBookIntent.EditMarking(markingId))
+    }
+
     override fun oldClickImg(src: String): Boolean {
         val urlMatcher = paramPattern.matcher(src)
         if (urlMatcher.find()) {
@@ -740,6 +744,13 @@ class ReadBookController(
                 return true
             }
 
+            R.id.menu_mark -> {
+                refs?.readView?.curPage?.createBookmark()?.let {
+                    viewModel.onIntent(ReadBookIntent.OpenMarking(it))
+                } ?: activity.toastOnUi(R.string.create_bookmark_error)
+                return true
+            }
+
             R.id.menu_edit -> {
                 viewModel.onIntent(ReadBookIntent.OpenContentEdit)
                 return true
@@ -804,6 +815,7 @@ class ReadBookController(
             items.add(ActionMenuItem(R.id.menu_browser, activity.getString(R.string.browser)))
             items.add(ActionMenuItem(R.id.menu_aloud, activity.getString(R.string.read_aloud)))
             items.add(ActionMenuItem(R.id.menu_bookmark, activity.getString(R.string.bookmark)))
+            items.add(ActionMenuItem(R.id.menu_mark, activity.getString(R.string.menu_mark)))
             items.add(ActionMenuItem(R.id.menu_dict, activity.getString(R.string.dict)))
             items.add(ActionMenuItem(R.id.menu_replace, activity.getString(R.string.replace)))
             items.add(ActionMenuItem(R.id.menu_edit, activity.getString(R.string.edit)))
@@ -1661,6 +1673,7 @@ data class ActionMenuItem(
                 R.id.menu_browser -> "menu_browser"
                 R.id.menu_aloud -> "menu_aloud"
                 R.id.menu_bookmark -> "menu_bookmark"
+                R.id.menu_mark -> "menu_mark"
                 R.id.menu_dict -> "menu_dict"
                 R.id.menu_replace -> "menu_replace"
                 R.id.menu_edit -> "menu_edit"

+ 23 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -140,6 +140,7 @@ fun ReadBookRouteScreen(
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val aiState by viewModel.aiState.collectAsStateWithLifecycle()
     val highlightRuleState by viewModel.highlightRuleState.collectAsStateWithLifecycle()
+    val markingState by viewModel.markingState.collectAsStateWithLifecycle()
     val contentEditState by viewModel.contentEditState.collectAsStateWithLifecycle()
     val contentProcessState by viewModel.contentProcessState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
@@ -273,6 +274,12 @@ fun ReadBookRouteScreen(
         }
     }
 
+    val bookmarkBadgeImagePicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.GetContent()
+    ) { uri ->
+        uri?.let { viewModel.onIntent(ReadBookIntent.BookmarkBadgeImageSelected(it)) }
+    }
+
     val txtTocRuleLauncher = rememberLauncherForActivityResult(
         ActivityResultContracts.StartActivityForResult()
     ) { result ->
@@ -596,6 +603,7 @@ fun ReadBookRouteScreen(
                 state = state,
                 aiState = aiState,
                 highlightRuleState = highlightRuleState,
+                markingState = markingState,
                 contentEditState = contentEditState,
                 contentProcessState = contentProcessState,
                 preferences = readPreferences,
@@ -605,6 +613,8 @@ fun ReadBookRouteScreen(
                     viewModel.onIntent(ReadBookIntent.DismissSheet)
                     showSelectMenuConfigSheet = true
                 },
+                onPickBookmarkBadgeImage = { bookmarkBadgeImagePicker.launch("image/*") },
+                onResetBookmarkBadge = { viewModel.onIntent(ReadBookIntent.ClearBookmarkBadgeImage) },
             )
             val bookNavigationSheet = state.activeSheet as? ReadBookSheet.BookNavigation
             ReaderBookSheetRoute(
@@ -617,6 +627,19 @@ fun ReadBookRouteScreen(
                     viewModel.onIntent(ReadBookIntent.DismissSheet)
                     viewModel.onIntent(ReadBookIntent.OpenChapterResult(index, chapterPos))
                 },
+                onBookmarkNavigate = { bookmark ->
+                    viewModel.onIntent(ReadBookIntent.NavigateToBookmark(bookmark))
+                },
+                onMarkingNavigate = { item ->
+                    viewModel.onIntent(
+                        ReadBookIntent.NavigateToMarking(
+                            marking = item.raw,
+                        )
+                    )
+                },
+                onMarkingEdit = { markingId ->
+                    viewModel.onIntent(ReadBookIntent.EditMarking(markingId))
+                },
                 onOpenFullBookInfo = {
                     state.book?.let { book ->
                         viewModel.onIntent(ReadBookIntent.DismissSheet)

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

@@ -15,6 +15,7 @@ import coil3.ImageLoader
 import io.legado.app.R
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.domain.usecase.BookmarkTargetVerdict
 import io.legado.app.help.coil.CoverExtras
 import io.legado.app.ui.book.read.sheet.AiRewritePresetConfigSheet
 import io.legado.app.ui.book.read.sheet.AiTextCleanSheet
@@ -29,6 +30,7 @@ import io.legado.app.ui.book.read.sheet.DownloadSheet
 import io.legado.app.ui.book.read.sheet.EyeProtectionConfigSheet
 import io.legado.app.ui.book.read.sheet.FloatingBarIconConfigSheet
 import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
+import io.legado.app.ui.book.read.sheet.MarkingSheet
 import io.legado.app.ui.book.read.sheet.MoreConfigSheet
 import io.legado.app.ui.book.read.sheet.PageAnimConfigSheet
 import io.legado.app.ui.book.read.sheet.PageKeyConfigSheet
@@ -69,12 +71,15 @@ fun ReadBookScreen(
     state: ReadBookUiState,
     aiState: ReadAiUiState,
     highlightRuleState: HighlightRuleConfigUiState,
+    markingState: MarkingUiState,
     contentEditState: ContentEditUiState,
     contentProcessState: ContentProcessConfigUiState,
     preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
     onBack: () -> Unit,
     onOpenTextSelectMenuConfig: () -> Unit,
+    onPickBookmarkBadgeImage: () -> Unit,
+    onResetBookmarkBadge: () -> Unit,
 ) {
     BackHandler {
         when {
@@ -167,6 +172,30 @@ fun ReadBookScreen(
         onDismiss = { onIntent(ReadBookIntent.ExitWithoutAddingCurrentBookToBookshelf) },
     )
 
+    // 书签/笔记跳转前校验未通过的确认框
+    val pendingTarget = state.pendingBookmarkTarget
+    AppAlertDialog(
+        show = pendingTarget != null,
+        onDismissRequest = { onIntent(ReadBookIntent.CancelBookmarkTargetJump) },
+        title = stringResource(R.string.bookmark_target_may_shift),
+        text = stringResource(
+            when (pendingTarget?.verdict) {
+                is BookmarkTargetVerdict.SourceChanged ->
+                    R.string.bookmark_target_source_changed
+
+                BookmarkTargetVerdict.TitleMismatch ->
+                    R.string.bookmark_target_title_mismatch
+
+                null -> R.string.bookmark_target_title_mismatch
+                BookmarkTargetVerdict.Match -> R.string.bookmark_target_title_mismatch
+            }
+        ),
+        confirmText = stringResource(R.string.bookmark_target_jump_anyway),
+        onConfirm = { onIntent(ReadBookIntent.ConfirmBookmarkTargetJump) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(ReadBookIntent.CancelBookmarkTargetJump) },
+    )
+
     // AppModalBottomSheet-based sheets — always composed, controlled by show flag
     // for proper enter/exit animations
     val dismissSheet = { onIntent(ReadBookIntent.DismissSheet) }
@@ -262,6 +291,15 @@ fun ReadBookScreen(
         onDismissRequest = dismissSheet,
         onIntent = onIntent,
     )
+    MarkingSheet(
+        show = state.activeSheet is ReadBookSheet.Marking,
+        state = markingState,
+        onDismissRequest = { onIntent(ReadBookIntent.DismissMarking) },
+        onSave = { style, note ->
+            onIntent(ReadBookIntent.SaveMarking(style, note))
+        },
+        onDelete = { onIntent(ReadBookIntent.DeleteMarking) },
+    )
     ContentEditSheet(
         show = state.activeSheet is ReadBookSheet.ContentEdit,
         state = contentEditState,
@@ -305,6 +343,8 @@ fun ReadBookScreen(
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.PageKeyConfig))
         },
         onOpenTextSelectMenuConfig = onOpenTextSelectMenuConfig,
+        onPickBookmarkBadgeImage = onPickBookmarkBadgeImage,
+        onResetBookmarkBadge = onResetBookmarkBadge,
     )
     ReadAloudNumberConfigSheet(
         show = state.activeSheet is ReadBookSheet.PreDownloadConfig,

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

@@ -44,9 +44,12 @@ import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.CleanSelectedTextUseCase
 import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
+import io.legado.app.domain.usecase.RelocateMarkingTargetUseCase
 import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
+import io.legado.app.domain.usecase.SaveMarkingUseCase
 import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
+import io.legado.app.domain.usecase.VerifyBookmarkTargetUseCase
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
@@ -122,6 +125,9 @@ class ReadBookViewModel(
     private val cleanSelectedTextUseCase: CleanSelectedTextUseCase,
     private val aiTextFactoryUseCase: AiTextFactoryUseCase,
     private val saveBookContentProcessUseCase: SaveBookContentProcessUseCase,
+    private val saveMarkingUseCase: SaveMarkingUseCase,
+    private val verifyBookmarkTargetUseCase: VerifyBookmarkTargetUseCase,
+    private val relocateMarkingTargetUseCase: RelocateMarkingTargetUseCase,
     private val bookContentProcessGateway: BookContentProcessGateway,
     private val aiArtifactGateway: AiArtifactGateway,
     private val aiPromptPresetGateway: AiPromptPresetGateway,
@@ -210,6 +216,64 @@ class ReadBookViewModel(
 
     val contentProcessState = contentProcessDelegate.uiState
 
+    // --- 划线/高亮笔记域:一次「选中 → 配置样式/备注 → 保存」的临时会话 ---
+
+    private val markingDelegate = MarkingDelegate(
+        scope = viewModelScope,
+        context = context,
+        highlightRuleRepository = highlightRuleRepository,
+        saveMarkingUseCase = saveMarkingUseCase,
+        host = object : MarkingDelegate.Host {
+            override fun reloadCurrentChapter() {
+                contentProcessDelegate.reloadCurrentChapter()
+            }
+
+            override fun dismissMarkingSheet() {
+                restoreMarkingReturnSheet()
+            }
+
+            override fun showToast(message: String) {
+                _effects.tryEmit(ReadBookEffect.ShowToast(message))
+            }
+        },
+    )
+
+    val markingState = markingDelegate.uiState
+
+    /**
+     * 划线笔记编辑可能从目录 Sheet 进入:保存/删除/取消后应回到原 sheet(目录),
+     * 而不是被丢回阅读页。从划词菜单新建时无原 sheet,回 null。
+     */
+    private var markingReturnSheet: ReadBookSheet? = null
+
+    private fun restoreMarkingReturnSheet() {
+        val returnSheet = markingReturnSheet
+        markingReturnSheet = null
+        _uiState.update { it.copy(activeSheet = returnSheet) }
+    }
+
+    // --- 跳转校验域:书签/笔记跳转前比对源与章节标题 ---
+
+    private val bookmarkNavigateDelegate = ReadBookmarkNavigateDelegate(
+        scope = viewModelScope,
+        bookRepository = bookRepository,
+        verifyUseCase = verifyBookmarkTargetUseCase,
+        relocateMarkingTargetUseCase = relocateMarkingTargetUseCase,
+        host = object : ReadBookmarkNavigateDelegate.Host {
+            override val pendingTarget: PendingBookmarkTarget?
+                get() = _uiState.value.pendingBookmarkTarget
+
+            override fun jumpToChapter(chapterIndex: Int, chapterPos: Int) {
+                onIntent(ReadBookIntent.DismissSheet)
+                onIntent(ReadBookIntent.OpenChapterResult(chapterIndex, chapterPos))
+            }
+
+            override fun setPendingTarget(pending: PendingBookmarkTarget?) {
+                _uiState.update { it.copy(pendingBookmarkTarget = pending) }
+            }
+        },
+    )
+
     // --- AI 域(摘要 / 净化 / 重写 / 预设)---
 
     private val aiHost = object : ReadAiDelegate.Host {
@@ -425,6 +489,14 @@ class ReadBookViewModel(
         themeSettingsGateway = themeSettingsGateway,
     )
 
+    /** 自定义书签角标(拷贝落盘 + 刷新角标),独立于样式域的小委托。 */
+    private val bookmarkBadgeDelegate = BookmarkBadgeDelegate(
+        scope = viewModelScope,
+        context = context,
+        readSettingsRepository = readSettingsRepository,
+        emitEffect = _effects::tryEmit,
+    )
+
     /** 日夜切换冷却期内不再弹提醒;光线传感器回调在 RouteScreen 里先问这个再发 intent。 */
     fun isDayNightSwitchCoolingDown(): Boolean = styleDelegate.isDayNightSwitchCoolingDown()
 
@@ -919,6 +991,7 @@ class ReadBookViewModel(
                 aiDelegate.onSheetDismissed(_uiState.value.activeSheet)
                 when (_uiState.value.activeSheet) {
                     is ReadBookSheet.HighlightRuleConfig -> highlightRuleDelegate.onSheetDismissed()
+                    is ReadBookSheet.Marking -> markingDelegate.onSheetDismissed()
                     is ReadBookSheet.ContentEdit -> contentEditDelegate.onSheetDismissed()
                     is ReadBookSheet.ContentProcesses,
                     is ReadBookSheet.TextProcessing -> contentProcessDelegate.onSheetDismissed()
@@ -1311,6 +1384,11 @@ class ReadBookViewModel(
                 styleDelegate.applyBackgroundImage(intent.uri)
             is ReadBookIntent.ReadStyleImageSelectedForMode ->
                 styleDelegate.applyBackgroundImageForMode(intent.uri, intent.isNight)
+            is ReadBookIntent.BookmarkBadgeImageSelected ->
+                bookmarkBadgeDelegate.applyBadgeImage(intent.uri)
+
+            is ReadBookIntent.ClearBookmarkBadgeImage ->
+                bookmarkBadgeDelegate.clearBadgeImage()
             is ReadBookIntent.ReadStyleConfigImportSelected -> styleDelegate.importConfig(intent.uri)
             is ReadBookIntent.ReadStyleConfigExportSelected -> styleDelegate.exportConfig(intent.uri)
             is ReadBookIntent.SaveReadStyleConfig -> styleDelegate.saveCurrentStyle()
@@ -1332,6 +1410,45 @@ class ReadBookViewModel(
 
             is ReadBookIntent.TextActionBookmark -> bookmarkDelegate.openEditor(intent.bookmark)
 
+            is ReadBookIntent.OpenMarking -> {
+                // 从划词菜单新建:无原 sheet 可回
+                markingReturnSheet = null
+                markingDelegate.open(intent.selection)
+                _uiState.update { it.copy(activeSheet = ReadBookSheet.Marking) }
+            }
+
+            is ReadBookIntent.EditMarking -> {
+                // 从目录 Sheet 进入:记住原 sheet,保存/删除/取消后返回
+                markingReturnSheet = _uiState.value.activeSheet
+                markingDelegate.openForEdit(intent.id)
+                _uiState.update { it.copy(activeSheet = ReadBookSheet.Marking) }
+            }
+
+            is ReadBookIntent.DismissMarking -> {
+                markingDelegate.onSheetDismissed()
+                restoreMarkingReturnSheet()
+            }
+
+            is ReadBookIntent.SaveMarking -> {
+                markingDelegate.save(intent.style, intent.note)
+            }
+
+            is ReadBookIntent.DeleteMarking -> {
+                markingDelegate.deleteCurrent()
+            }
+
+            is ReadBookIntent.NavigateToBookmark ->
+                bookmarkNavigateDelegate.navigateToBookmark(intent.bookmark)
+
+            is ReadBookIntent.NavigateToMarking ->
+                bookmarkNavigateDelegate.navigateToMarking(intent.marking)
+
+            is ReadBookIntent.ConfirmBookmarkTargetJump ->
+                bookmarkNavigateDelegate.confirmJump()
+
+            is ReadBookIntent.CancelBookmarkTargetJump ->
+                bookmarkNavigateDelegate.cancelJump()
+
             is ReadBookIntent.TextActionReplace -> {
                 _effects.tryEmit(
                     ReadBookEffect.TextActionReplace(

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

@@ -115,6 +115,7 @@ class ReadBookmarkDelegate(
                         Bookmark(
                             bookName = book.name,
                             bookAuthor = book.author,
+                            bookUrl = book.bookUrl,
                             chapterIndex = chapter.chapter.index,
                             chapterName = chapter.title,
                             chapterPos = ReadBook.durChapterPos,
@@ -127,6 +128,7 @@ class ReadBookmarkDelegate(
                     )
                 } else {
                     // 只删离当前阅读位置最近的一条:同一页可能有多条书签,不应整页误删。
+                    // 书签与划线笔记完全独立,这里只删书签本身。
                     val nearest = existing.minByOrNull { abs(it.chapterPos - ReadBook.durChapterPos) }
                         ?: return@withLock
                     bookmarkRepository.delete(nearest)
@@ -147,6 +149,7 @@ class ReadBookmarkDelegate(
             val bookmark = Bookmark(
                 bookName = book.name,
                 bookAuthor = book.author,
+                bookUrl = book.bookUrl,
                 chapterIndex = chapter.chapter.index,
                 chapterName = chapter.title,
                 chapterPos = ReadBook.durChapterPos,

+ 135 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkNavigateDelegate.kt

@@ -0,0 +1,135 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.data.entities.BookMarking
+import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.domain.usecase.BookmarkTargetVerdict
+import io.legado.app.domain.usecase.RelocateMarkingTargetUseCase
+import io.legado.app.domain.usecase.VerifyBookmarkTargetUseCase
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.model.ReadBook
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * 书签/笔记跳转校验域(无自持渲染状态)。
+ *
+ * 换源后 bookUrl 会变、目录会重排,书签/笔记的 chapterIndex/chapterPos 是创建时源里的
+ * 坐标,可能偏移。跳转前比对创建时的源指纹与章节标题,不通过则交给 Host 弹「仍跳转」。
+ *
+ * 校验逻辑(取目标章节标题 + verify use case + 判定)住在本域;确认框状态
+ * [PendingBookmarkTarget] 是瞬态对话框状态(与 activeDialog 同性质),留 UiState。
+ */
+class ReadBookmarkNavigateDelegate(
+    private val scope: CoroutineScope,
+    private val bookRepository: BookRepository,
+    private val verifyUseCase: VerifyBookmarkTargetUseCase,
+    private val relocateMarkingTargetUseCase: RelocateMarkingTargetUseCase,
+    private val host: Host,
+) {
+
+    interface Host {
+        val pendingTarget: PendingBookmarkTarget?
+        fun jumpToChapter(chapterIndex: Int, chapterPos: Int)
+        fun setPendingTarget(pending: PendingBookmarkTarget?)
+    }
+
+    fun navigateToBookmark(bookmark: Bookmark) {
+        val book = ReadBook.book ?: return
+        scope.launch {
+            val targetTitle = bookRepository.getChapterTitle(
+                book.name, book.author, bookmark.chapterIndex,
+            )
+            val verdict = verifyUseCase.verify(
+                currentBookUrl = book.bookUrl,
+                targetChapterTitle = targetTitle,
+                storedBookUrl = bookmark.bookUrl,
+                storedChapterName = bookmark.chapterName,
+            )
+            if (verdict is BookmarkTargetVerdict.Match) {
+                host.jumpToChapter(bookmark.chapterIndex, bookmark.chapterPos)
+            } else {
+                host.setPendingTarget(
+                    PendingBookmarkTarget(bookmark.chapterIndex, bookmark.chapterPos, verdict)
+                )
+            }
+        }
+    }
+
+    fun navigateToMarking(marking: BookMarking) {
+        val book = ReadBook.book ?: return
+        val anchor = marking.anchor() ?: return
+        val chapterIndex = marking.chapterIndex ?: anchor.chapterIndex
+        val chapterPos = anchor.chapterPosition ?: 0
+        scope.launch {
+            val targetTitle = bookRepository.getChapterTitle(
+                book.name, book.author, chapterIndex,
+            )
+            val verdict = verifyUseCase.verify(
+                currentBookUrl = book.bookUrl,
+                targetChapterTitle = targetTitle,
+                storedBookUrl = marking.bookUrl,
+                storedChapterName = marking.chapterName,
+            )
+            if (verdict is BookmarkTargetVerdict.Match) {
+                host.jumpToChapter(chapterIndex, chapterPos)
+            } else {
+                relocateMarking(book, marking.chapterName, anchor)
+                    ?.let { host.jumpToChapter(it.chapterIndex, it.chapterPosition) }
+                    ?: host.setPendingTarget(
+                        PendingBookmarkTarget(
+                            chapterIndex,
+                            chapterPos,
+                            verdict
+                        )
+                    )
+            }
+        }
+    }
+
+    fun confirmJump() {
+        val pending = host.pendingTarget ?: return
+        host.setPendingTarget(null)
+        host.jumpToChapter(pending.chapterIndex, pending.chapterPos)
+    }
+
+    fun cancelJump() {
+        host.setPendingTarget(null)
+    }
+
+    /**
+     * Only inspect locally available chapter content. A missing or ambiguous match keeps the
+     * existing confirmation flow, rather than fetching network content while the sheet is open.
+     */
+    private suspend fun relocateMarking(
+        book: io.legado.app.data.entities.Book,
+        chapterName: String,
+        anchor: TextProcessAnchor,
+    ): RelocateMarkingTargetUseCase.Target? {
+        val processor = ContentProcessor.get(book)
+        val candidates = bookRepository.getChapters(book.bookUrl)
+            .asSequence()
+            .filter {
+                it.index == anchor.chapterIndex ||
+                        (chapterName.isNotBlank() && it.title == chapterName)
+            }
+            .distinctBy { it.index }
+            .mapNotNull { chapter ->
+                val rawContent = BookHelp.getContent(book, chapter) ?: return@mapNotNull null
+                RelocateMarkingTargetUseCase.Candidate(
+                    chapterIndex = chapter.index,
+                    content = processor.getContent(book, chapter, rawContent, includeTitle = false)
+                        .toString(),
+                )
+            }
+            .toList()
+        return relocateMarkingTargetUseCase.locate(anchor, candidates)
+    }
+
+    private fun BookMarking.anchor(): TextProcessAnchor? =
+        GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
+}

+ 11 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt

@@ -16,6 +16,7 @@ import io.legado.app.model.ReadSessionState
 import io.legado.app.utils.postEvent
 import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 
 /**
@@ -637,6 +638,16 @@ class ReadConfigUpdateDelegate(
                     readSettingsRepository.update { it.copy(swipeToAddBookmark = update.value) }
                 }
             }
+            is ConfigUpdate.BookmarkBadgeSize -> {
+                scope.launch {
+                    readSettingsRepository.update { it.copy(bookmarkBadgeSize = update.value) }
+                    // 等写入落地再发 UpdateStyle,否则 upBookmarkBadge 读到旧尺寸
+                    readSettingsRepository.preferences.first { it.bookmarkBadgeSize == update.value }
+                    host.emitEffect(
+                        ReadBookEffect.UpdateReadViewConfig(setOf(ConfigUpdateAction.UpdateStyle))
+                    )
+                }
+            }
             is ConfigUpdate.SliderVibrator -> {
                 scope.launch {
                     readSettingsRepository.setSliderVibrator(update.value)

+ 4 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadContentProcessDelegate.kt

@@ -18,10 +18,13 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
 /**
- * 正文处理(划词改写落库项)域(R2.2 续批)。
+ * 正文处理域(R2.2 续批)。
  *
  * 自持 [ContentProcessConfigUiState],不再让每次加载/开关/删除都 copy 整个
  * [ReadBookUiState]。章节重载是本域和 AI 域共用的动作,故走 [Host]。
+ *
+ * 只承载 AI 改写(净化/重写)等「修改正文」的记录;用户划线/高亮笔记独立存于
+ * book_marks 表,查看在目录 Sheet,不混进本表。
  */
 class ReadContentProcessDelegate(
     private val context: Context,

+ 8 - 1
app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt

@@ -11,9 +11,9 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.association.OpenUrlConfirmActivity
-import io.legado.app.ui.book.read.page.delegate.PageDelegate
 import io.legado.app.ui.book.read.ReaderLayoutController
 import io.legado.app.ui.book.read.ReaderViewport
+import io.legado.app.ui.book.read.page.delegate.PageDelegate
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -329,6 +329,12 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
                         handled = true
                     }
                 }
+                is TextColumn -> {
+                    column.markingId?.let { markingId ->
+                        requireCallBack.onMarkingClick(markingId)
+                        handled = true
+                    }
+                }
             }
         }
         return handled
@@ -798,5 +804,6 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
         fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean
         fun oldClickImg(src: String): Boolean
         fun clickImg(click: String, src: String)
+        fun onMarkingClick(markingId: String)
     }
 }

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

@@ -77,8 +77,28 @@ class PageView(
     private var isMainView = false
     var isScroll = false
 
+    /** 已应用到角标 ImageView 的宽度 px,用于跳过 layout 重复更新的热路径。 */
+    private var appliedBadgeSizePx: Int = -1
+
     private var currentTextPage: TextPage? = null
 
+    /**
+     * 当前页范围内是否落有书签。与角标同源([ReaderBookmarkState] 快照),
+     * 供角标显示与下滑手势的提示文案共用。
+     */
+    fun hasBookmarkOnCurrentPage(): Boolean {
+        val page = currentTextPage ?: return false
+        val book = ReadBook.book ?: return false
+        if (page.isMsgPage || page.lineSize <= 0) return false
+        return ReaderBookmarkState.hasBookmarkInRange(
+            bookName = book.name,
+            bookAuthor = book.author,
+            chapterIndex = page.chapterIndex,
+            startPos = page.chapterPosition,
+            endPos = page.chapterPosition + page.charSize,
+        )
+    }
+
     /**
      * 右上角书签角标:本页范围内落有书签时显示。
      *
@@ -86,18 +106,30 @@ class PageView(
      * 与下滑手势在滚动模式下同样不启用保持一致。
      */
     fun upBookmarkBadge() {
-        val page = currentTextPage
-        val book = ReadBook.book
-        val visible = book != null && page != null && !isScroll && !page.isMsgPage && page.lineSize > 0 &&
-                ReaderBookmarkState.hasBookmarkInRange(
-                    bookName = book.name,
-                    bookAuthor = book.author,
-                    chapterIndex = page.chapterIndex,
-                    startPos = page.chapterPosition,
-                    endPos = page.chapterPosition + page.charSize,
-                )
-        // 角标是固定的黄色书签丝带(见 ic_bookmark_badge),不随主题变色。
-        binding.ivBookmarkBadge.isGone = !visible
+        val visible = !isScroll && hasBookmarkOnCurrentPage()
+        val badge = binding.ivBookmarkBadge
+        if (!visible) {
+            badge.isGone = true
+            return
+        }
+        badge.isGone = false
+        // 角标为 1:2 的丝带,宽度由 bookmarkBadgeSize 决定;仅当尺寸变化才更新布局。
+        val sizeDp = ReadConfig.bookmarkBadgeSize.coerceAtLeast(1)
+        val sizePx = sizeDp.dpToPx()
+        if (appliedBadgeSizePx != sizePx) {
+            appliedBadgeSizePx = sizePx
+            badge.updateLayoutParams {
+                width = sizePx
+                height = sizePx * 2
+            }
+        }
+        // 默认是固定的黄色书签丝带(见 ic_bookmark_badge);用户可自定义为任意图片。
+        // 默认 drawable 每次 getDrawable 都是新实例,用 constantState 比对避免反复 setImageDrawable。
+        val custom = ReaderBookmarkBadge.drawable(context)
+        val target = custom ?: ContextCompat.getDrawable(context, R.drawable.ic_bookmark_badge)
+        if (badge.drawable !== target && badge.drawable?.constantState !== target?.constantState) {
+            badge.setImageDrawable(target)
+        }
     }
 
     val headerHeight: Int

+ 41 - 23
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -22,13 +22,11 @@ import android.widget.FrameLayout
 import android.widget.TextView
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updateLayoutParams
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
 import io.legado.app.model.ReadSessionState
-
 import io.legado.app.ui.book.read.page.api.DataSource
 import io.legado.app.ui.book.read.page.delegate.CoverPageDelegate
 import io.legado.app.ui.book.read.page.delegate.FadePageDelegate
@@ -45,15 +43,13 @@ import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
+import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.activity
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.invisible
-import io.legado.app.utils.statusBarHeight
-
 import io.legado.app.utils.throttle
 import java.text.BreakIterator
 import java.util.Locale
@@ -158,7 +154,7 @@ class ReadView(
             FrameLayout.LayoutParams.WRAP_CONTENT,
             Gravity.TOP or Gravity.CENTER_HORIZONTAL,
         ).apply {
-            topMargin = BOOKMARK_SWIPE_HINT_TOP_MARGIN_DP.dpToPx()
+            topMargin = BOOKMARK_SWIPE_HINT_CONTENT_TOP_MARGIN_DP.dpToPx()
         }
         visibility = View.GONE
     }
@@ -293,7 +289,9 @@ class ReadView(
                 pressDown = true
                 isMove = false
                 bookmarkSwipeEnabled = ReadConfig.swipeToAddBookmark
-                resetBookmarkSwipe()
+                // 新触摸先清零:取消上一手势遗留的弹回动画并把页面归位。若不取消,
+                // 动画会与 MOVE 的逐帧 translationY 互相抢占,起手一段明显顿挫。
+                resetBookmarkSwipe(animate = false)
                 pageDelegate?.onTouch(event)
                 pageDelegate?.onDown()
                 setStartPoint(event.x, event.y, false)
@@ -390,16 +388,20 @@ class ReadView(
     /**
      * 手势结束(抬手/取消):页面弹回原位、收起提示条。翻页委托不受影响。
      */
-    private fun resetBookmarkSwipe() {
+    private fun resetBookmarkSwipe(animate: Boolean = true) {
         isBookmarkSwipe = false
         bookmarkSwipeReleased = false
         hideBookmarkSwipeHint()
         curPage.animate().cancel()
-        curPage.animate()
-            .translationY(0f)
-            .setDuration(BOOKMARK_SWIPE_SPRING_BACK_MS)
-            .setInterpolator(DecelerateInterpolator())
-            .start()
+        if (animate && curPage.translationY != 0f) {
+            curPage.animate()
+                .translationY(0f)
+                .setDuration(BOOKMARK_SWIPE_SPRING_BACK_MS)
+                .setInterpolator(DecelerateInterpolator())
+                .start()
+        } else {
+            curPage.translationY = 0f
+        }
     }
 
     /**
@@ -428,23 +430,34 @@ class ReadView(
 
     private fun showBookmarkSwipeHint() {
         if (bookmarkSwipeHint.visibility != View.VISIBLE) {
-            // 提示条是 ReadView 的直接子 View,edge-to-edge 下会被状态栏盖住;
-            // 每次显示前按当前系统栏顶部 inset 垫高,状态栏隐藏(inset 为 0)时自然回落顶部。
+            // 当前页已有书签时下滑是取消,文案随状态切换;书签只在抬手时变更,拖动期间稳定。
+            bookmarkSwipeHint.text = if (curPage.hasBookmarkOnCurrentPage()) {
+                context.getString(R.string.bookmark_swipe_release_to_remove)
+            } else {
+                context.getString(R.string.bookmark_swipe_release_to_add)
+            }
+            // 提示条挂在阅读内容顶部(状态栏 + 页眉下方)而不是屏幕顶部——下拉时
+            // 页面移开露出的正是这块区域,提示条像从「阅读页上方」浮现。
             bookmarkSwipeHint.updateLayoutParams<FrameLayout.LayoutParams> {
-                topMargin = bookmarkSwipeHintTopInset() + BOOKMARK_SWIPE_HINT_TOP_MARGIN_DP.dpToPx()
+                topMargin =
+                    bookmarkSwipeContentTop() + BOOKMARK_SWIPE_HINT_CONTENT_TOP_MARGIN_DP.dpToPx()
             }
+            bookmarkSwipeHint.animate().cancel()
+            bookmarkSwipeHint.alpha = 0f
             bookmarkSwipeHint.visibility = View.VISIBLE
+            bookmarkSwipeHint.animate()
+                .alpha(1f)
+                .setDuration(BOOKMARK_SWIPE_HINT_FADE_MS)
+                .start()
         }
     }
 
-    private fun bookmarkSwipeHintTopInset(): Int =
-        ViewCompat.getRootWindowInsets(this)
-            ?.getInsets(WindowInsetsCompat.Type.systemBars())
-            ?.top
-            ?: context.statusBarHeight
+    /** 阅读内容顶部相对 ReadView 顶部的距离:状态栏占位 + 页眉 + 内边距。 */
+    private fun bookmarkSwipeContentTop(): Int = curPage.headerHeight
 
     private fun hideBookmarkSwipeHint() {
         if (bookmarkSwipeHint.visibility != View.GONE) {
+            bookmarkSwipeHint.animate().cancel()
             bookmarkSwipeHint.visibility = View.GONE
         }
     }
@@ -867,6 +880,8 @@ class ReadView(
         curPage.upStyle()
         prevPage.upStyle()
         nextPage.upStyle()
+        // 角标可能随样式刷新而切换自定义图/默认图(选图或恢复默认后立即生效)
+        upBookmarkBadge()
     }
 
     /**
@@ -1070,8 +1085,11 @@ class ReadView(
         /** 竖直位移需超过水平位移的这个倍数,才判定为下滑而非斜向翻页。 */
         const val VERTICAL_DOMINANCE_RATIO = 1.5f
 
-        /** 顶部提示条相对页面顶部的距离。 */
-        const val BOOKMARK_SWIPE_HINT_TOP_MARGIN_DP = 12
+        /** 顶部提示条相对阅读内容顶部的距离。 */
+        const val BOOKMARK_SWIPE_HINT_CONTENT_TOP_MARGIN_DP = 8
+
+        /** 顶部提示条淡入时长。 */
+        const val BOOKMARK_SWIPE_HINT_FADE_MS = 120L
 
         /** 顶部提示条圆角半径。 */
         const val BOOKMARK_SWIPE_HINT_CORNER_RADIUS_DP = 24f

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

@@ -0,0 +1,91 @@
+package io.legado.app.ui.book.read.page
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import com.caverock.androidsvg.SVG
+import io.legado.app.ui.config.readConfig.ReadConfig
+import io.legado.app.utils.SvgUtils
+import java.io.File
+import java.io.FileInputStream
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * 书签角标图源:把用户自定义的角标图片(PNG/JPEG/WebP/SVG 等)解码为可绘制对象并缓存。
+ *
+ * 角标是 10×20dp 的小图,且 `upBookmarkBadge` 位于翻页渲染热路径上,所以按
+ * `path + lastModified` 单条缓存——路径或文件内容变了才重新解码,避免每页读盘。
+ * 返回 null 表示未自定义(或文件失效),由调用方回退到默认黄色书签。
+ */
+object ReaderBookmarkBadge {
+
+    private class Cache(val key: String, val drawable: Drawable?)
+
+    @Volatile
+    private var cache: Cache? = null
+
+    fun drawable(context: Context): Drawable? {
+        val path = ReadConfig.bookmarkBadgeImage
+        if (path.isBlank()) {
+            cache = null
+            return null
+        }
+        val file = File(path)
+        if (!file.isFile) {
+            cache = null
+            return null
+        }
+        // 角标为 1:2 的丝带,宽度来自 bookmarkBadgeSize(dp)。尺寸参与缓存键,
+        // 调大小后同一路径也会重新解码到新分辨率。
+        val density = context.resources.displayMetrics.density
+        val sizeDp = ReadConfig.bookmarkBadgeSize.coerceAtLeast(1)
+        val targetW = (sizeDp * density).toInt().coerceAtLeast(1)
+        val targetH = (sizeDp * 2 * density).toInt().coerceAtLeast(1)
+        val key = "$path|${file.lastModified()}|${targetW}x$targetH"
+        cache?.takeIf { it.key == key }?.let { return it.drawable }
+        val bitmap = decode(file, targetW, targetH) ?: run {
+            cache = Cache(key, null)
+            return null
+        }
+        return BitmapDrawable(context.resources, bitmap).also { cache = Cache(key, it) }
+    }
+
+    private fun decode(file: File, targetW: Int, targetH: Int): Bitmap? =
+        decodeRaster(file.path, targetW, targetH) ?: decodeSvg(file, targetW, targetH)
+
+    /** 位图解码:先读边界,按目标大小的两倍做采样,避免用户选了大图撑爆内存。 */
+    private fun decodeRaster(path: String, targetW: Int, targetH: Int): Bitmap? {
+        val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+        BitmapFactory.decodeFile(path, bounds)
+        if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
+        var sample = 1
+        while (bounds.outWidth / (sample * 2) >= targetW * 2 &&
+            bounds.outHeight / (sample * 2) >= targetH * 2
+        ) {
+            sample *= 2
+        }
+        val options = BitmapFactory.Options().apply { inSampleSize = sample }
+        return BitmapFactory.decodeFile(path, options)
+    }
+
+    /**
+     * SVG 解码:缩放到角标实际像素尺寸(小图需要放大到目标大小才清晰,
+     * [SvgUtils.createBitmap] 只缩不放,不适用)。位图解码失败时才走到这里。
+     */
+    private fun decodeSvg(file: File, targetW: Int, targetH: Int): Bitmap? = runCatching {
+        val svg = SVG.getFromInputStream(FileInputStream(file))
+        val size = SvgUtils.getSize(file.path) ?: return@runCatching null
+        val scale = min(targetW / size.width.toFloat(), targetH / size.height.toFloat())
+        val width = max(1, (size.width * scale).toInt())
+        val height = max(1, (size.height * scale).toInt())
+        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        svg.setDocumentWidth("100%")
+        svg.setDocumentHeight("100%")
+        svg.renderToCanvas(Canvas(bitmap))
+        bitmap
+    }.getOrNull()
+}

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt

@@ -6,10 +6,10 @@ import android.os.Build
 import androidx.annotation.Keep
 import androidx.core.net.toUri
 import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.ResourceLoadFailureCache
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.book.read.page.ResourceLoadFailureCache
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.spToPx
 import splitties.init.appCtx
@@ -38,6 +38,7 @@ data class TextColumn(
     override val npRight: Float = 0.1f,
     override val npTop: Float = 0.1f,
     override val npBottom: Float = 0.1f,
+    val markingId: String? = null,
 ) : TextBaseColumn {
 
     override var textLine: TextLine = emptyTextLine

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

@@ -22,6 +22,7 @@ data class CharStyle(
     val npRight: Float = 0.1f,
     val npTop: Float = 0.1f,
     val npBottom: Float = 0.1f,
+    val markingId: String? = null,
 ) {
     val hasStyle: Boolean
         get() = textColor != null || bgColor != null || underlineMode != 0 || bgImage.isNotEmpty() || fontPath.isNotEmpty() || fontWeight != 400 || isItalic || fontSizeOffset != 0

+ 68 - 11
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -23,9 +23,13 @@ import io.legado.app.constant.PageAnim
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.repository.HighlightRuleRepository
+import io.legado.app.domain.model.BookContentProcessEngine
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.domain.model.TextProcessStyle
 import io.legado.app.help.book.BookContent
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.getBookSource
@@ -1441,6 +1445,7 @@ class TextChapterLayout(
                     npRight = style?.npRight ?: 0.1f,
                     npTop = style?.npTop ?: 0.1f,
                     npBottom = style?.npBottom ?: 0.1f,
+                    markingId = style?.markingId,
                 )
             }
         }
@@ -1601,22 +1606,74 @@ class TextChapterLayout(
         contents: List<String>,
     ): HighlightStyleContext? {
         val rules = compiledHighlightRules
-        if (rules.isEmpty()) return null
-        val titleStyles = createHighlightStyles(
-            text = buildTitleHighlightText(titleSegments),
-            isTitle = true,
-            rules = rules
-        )
+        val markings = textChapter.effectiveContentProcesses.filter { it.isUserMarking() }
+        if (rules.isEmpty() && markings.isEmpty()) return null
+        val titleStyles = if (rules.isEmpty()) {
+            null
+        } else {
+            createHighlightStyles(
+                text = buildTitleHighlightText(titleSegments),
+                isTitle = true,
+                rules = rules
+            )
+        }
         val bodyHighlightText = buildBodyHighlightText(contents)
-        val bodyStyles = createHighlightStyles(
-            text = bodyHighlightText.text,
-            isTitle = false,
-            rules = rules
-        )
+        var bodyStyles = if (rules.isEmpty()) {
+            null
+        } else {
+            createHighlightStyles(
+                text = bodyHighlightText.text,
+                isTitle = false,
+                rules = rules
+            )
+        }
+        bodyStyles = applyUserMarkings(bodyStyles, bodyHighlightText.text, markings)
         if (titleStyles == null && bodyStyles == null) return null
         return HighlightStyleContext(titleStyles, bodyStyles, bodyHighlightText.contentOffsets)
     }
 
+    /**
+     * 把用户划线/高亮标记的样式合并进每字符样式数组。锚点按文本 + 章节位置就近匹配,
+     * 标记是显式操作,覆盖同位置的正则高亮规则。
+     */
+    private fun applyUserMarkings(
+        existing: Array<CharStyle?>?,
+        bodyText: String,
+        markings: List<BookContentProcess>,
+    ): Array<CharStyle?>? {
+        if (markings.isEmpty()) return existing
+        var styles = existing
+        for (process in markings) {
+            val anchor = GSON.fromJsonObject<TextProcessAnchor>(process.anchorJson).getOrNull()
+                ?: continue
+            val style = GSON.fromJsonObject<TextProcessStyle>(process.styleJson).getOrNull()
+                ?: continue
+            val range = BookContentProcessEngine.resolveRange(bodyText, anchor) ?: continue
+            if (bodyText.isEmpty()) break
+            val active = styles ?: arrayOfNulls<CharStyle>(bodyText.length).also { styles = it }
+            val charStyle = style.toCharStyle(process.id.removePrefix("mark:"))
+            for (i in range.first.coerceAtLeast(0)..range.last.coerceAtMost(active.lastIndex)) {
+                active[i] = charStyle
+            }
+        }
+        return styles
+    }
+
+    private fun TextProcessStyle.toCharStyle(markingId: String? = null): CharStyle = CharStyle(
+        textColor = textColor,
+        bgColor = bgColor,
+        underlineMode = underlineMode,
+        underlineColor = underlineColor ?: textColor ?: 0xFF63C37D.toInt(),
+        underlineWidth = underlineWidth,
+        underlineOffset = underlineOffset,
+        underlineSvgPath = underlineSvgPath.orEmpty(),
+        markingId = markingId,
+    )
+
+    private fun BookContentProcess.isUserMarking(): Boolean =
+        kind == BookContentProcess.KIND_USER_UNDERLINE ||
+                kind == BookContentProcess.KIND_USER_HIGHLIGHT
+
     private fun buildTitleHighlightText(titleSegments: List<TitleSegment>?): String {
         if (titleSegments.isNullOrEmpty()) return ""
         return buildString {

+ 7 - 3
app/src/main/java/io/legado/app/ui/book/read/sheet/ContentProcessesSheet.kt

@@ -40,6 +40,10 @@ import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
 
+/**
+ * 正文处理 Sheet:只承载 AI 改写(净化/重写)等修改正文的记录。
+ * 用户划线/高亮笔记独立于本表,查看在目录 Sheet 的「笔记」页。
+ */
 @Composable
 fun ContentProcessesSheet(
     show: Boolean,
@@ -97,7 +101,9 @@ fun ContentProcessesSheet(
                             item = item,
                             onClick = { viewingItem = item },
                             onToggle = {
-                                onIntent(ReadBookIntent.ToggleContentProcess(item.id, !item.enabled))
+                                onIntent(
+                                    ReadBookIntent.ToggleContentProcess(item.id, !item.enabled)
+                                )
                             },
                             onDelete = {
                                 onIntent(ReadBookIntent.RequestDeleteContentProcess(item))
@@ -252,8 +258,6 @@ private fun contentProcessTitle(item: ContentProcessItemUi): String {
     val kind = when (item.kind) {
         BookContentProcess.KIND_AI_CLEAN -> stringResource(R.string.content_process_ai_clean)
         BookContentProcess.KIND_AI_REWRITE -> stringResource(R.string.content_process_ai_rewrite)
-        BookContentProcess.KIND_USER_UNDERLINE -> stringResource(R.string.content_process_user_underline)
-        BookContentProcess.KIND_USER_HIGHLIGHT -> stringResource(R.string.content_process_user_highlight)
         else -> item.kind
     }
     val action = when (item.actionType) {

+ 440 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/MarkingSheet.kt

@@ -0,0 +1,440 @@
+package io.legado.app.ui.book.read.sheet
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Palette
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.domain.model.MarkingEffect
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.ui.book.read.MarkingUiState
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+
+/**
+ * 划线/高亮笔记配置 Sheet。
+ *
+ * 样式来源两种:复用用户已有的一条高亮规则([MarkingUiState.highlightRules]),
+ * 或直接自定义本次的样式——上面一行预设颜色(尾部为自定义颜色),下面 5x1 效果格
+ * (单实线/波浪线/虚线/背景色/字体色)。背景色自动加 ~20% 透明度。另带备注输入。
+ * 两种进入方式:
+ * - 新增:选中文本后点「划线」([MarkingUiState.selection]);
+ * - 编辑:再次选中同一段文本,或从目录 Sheet 点标记项
+ *   ([MarkingUiState.editing] 非空),预填样式与备注,保存即更新;编辑模式可删除。
+ */
+@Composable
+fun MarkingSheet(
+    show: Boolean,
+    state: MarkingUiState,
+    onDismissRequest: () -> Unit,
+    onSave: (style: TextProcessStyle, note: String) -> Unit,
+    onDelete: () -> Unit,
+) {
+    val selection = state.selection
+    val editing = state.editing
+
+    // 编辑模式无选中文本时,预览取标记锚点里的原文。
+    val editingAnchorText = remember(editing) {
+        editing?.anchorJson
+            ?.let { GSON.fromJsonObject<TextProcessAnchor>(it).getOrNull() }
+            ?.selectedText
+            ?: ""
+    }
+
+    // 状态提升到 Sheet 顶层:底部 ColorPickerSheet 与内容共用;以 show + editing 为键,
+    // 每次打开/切到编辑模式时重置(编辑模式的样式/颜色/备注来自已有标记)。
+    val editingStyle = remember(editing) {
+        editing?.styleJson?.let { GSON.fromJsonObject<TextProcessStyle>(it).getOrNull() }
+    }
+    // 新建默认走「复用规则」,编辑已有标记时默认走自定义以展示实际存储的样式
+    var useRule by remember(show, editing) { mutableStateOf(editing == null) }
+    var selectedRuleId by remember(show, editing) { mutableStateOf<String?>(null) }
+    // 5x1 效果格 + 选中颜色:编辑模式从已有标记反推
+    var effect by remember(show, editing) {
+        mutableStateOf(MarkingEffect.fromStyle(editingStyle))
+    }
+    var markColor by remember(show, editing) {
+        mutableStateOf(MarkingEffect.colorOf(editingStyle))
+    }
+    // 隐藏透传:编辑下划线类标记时保留已有宽度/偏移/SVG(自定义模式没有这些控件)
+    var underlineWidth by remember(show, editing) {
+        mutableStateOf(editingStyle?.underlineWidth ?: 1f)
+    }
+    var underlineOffset by remember(show, editing) {
+        mutableStateOf(editingStyle?.underlineOffset ?: 2f)
+    }
+    var underlineSvgPath by remember(show, editing) {
+        mutableStateOf(editingStyle?.underlineSvgPath)
+    }
+    var showColorPicker by remember(show, editing) { mutableStateOf(false) }
+    val noteState = key(show, editing) {
+        rememberTextFieldState(initialText = editing?.note ?: "")
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = selection?.bookText?.takeIf { it.isNotBlank() } ?: editingAnchorText,
+        startAction = {
+            if (editing != null) {
+                MediumTonalButton(
+                    onClick = onDelete,
+                    icon = Icons.Default.Delete,
+                    contentDescription = stringResource(R.string.delete)
+                )
+            }
+        },
+        endAction = {
+            MediumTonalButton(
+                onClick = {
+                    val style = buildStyle(
+                        useRule = useRule,
+                        selectedRule = state.highlightRules.firstOrNull { it.id == selectedRuleId },
+                        effect = effect,
+                        markColor = markColor,
+                        underlineWidth = underlineWidth,
+                        underlineOffset = underlineOffset,
+                        underlineSvgPath = underlineSvgPath,
+                    )
+                    onSave(style, noteState.text.toString())
+                },
+                icon = Icons.Default.Save,
+                contentDescription = stringResource(android.R.string.ok)
+            )
+        }
+    ) {
+        if (selection == null && editing == null) {
+            // 编辑模式异步加载标记期间的占位,避免先空再弹内容
+            if (state.loading) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(160.dp),
+                    horizontalArrangement = Arrangement.Center,
+                    verticalAlignment = Alignment.CenterVertically,
+                ) {
+                    AppCircularProgressIndicator()
+                }
+            }
+            return@AppModalBottomSheet
+        }
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState()),
+            verticalArrangement = Arrangement.spacedBy(4.dp)
+        ) {
+            TinyDropdownSettingItem(
+                title = stringResource(R.string.bookmark_mark_style_source),
+                selectedValue = if (useRule) STYLE_SOURCE_RULE else STYLE_SOURCE_CUSTOM,
+                displayEntries = arrayOf(
+                    stringResource(R.string.bookmark_mark_reuse_rule),
+                    stringResource(R.string.bookmark_mark_custom),
+                ),
+                entryValues = arrayOf(STYLE_SOURCE_RULE, STYLE_SOURCE_CUSTOM),
+                onValueChange = { useRule = it == STYLE_SOURCE_RULE },
+            )
+
+            if (useRule) {
+                val rules = state.highlightRules
+                if (rules.isEmpty()) {
+                    EmptyMessage(
+                        message = stringResource(R.string.bookmark_mark_no_rules),
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .height(200.dp),
+                    )
+                } else {
+                    rules.forEach { rule ->
+                        val selected = selectedRuleId == rule.id
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .clickable {
+                                    selectedRuleId = if (selected) null else rule.id
+                                }
+                                .padding(horizontal = 16.dp, vertical = 12.dp),
+                            verticalAlignment = Alignment.CenterVertically,
+                        ) {
+                            Icon(
+                                imageVector = Icons.Default.Check,
+                                contentDescription = null,
+                                tint = if (selected) {
+                                    LegadoTheme.colorScheme.primary
+                                } else {
+                                    LegadoTheme.colorScheme.outlineVariant
+                                },
+                                modifier = Modifier.size(20.dp),
+                            )
+                            Spacer(modifier = Modifier.width(12.dp))
+                            Column(modifier = Modifier.weight(1f)) {
+                                AppText(
+                                    text = rule.name.ifBlank { rule.displayPattern() },
+                                    style = LegadoTheme.typography.bodyMedium,
+                                )
+                                AppText(
+                                    text = rule.styleSummary(),
+                                    style = LegadoTheme.typography.labelSmall,
+                                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                )
+                            }
+                        }
+                    }
+                }
+            } else {
+                // 自定义:预设颜色行(尾部自定义色)+ 5x1 效果格
+                MarkingColorRow(
+                    selectedColor = markColor,
+                    onColorSelected = { markColor = it },
+                    onCustomColorClick = { showColorPicker = true },
+                )
+                MarkingEffectGrid(
+                    selectedEffect = effect,
+                    onEffectSelected = { effect = it },
+                )
+            }
+
+            Spacer(Modifier.padding(top = 16.dp))
+            // 备注(笔记)
+            AppTextField(
+                state = noteState,
+                modifier = Modifier
+                    .fillMaxWidth(),
+                label = stringResource(R.string.bookmark_mark_note),
+                placeholder = {
+                    AppText(stringResource(R.string.bookmark_mark_note_hint))
+                },
+            )
+        }
+    }
+
+    ColorPickerSheet(
+        show = showColorPicker,
+        initialColor = markColor,
+        onDismissRequest = { showColorPicker = false },
+        onColorSelected = { color ->
+            markColor = color
+            showColorPicker = false
+        },
+    )
+}
+
+/** 自定义样式区:预设颜色行(尾部为自定义颜色,打开取色器)。 */
+@Composable
+private fun MarkingColorRow(
+    selectedColor: Int,
+    onColorSelected: (Int) -> Unit,
+    onCustomColorClick: () -> Unit,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .horizontalScroll(rememberScrollState())
+            .padding(vertical = 8.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        MarkingPresetColors.forEach { color ->
+            MarkingColorSwatch(
+                color = color,
+                selected = color == selectedColor,
+                onClick = { onColorSelected(color) },
+            )
+        }
+        // 尾部自定义颜色:未选自定义色时显示取色图标,选中后显示该色并高亮
+        val isCustom = selectedColor !in MarkingPresetColors
+        MarkingColorSwatch(
+            color = if (isCustom) selectedColor else null,
+            selected = isCustom,
+            onClick = onCustomColorClick,
+            custom = true,
+        )
+    }
+}
+
+@Composable
+private fun MarkingColorSwatch(
+    color: Int?,
+    selected: Boolean,
+    onClick: () -> Unit,
+    custom: Boolean = false,
+) {
+    val borderColor = if (selected) {
+        LegadoTheme.colorScheme.primary
+    } else {
+        LegadoTheme.colorScheme.outlineVariant
+    }
+    Box(
+        modifier = Modifier
+            .size(32.dp)
+            .clip(CircleShape)
+            .background(
+                if (color != null) Color(color) else LegadoTheme.colorScheme.surfaceContainerHigh
+            )
+            .border(2.dp, borderColor, CircleShape)
+            .clickable(onClick = onClick),
+        contentAlignment = Alignment.Center,
+    ) {
+        if (custom && color == null) {
+            Icon(
+                imageVector = Icons.Default.Palette,
+                contentDescription = stringResource(R.string.bookmark_mark_custom_color),
+                tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier.size(16.dp),
+            )
+        }
+    }
+}
+
+/** 5x1 效果格:单实线 / 波浪线 / 虚线 / 背景色 / 字体色。 */
+@Composable
+private fun MarkingEffectGrid(
+    selectedEffect: MarkingEffect,
+    onEffectSelected: (MarkingEffect) -> Unit,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(4.dp),
+    ) {
+        MarkingEffect.entries.forEach { entry ->
+            val selected = entry == selectedEffect
+            Box(
+                modifier = Modifier
+                    .weight(1f)
+                    .clip(RoundedCornerShape(8.dp))
+                    .background(
+                        if (selected) {
+                            LegadoTheme.colorScheme.secondaryContainer
+                        } else {
+                            LegadoTheme.colorScheme.surfaceContainer
+                        }
+                    )
+                    .clickable { onEffectSelected(entry) }
+                    .padding(vertical = 8.dp),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppText(
+                    text = stringResource(entry.labelRes()),
+                    style = LegadoTheme.typography.labelMediumEmphasized,
+                    color = if (selected) {
+                        LegadoTheme.colorScheme.onSecondaryContainer
+                    } else {
+                        LegadoTheme.colorScheme.onSurface
+                    },
+                    maxLines = 1,
+                )
+            }
+        }
+    }
+}
+
+/** 效果标签:供 MarkingSheet 5x1 效果格与目录 Sheet 笔记项共用。 */
+internal fun MarkingEffect.labelRes(): Int = when (this) {
+    MarkingEffect.SOLID -> R.string.bookmark_mark_effect_solid
+    MarkingEffect.WAVE -> R.string.bookmark_mark_effect_wave
+    MarkingEffect.DASHED -> R.string.bookmark_mark_effect_dash
+    MarkingEffect.BG -> R.string.bookmark_mark_effect_bg
+    MarkingEffect.TEXT -> R.string.bookmark_mark_effect_text
+}
+
+/** 从标记的 styleJson 反推效果(目录 Sheet 笔记项展示用)。 */
+internal fun MarkingEffect.Companion.fromStyleJson(styleJson: String?): MarkingEffect {
+    val style = styleJson?.let { GSON.fromJsonObject<TextProcessStyle>(it).getOrNull() }
+    return fromStyle(style)
+}
+
+private fun buildStyle(
+    useRule: Boolean,
+    selectedRule: HighlightRule?,
+    effect: MarkingEffect,
+    markColor: Int,
+    underlineWidth: Float,
+    underlineOffset: Float,
+    underlineSvgPath: String?,
+): TextProcessStyle {
+    if (useRule && selectedRule != null) {
+        return selectedRule.toProcessStyle()
+    }
+    val base = effect.toStyle(markColor)
+    return if (effect.isUnderline) {
+        base.copy(
+            underlineWidth = underlineWidth,
+            underlineOffset = underlineOffset,
+            underlineSvgPath = underlineSvgPath,
+        )
+    } else {
+        base
+    }
+}
+
+private fun HighlightRule.toProcessStyle(): TextProcessStyle = TextProcessStyle(
+    textColor = textColor,
+    bgColor = bgColor,
+    underlineMode = underlineMode,
+    underlineColor = underlineColor,
+    underlineWidth = underlineWidth,
+    underlineOffset = underlineOffset,
+    underlineSvgPath = underlineSvgPath,
+)
+
+/** 自定义模式的预设颜色(尾部之外的自定义色用取色器)。 */
+private val MarkingPresetColors = listOf(
+    0xFFFF5252.toInt(),
+    0xFFFF9800.toInt(),
+    0xFFFFEB3B.toInt(),
+    0xFF4CAF50.toInt(),
+    0xFF26A6D6.toInt(),
+    0xFF2196F3.toInt(),
+    0xFF9C27B0.toInt(),
+    0xFFEC407A.toInt(),
+    0xFF795548.toInt(),
+    0xFF607D8B.toInt(),
+)
+
+private const val STYLE_SOURCE_RULE = "rule"
+private const val STYLE_SOURCE_CUSTOM = "custom"

+ 34 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt

@@ -22,8 +22,10 @@ import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import org.koin.compose.koinInject
+import kotlin.math.roundToInt
 
 @Composable
 fun MoreConfigSheet(
@@ -33,6 +35,8 @@ fun MoreConfigSheet(
     onOpenClickRegionalConfig: () -> Unit,
     onOpenPageKeyConfig: () -> Unit,
     onOpenTextSelectMenuConfig: () -> Unit,
+    onPickBookmarkBadgeImage: () -> Unit,
+    onResetBookmarkBadge: () -> Unit,
 ) {
     val readSettingsRepository: ReadSettingsRepository = koinInject()
     val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle(
@@ -108,6 +112,11 @@ fun MoreConfigSheet(
                 onSwipeToAddBookmarkChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.SwipeToAddBookmark(it)))
                 },
+                onPickBookmarkBadgeImage = onPickBookmarkBadgeImage,
+                onResetBookmarkBadge = onResetBookmarkBadge,
+                onBookmarkBadgeSizeChange = {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BookmarkBadgeSize(it)))
+                },
             )
 
             // Other
@@ -239,6 +248,9 @@ private fun PageControlSettings(
     onVolumeKeyPageOnPlayChange: (Boolean) -> Unit,
     onKeyPageOnLongPressChange: (Boolean) -> Unit,
     onSwipeToAddBookmarkChange: (Boolean) -> Unit,
+    onPickBookmarkBadgeImage: () -> Unit,
+    onResetBookmarkBadge: () -> Unit,
+    onBookmarkBadgeSizeChange: (Int) -> Unit,
 ) {
     val doublePageEntries = stringArrayResource(R.array.double_page_title)
     val doublePageValues = stringArrayResource(R.array.double_page_value)
@@ -284,6 +296,28 @@ private fun PageControlSettings(
         checked = preferences.swipeToAddBookmark,
         onCheckedChange = onSwipeToAddBookmarkChange,
     )
+    TinyClickableSettingItem(
+        title = stringResource(R.string.bookmark_badge),
+        description = if (preferences.bookmarkBadgeImage.isBlank()) {
+            stringResource(R.string.bookmark_badge_default)
+        } else {
+            stringResource(R.string.bookmark_badge_custom)
+        },
+        onClick = onPickBookmarkBadgeImage,
+    )
+    if (preferences.bookmarkBadgeImage.isNotBlank()) {
+        TinyClickableSettingItem(
+            title = stringResource(R.string.bookmark_badge_reset),
+            onClick = onResetBookmarkBadge,
+        )
+    }
+    TinySliderSettingItem(
+        title = stringResource(R.string.bookmark_badge_size),
+        value = preferences.bookmarkBadgeSize.toFloat(),
+        valueRange = 6f..50f,
+        valueFormat = { "${it.roundToInt()}dp" },
+        onValueChange = { onBookmarkBadgeSizeChange(it.roundToInt()) },
+    )
 }
 
 @Composable

+ 172 - 25
app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt

@@ -34,7 +34,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.Login
-import androidx.compose.material.icons.automirrored.filled.MenuOpen
 import androidx.compose.material.icons.filled.Block
 import androidx.compose.material.icons.filled.BookmarkAdd
 import androidx.compose.material.icons.filled.CheckCircle
@@ -89,6 +88,7 @@ import io.legado.app.ui.book.toc.TocBookmarkItemUi
 import io.legado.app.ui.book.toc.TocEffect
 import io.legado.app.ui.book.toc.TocIntent
 import io.legado.app.ui.book.toc.TocItemUi
+import io.legado.app.ui.book.toc.TocMarkingItemUi
 import io.legado.app.ui.book.toc.TocUiState
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewActivity
@@ -118,6 +118,7 @@ enum class ReaderBookSheetTab {
     Information,
     Toc,
     Bookmarks,
+    Marks,
 }
 
 @Composable
@@ -128,6 +129,12 @@ fun ReaderBookSheetRoute(
     onDismissRequest: () -> Unit,
     onChapterClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
     onOpenFullBookInfo: () -> Unit,
+    /** 书签页跳转:携带完整书签供跳转前校验。 */
+    onBookmarkNavigate: (Bookmark) -> Unit = { _ -> },
+    /** 笔记页跳转:携带完整展示项供跳转前校验。 */
+    onMarkingNavigate: (TocMarkingItemUi) -> Unit = { _ -> },
+    /** 笔记页点击进入 MarkingSheet 编辑。 */
+    onMarkingEdit: (markingId: String) -> Unit = {},
     bookSource: BookSource? = null,
     onOpenChapterUrl: () -> Unit = {},
     onToggleReadUrlInBrowser: () -> Unit = {},
@@ -186,7 +193,9 @@ fun ReaderBookSheetRoute(
         },
         onIntent = viewModel::onIntent,
         onChapterClick = { index -> onChapterClick(index, 0) },
-        onBookmarkNavigate = onChapterClick,
+        onBookmarkNavigate = onBookmarkNavigate,
+        onMarkingNavigate = onMarkingNavigate,
+        onMarkingEdit = onMarkingEdit,
         onOpenFullScreen = { tab ->
             when (tab) {
                 ReaderBookSheetTab.Information -> onOpenFullBookInfo()
@@ -201,6 +210,9 @@ fun ReaderBookSheetRoute(
                             )
                     )
                 }
+
+                // 笔记页无全屏落地(TocActivity 暂无对应页)
+                ReaderBookSheetTab.Marks -> Unit
             }
         },
         onEditLocalTocRule = { regex ->
@@ -230,7 +242,9 @@ private fun ReaderBookSheet(
     onDismissRequest: () -> Unit,
     onIntent: (TocIntent) -> Unit,
     onChapterClick: (Int) -> Unit,
-    onBookmarkNavigate: (Int, Int) -> Unit,
+    onBookmarkNavigate: (Bookmark) -> Unit,
+    onMarkingNavigate: (TocMarkingItemUi) -> Unit,
+    onMarkingEdit: (String) -> Unit,
     onOpenFullScreen: (ReaderBookSheetTab) -> Unit,
     onEditLocalTocRule: (String?) -> Unit,
     onExportBookmarks: (isMarkdown: Boolean, fileName: String) -> Unit,
@@ -280,6 +294,7 @@ private fun ReaderBookSheet(
                 stringResource(R.string.information),
                 stringResource(R.string.chapter_list),
                 stringResource(R.string.bookmark),
+                stringResource(R.string.marks),
             ),
             selectedTabIndex = pagerState.currentPage,
             onTabSelected = {
@@ -287,28 +302,16 @@ private fun ReaderBookSheet(
                 onIntent(TocIntent.ClearSelection)
                 scope.launch { pagerState.animateScrollToPage(it) }
             },
+            onTabLongClick = { index ->
+                val tab = ReaderBookSheetTab.entries[index]
+                if (tab != ReaderBookSheetTab.Marks) {
+                    onDismissRequest()
+                    onOpenFullScreen(tab)
+                }
+            },
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(start = 16.dp, top = 8.dp, end = 16.dp),
-            tabEndContent = { index ->
-                Box(
-                    modifier = Modifier
-                        .padding(start = 8.dp)
-                        .size(16.dp)
-                        .clip(RoundedCornerShape(6.dp))
-                        .clickable {
-                            onDismissRequest()
-                            onOpenFullScreen(ReaderBookSheetTab.entries[index])
-                        },
-                    contentAlignment = Alignment.Center,
-                ) {
-                    Icon(
-                        Icons.AutoMirrored.Filled.MenuOpen,
-                        contentDescription = stringResource(R.string.open),
-                        modifier = Modifier.size(16.dp),
-                    )
-                }
-            },
         )
         HorizontalPager(
             state = pagerState,
@@ -340,6 +343,14 @@ private fun ReaderBookSheet(
                         onEditBookmark = { editingBookmark = it },
                         onExportBookmarks = onExportBookmarks,
                     )
+
+                    ReaderBookSheetTab.Marks -> ReaderBookMarkingsPage(
+                        markings = state.markings,
+                        currentChapterIndex = state.book?.durChapterIndex,
+                        currentBookUrl = state.book?.bookUrl,
+                        onMarkingNavigate = onMarkingNavigate,
+                        onMarkingEdit = onMarkingEdit,
+                    )
                 }
             }
         }
@@ -1069,7 +1080,7 @@ private fun ReaderBookTocMenu(
 private fun ReaderBookBookmarksPage(
     state: TocUiState,
     onIntent: (TocIntent) -> Unit,
-    onBookmarkNavigate: (Int, Int) -> Unit,
+    onBookmarkNavigate: (Bookmark) -> Unit,
     onEditBookmark: (Bookmark) -> Unit,
     onExportBookmarks: (Boolean, String) -> Unit,
 ) {
@@ -1131,7 +1142,7 @@ private fun ReaderBookBookmarksPage(
 private fun ReaderSheetBookmarkList(
     bookmarks: List<TocBookmarkItemUi>,
     currentChapterIndex: Int?,
-    onBookmarkNavigate: (Int, Int) -> Unit,
+    onBookmarkNavigate: (Bookmark) -> Unit,
     onEditBookmark: (Bookmark) -> Unit,
 ) {
     val listState = rememberLazyListState()
@@ -1170,7 +1181,7 @@ private fun ReaderSheetBookmarkList(
                 item = bookmark,
                 onClick = { onEditBookmark(bookmark.raw) },
                 onLongClick = {
-                    onBookmarkNavigate(bookmark.chapterIndex, bookmark.chapterPos)
+                    onBookmarkNavigate(bookmark.raw)
                 },
                 modifier = Modifier.animateItem(),
             )
@@ -1247,3 +1258,139 @@ private fun ReaderSheetBookmarkItem(
         }
     }
 }
+
+/**
+ * 划线/高亮笔记页:与书签一致,点按进入编辑(MarkingSheet),长按跳转到标记位置。
+ */
+@Composable
+private fun ReaderBookMarkingsPage(
+    markings: List<TocMarkingItemUi>,
+    currentChapterIndex: Int?,
+    currentBookUrl: String?,
+    onMarkingNavigate: (TocMarkingItemUi) -> Unit,
+    onMarkingEdit: (String) -> Unit,
+) {
+    if (markings.isEmpty()) {
+        Box(
+            modifier = Modifier.fillMaxSize(),
+            contentAlignment = Alignment.Center,
+        ) {
+            EmptyMessage(message = stringResource(R.string.marks_empty))
+        }
+        return
+    }
+
+    LazyColumn(
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(6.dp),
+    ) {
+        items(
+            items = markings,
+            key = { it.id },
+            contentType = { "marking" },
+        ) { marking ->
+            ReaderSheetMarkingItem(
+                item = marking,
+                isOtherSource = currentBookUrl != null &&
+                        marking.bookUrl.isNotBlank() &&
+                        marking.bookUrl != currentBookUrl,
+                onClick = { onMarkingEdit(marking.id) },
+                onLongClick = { onMarkingNavigate(marking) },
+                modifier = Modifier.animateItem(),
+            )
+        }
+    }
+}
+
+@Composable
+private fun ReaderSheetMarkingItem(
+    item: TocMarkingItemUi,
+    isOtherSource: Boolean,
+    onClick: () -> Unit,
+    onLongClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val containerColor = if (item.isDur) {
+        LegadoTheme.colorScheme.secondaryContainer
+    } else {
+        LegadoTheme.colorScheme.surfaceContainerLow
+    }
+    val contentColor = if (item.isDur) {
+        LegadoTheme.colorScheme.onSecondaryContainer
+    } else {
+        LegadoTheme.colorScheme.onSurface
+    }
+    val createdTime = remember(item.raw.createdAt) {
+        SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
+            .format(Date(item.raw.createdAt))
+    }
+
+    NormalCard(
+        onClick = onClick,
+        onLongClick = onLongClick,
+        cornerRadius = 12.dp,
+        containerColor = containerColor,
+        contentColor = contentColor,
+        modifier = modifier.fillMaxWidth(),
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                AppText(
+                    text = createdTime,
+                    style = LegadoTheme.typography.labelSmallEmphasized,
+                    color = contentColor,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                    modifier = Modifier.weight(1f),
+                )
+                AppText(
+                    text = item.chapterName.ifBlank {
+                        stringResource(R.string.chapter_index_format, item.chapterIndex + 1)
+                    },
+                    style = LegadoTheme.typography.labelSmallEmphasized,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                    modifier = Modifier.padding(end = 4.dp),
+                )
+                if (isOtherSource) {
+                    AppText(
+                        text = stringResource(R.string.marks_other_source),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.error,
+                        modifier = Modifier.padding(end = 4.dp),
+                    )
+                }
+                if (item.isDur) {
+                    Icon(
+                        imageVector = Icons.Default.LocationOn,
+                        contentDescription = null,
+                        tint = contentColor,
+                        modifier = Modifier.size(12.dp),
+                    )
+                }
+            }
+            if (item.text.isNotBlank()) {
+                AppText(
+                    text = item.text,
+                    style = LegadoTheme.typography.bodyMedium,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+            if (item.note.isNotBlank()) {
+                AppText(
+                    text = item.note,
+                    style = LegadoTheme.typography.labelMedium,
+                    color = LegadoTheme.colorScheme.primary,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+        }
+    }
+}

+ 111 - 2
app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt

@@ -204,7 +204,7 @@ fun TocScreen(
     val book = uiState.book
     val state = uiState.action
 
-    val pagerState = rememberPagerState(initialPage = initialPage.coerceIn(0, 1)) { 2 }
+    val pagerState = rememberPagerState(initialPage = initialPage.coerceIn(0, 2)) { 3 }
     val scope = rememberCoroutineScope()
 
     val listState = rememberLazyListState()
@@ -537,7 +537,8 @@ fun TocScreen(
                         AppTabRow(
                             tabTitles = listOf(
                                 stringResource(R.string.chapter_list),
-                                stringResource(R.string.bookmark)
+                                stringResource(R.string.bookmark),
+                                stringResource(R.string.marks),
                             ),
                             selectedTabIndex = pagerState.currentPage,
                             onTabSelected = { index ->
@@ -668,6 +669,16 @@ fun TocScreen(
                             bottom = 120.dp
                         )
                     )
+
+                    2 -> MarkingListContent(
+                        markings = uiState.markings,
+                        book = book,
+                        onMarkingClick = onBookmarkClick,
+                        contentPadding = adaptiveContentPaddingOnlyVertical(
+                            top = padding.calculateTopPadding(),
+                            bottom = 120.dp
+                        )
+                    )
                 }
             }
 
@@ -1036,6 +1047,104 @@ fun BookmarkListContent(
     }
 }
 
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun MarkingListContent(
+    markings: List<TocMarkingItemUi>,
+    book: Book?,
+    onMarkingClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
+    contentPadding: PaddingValues,
+) {
+    if (markings.isEmpty()) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(
+                    top = contentPadding.calculateTopPadding(),
+                    bottom = contentPadding.calculateBottomPadding(),
+                ),
+            contentAlignment = Alignment.Center,
+        ) {
+            EmptyMessage(message = stringResource(R.string.marks_empty))
+        }
+        return
+    }
+
+    FastScrollLazyColumn(
+        state = rememberLazyListState(),
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = contentPadding,
+    ) {
+        items(items = markings, key = { it.id }) { marking ->
+            val contentColor = if (marking.isDur) {
+                LegadoTheme.colorScheme.onSecondaryContainer
+            } else {
+                LegadoTheme.colorScheme.onSurface
+            }
+            NormalCard(
+                onClick = { onMarkingClick(marking.chapterIndex, marking.chapterPos) },
+                containerColor = if (marking.isDur) {
+                    LegadoTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
+                } else {
+                    LegadoTheme.colorScheme.surface
+                },
+                contentColor = contentColor,
+                cornerRadius = 0.dp,
+                modifier = Modifier
+                    .animateItem()
+                    .fillMaxWidth(),
+            ) {
+                Column(
+                    modifier = Modifier
+                        .padding(vertical = 12.dp)
+                        .adaptiveHorizontalPadding(),
+                ) {
+                    Row(verticalAlignment = Alignment.CenterVertically) {
+                        AppText(
+                            text = marking.chapterName.ifBlank {
+                                stringResource(
+                                    R.string.chapter_index_format,
+                                    marking.chapterIndex + 1
+                                )
+                            },
+                            style = LegadoTheme.typography.labelMediumEmphasized,
+                            color = contentColor,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                            modifier = Modifier.weight(1f),
+                        )
+                        if (marking.bookUrl.isNotBlank() && marking.bookUrl != book?.bookUrl) {
+                            AppText(
+                                text = stringResource(R.string.marks_other_source),
+                                style = LegadoTheme.typography.labelSmall,
+                                color = LegadoTheme.colorScheme.error,
+                            )
+                        }
+                    }
+                    AppText(
+                        text = marking.text,
+                        style = LegadoTheme.typography.bodyMedium,
+                        color = contentColor.copy(alpha = 0.78f),
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier.padding(top = 4.dp),
+                    )
+                    if (marking.note.isNotBlank()) {
+                        AppText(
+                            text = marking.note,
+                            style = LegadoTheme.typography.labelMedium,
+                            color = LegadoTheme.colorScheme.primary,
+                            maxLines = 2,
+                            overflow = TextOverflow.Ellipsis,
+                            modifier = Modifier.padding(top = 4.dp),
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
 @Composable
 private fun StatusIcon(
     isDur: Boolean,

+ 72 - 1
app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt

@@ -11,13 +11,16 @@ import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.constant.AppLog
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.domain.gateway.BookMarkingGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
@@ -35,6 +38,8 @@ import io.legado.app.model.webBook.WebBook
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.list.SelectableItem
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableSet
 import kotlinx.collections.immutable.persistentListOf
@@ -93,6 +98,21 @@ data class TocBookmarkItemUi(
     val raw: Bookmark
 )
 
+/** 划线/高亮笔记(book_marks 表)在目录 Sheet 里的展示项。 */
+@Immutable
+data class TocMarkingItemUi(
+    val id: String,
+    val chapterIndex: Int,
+    val chapterPos: Int,
+    val text: String,
+    val note: String,
+    val chapterName: String,
+    /** 创建时的源(源指纹),用于在笔记页标出跨源笔记。 */
+    val bookUrl: String,
+    val isDur: Boolean,
+    val raw: BookMarking
+)
+
 @Stable
 data class TocActionState(
     override val items: ImmutableList<TocItemUi> = persistentListOf(),
@@ -112,6 +132,7 @@ data class TocUiState(
     val book: Book? = null,
     val collapsedVolumes: ImmutableSet<Int> = persistentSetOf(),
     val bookmarks: ImmutableList<TocBookmarkItemUi> = persistentListOf(),
+    val markings: ImmutableList<TocMarkingItemUi> = persistentListOf(),
     val isSplitLongChapter: Boolean = false,
     val isReverse: Boolean = false,
 )
@@ -221,6 +242,7 @@ class TocViewModel(
     private val bookRepository: BookRepository,
     private val bookSourceRepository: BookSourceRepository,
     private val bookmarkRepository: BookmarkRepository,
+    private val bookMarkingGateway: BookMarkingGateway,
     private val readSettingsRepository: ReadSettingsRepository,
     private val otherSettingsGateway: OtherSettingsGateway,
 ) : BaseRuleViewModel<TocItemUi, TocDomainItem, Int, TocActionState>(
@@ -310,18 +332,65 @@ class TocViewModel(
                 initialValue = emptyList()
             )
 
+    val markingUiList: StateFlow<List<TocMarkingItemUi>> =
+        combine(
+            bookState.filterNotNull(),
+            _searchKey
+        ) { book, query ->
+            book to query
+        }
+            .flatMapLatest { (book, query) ->
+                // 按「书名+作者」订阅全部章节的标记:换源后跨源笔记仍列出
+                bookMarkingGateway
+                    .flowByBook(book.name, book.author)
+                    .map { list ->
+                        list
+                            .asSequence()
+                            .mapNotNull { marking ->
+                                val anchor = GSON
+                                    .fromJsonObject<TextProcessAnchor>(marking.anchorJson)
+                                    .getOrNull()
+                                    ?: return@mapNotNull null
+                                TocMarkingItemUi(
+                                    id = marking.id,
+                                    chapterIndex = marking.chapterIndex ?: anchor.chapterIndex,
+                                    chapterPos = anchor.chapterPosition ?: 0,
+                                    text = anchor.selectedText,
+                                    note = marking.note,
+                                    chapterName = marking.chapterName,
+                                    bookUrl = marking.bookUrl,
+                                    isDur = marking.chapterIndex == book.durChapterIndex,
+                                    raw = marking,
+                                )
+                            }
+                            .filter {
+                                query.isBlank() ||
+                                        it.text.contains(query, ignoreCase = true) ||
+                                        it.note.contains(query, ignoreCase = true)
+                            }
+                            .toList()
+                    }
+            }
+            .stateIn(
+                scope = viewModelScope,
+                started = SharingStarted.WhileSubscribed(5000),
+                initialValue = emptyList()
+            )
+
     val screenState: StateFlow<TocUiState> by lazy {
         combine(
             uiState,
             bookState,
             collapsedVolumes,
             bookmarkUiList,
-        ) { action, book, collapsed, bookmarks ->
+            markingUiList,
+        ) { action, book, collapsed, bookmarks, markings ->
             TocUiState(
                 action = action,
                 book = book,
                 collapsedVolumes = collapsed.toImmutableSet(),
                 bookmarks = bookmarks.toImmutableList(),
+                markings = markings.toImmutableList(),
                 isSplitLongChapter = book?.getSplitLongChapter() ?: false,
                 isReverse = book?.getReverseToc() ?: false,
             )
@@ -749,6 +818,8 @@ class TocViewModel(
             Bookmark(
                 bookName = book.name,
                 bookAuthor = book.author,
+                // 保留创建时书源,换源后跳转时才能校验章节坐标是否仍可靠。
+                bookUrl = book.bookUrl,
                 chapterIndex = item.id,
                 chapterPos = 0,
                 chapterName = item.title,

+ 2 - 2
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt

@@ -11,8 +11,6 @@ import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsBoolean
-import io.legado.app.help.config.compatDsInt
-import io.legado.app.help.config.compatDsString
 import io.legado.app.utils.isNightMode
 import io.legado.app.utils.sysConfiguration
 import org.koin.core.context.GlobalContext
@@ -76,6 +74,8 @@ object ReadConfig {
     val volumeKeyPageOnPlay get() = read.volumeKeyPageOnPlay
     val keyPageOnLongPress get() = read.keyPageOnLongPress
     val swipeToAddBookmark get() = read.swipeToAddBookmark
+    val bookmarkBadgeImage get() = read.bookmarkBadgeImage
+    val bookmarkBadgeSize get() = read.bookmarkBadgeSize
     val sliderVibrator get() = read.sliderVibrator
     val useNewTocSheet get() = read.useNewTocSheet
     val selectVibrator get() = read.selectVibrator

+ 4 - 2
app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt

@@ -1,12 +1,12 @@
 package io.legado.app.ui.widget.components.card
 
 import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
@@ -37,7 +37,8 @@ fun TextCard(
     iconSize: Dp = 14.dp,
     spacing: Dp = 4.dp,
     textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized,
-    border: BorderStroke? = null
+    border: BorderStroke? = null,
+    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
 ) {
     val defaultBackground = LegadoTheme.colorScheme.surfaceContainer
     val defaultContent = LegadoTheme.colorScheme.onSurface
@@ -58,6 +59,7 @@ fun TextCard(
                 horizontal = horizontalPadding,
                 vertical = verticalPadding
             ),
+            horizontalArrangement = horizontalArrangement,
             verticalAlignment = Alignment.CenterVertically
         ) {
 

+ 6 - 7
app/src/main/java/io/legado/app/ui/widget/components/tabRow/CardTabRow.kt

@@ -1,25 +1,22 @@
 package io.legado.app.ui.widget.components.tabRow
 
 import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.snap
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.animation.core.snap
-import androidx.compose.ui.graphics.Color
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
@@ -32,6 +29,7 @@ fun CardTabRow(
     tabTitles: List<String>,
     selectedTabIndex: Int,
     onTabSelected: (Int) -> Unit,
+    onTabLongClick: ((Int) -> Unit)? = null,
     modifier: Modifier = Modifier,
     tabEndContent: (@Composable (Int) -> Unit)? = null,
 ) {
@@ -71,6 +69,7 @@ fun CardTabRow(
 
             NormalCard(
                 onClick = { onTabSelected(index) },
+                onLongClick = onTabLongClick?.let { { it(index) } },
                 modifier = Modifier.weight(1f),
                 containerColor = containerColor,
                 contentColor = contentColor,

+ 34 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -335,7 +335,40 @@
     <string name="bookmark_added">已添加书签</string>
     <string name="bookmark_removed">已取消书签</string>
     <string name="swipe_to_add_bookmark">下滑添加书签</string>
-    <string name="bookmark_swipe_release_to_add">松手添加书签</string>
+    <string name="bookmark_swipe_release_to_add">松手以添加书签</string>
+    <string name="bookmark_swipe_release_to_remove">松手以取消书签</string>
+    <string name="bookmark_badge">书签角标</string>
+    <string name="bookmark_badge_default">默认</string>
+    <string name="bookmark_badge_custom">自定义图片</string>
+    <string name="bookmark_badge_reset">恢复默认书签角标</string>
+    <string name="bookmark_badge_size">角标大小</string>
+    <string name="menu_mark">笔记</string>
+    <string name="bookmark_mark">划线标记</string>
+    <string name="bookmark_mark_selection">选中文本</string>
+    <string name="bookmark_mark_style">标记样式</string>
+    <string name="bookmark_mark_style_source">样式来源</string>
+    <string name="bookmark_mark_reuse_rule">复用高亮规则</string>
+    <string name="bookmark_mark_custom">自定义</string>
+    <string name="bookmark_mark_no_rules">当前阅读样式没有高亮规则</string>
+    <string name="bookmark_mark_text_color">文字颜色</string>
+    <string name="bookmark_mark_bg_color">背景颜色</string>
+    <string name="bookmark_mark_effect_solid">单实线</string>
+    <string name="bookmark_mark_effect_wave">波浪线</string>
+    <string name="bookmark_mark_effect_dash">虚线</string>
+    <string name="bookmark_mark_effect_bg">背景色</string>
+    <string name="bookmark_mark_effect_text">字体色</string>
+    <string name="bookmark_mark_custom_color">自定义颜色</string>
+    <string name="bookmark_mark_note">备注</string>
+    <string name="bookmark_mark_note_hint">输入备注…</string>
+    <string name="bookmark_mark_editing">已有标记,保存将更新</string>
+    <string name="content_process_ai_section">AI 改写</string>
+    <string name="marks">笔记</string>
+    <string name="marks_empty">暂无划线笔记</string>
+    <string name="marks_other_source">其他书源</string>
+    <string name="bookmark_target_may_shift">定位可能已偏移</string>
+    <string name="bookmark_target_source_changed">此书签/笔记创建于其他书源,定位可能不准。仍要跳转吗?</string>
+    <string name="bookmark_target_title_mismatch">目标章节标题已变动,定位可能不准。仍要跳转吗?</string>
+    <string name="bookmark_target_jump_anyway">仍跳转</string>
     <string name="a11y_page_bookmarked">本页已添加书签</string>
     <string name="action_del">删除</string>
     <string name="load_over_time">加载超时</string>

+ 34 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -281,7 +281,40 @@
     <string name="bookmark_added">已添加書籤</string>
     <string name="bookmark_removed">已取消書籤</string>
     <string name="swipe_to_add_bookmark">下滑新增書籤</string>
-    <string name="bookmark_swipe_release_to_add">鬆手新增書籤</string>
+    <string name="bookmark_swipe_release_to_add">鬆手以新增書籤</string>
+    <string name="bookmark_swipe_release_to_remove">鬆手以取消書籤</string>
+    <string name="bookmark_badge">書籤角標</string>
+    <string name="bookmark_badge_default">預設</string>
+    <string name="bookmark_badge_custom">自訂圖片</string>
+    <string name="bookmark_badge_reset">恢復預設書籤角標</string>
+    <string name="bookmark_badge_size">角標大小</string>
+    <string name="menu_mark">筆記</string>
+    <string name="bookmark_mark">劃線標記</string>
+    <string name="bookmark_mark_selection">選中文字</string>
+    <string name="bookmark_mark_style">標記樣式</string>
+    <string name="bookmark_mark_style_source">樣式來源</string>
+    <string name="bookmark_mark_reuse_rule">複用高亮規則</string>
+    <string name="bookmark_mark_custom">自訂</string>
+    <string name="bookmark_mark_no_rules">目前閱讀樣式沒有高亮規則</string>
+    <string name="bookmark_mark_text_color">文字顏色</string>
+    <string name="bookmark_mark_bg_color">背景顏色</string>
+    <string name="bookmark_mark_effect_solid">單實線</string>
+    <string name="bookmark_mark_effect_wave">波浪線</string>
+    <string name="bookmark_mark_effect_dash">虛線</string>
+    <string name="bookmark_mark_effect_bg">背景色</string>
+    <string name="bookmark_mark_effect_text">字體色</string>
+    <string name="bookmark_mark_custom_color">自訂顏色</string>
+    <string name="bookmark_mark_note">備註</string>
+    <string name="bookmark_mark_note_hint">輸入備註…</string>
+    <string name="bookmark_mark_editing">已有標記,儲存將更新</string>
+    <string name="content_process_ai_section">AI 改寫</string>
+    <string name="marks">筆記</string>
+    <string name="marks_empty">暫無劃線筆記</string>
+    <string name="marks_other_source">其他書源</string>
+    <string name="bookmark_target_may_shift">定位可能已偏移</string>
+    <string name="bookmark_target_source_changed">此書籤/筆記建立於其他書源,定位可能不準。仍要跳轉嗎?</string>
+    <string name="bookmark_target_title_mismatch">目標章節標題已變動,定位可能不準。仍要跳轉嗎?</string>
+    <string name="bookmark_target_jump_anyway">仍跳轉</string>
     <string name="a11y_page_bookmarked">本頁已添加書籤</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">加載超時</string>

+ 34 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -283,7 +283,40 @@
     <string name="bookmark_added">已新增書籤</string>
     <string name="bookmark_removed">已取消書籤</string>
     <string name="swipe_to_add_bookmark">下滑新增書籤</string>
-    <string name="bookmark_swipe_release_to_add">鬆手新增書籤</string>
+    <string name="bookmark_swipe_release_to_add">鬆手以新增書籤</string>
+    <string name="bookmark_swipe_release_to_remove">鬆手以取消書籤</string>
+    <string name="bookmark_badge">書籤角標</string>
+    <string name="bookmark_badge_default">預設</string>
+    <string name="bookmark_badge_custom">自訂圖片</string>
+    <string name="bookmark_badge_reset">恢復預設書籤角標</string>
+    <string name="bookmark_badge_size">角標大小</string>
+    <string name="menu_mark">筆記</string>
+    <string name="bookmark_mark">劃線標記</string>
+    <string name="bookmark_mark_selection">選中文字</string>
+    <string name="bookmark_mark_style">標記樣式</string>
+    <string name="bookmark_mark_style_source">樣式來源</string>
+    <string name="bookmark_mark_reuse_rule">複用高亮規則</string>
+    <string name="bookmark_mark_custom">自訂</string>
+    <string name="bookmark_mark_no_rules">目前閱讀樣式沒有高亮規則</string>
+    <string name="bookmark_mark_text_color">文字顏色</string>
+    <string name="bookmark_mark_bg_color">背景顏色</string>
+    <string name="bookmark_mark_effect_solid">單實線</string>
+    <string name="bookmark_mark_effect_wave">波浪線</string>
+    <string name="bookmark_mark_effect_dash">虛線</string>
+    <string name="bookmark_mark_effect_bg">背景色</string>
+    <string name="bookmark_mark_effect_text">字體色</string>
+    <string name="bookmark_mark_custom_color">自訂顏色</string>
+    <string name="bookmark_mark_note">備註</string>
+    <string name="bookmark_mark_note_hint">輸入備註…</string>
+    <string name="bookmark_mark_editing">已有標記,儲存將更新</string>
+    <string name="content_process_ai_section">AI 改寫</string>
+    <string name="marks">筆記</string>
+    <string name="marks_empty">暫無劃線筆記</string>
+    <string name="marks_other_source">其他書源</string>
+    <string name="bookmark_target_may_shift">定位可能已偏移</string>
+    <string name="bookmark_target_source_changed">此書籤/筆記建立於其他書源,定位可能不準。仍要跳轉嗎?</string>
+    <string name="bookmark_target_title_mismatch">目標章節標題已變動,定位可能不準。仍要跳轉嗎?</string>
+    <string name="bookmark_target_jump_anyway">仍跳轉</string>
     <string name="a11y_page_bookmarked">本頁已新增書籤</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">載入超時</string>

+ 1 - 0
app/src/main/res/values/ids.xml

@@ -28,6 +28,7 @@
     <item name="menu_dict" type="id" />
     <item name="menu_edit" type="id" />
     <item name="menu_search_content" type="id" />
+    <item name="menu_mark" type="id" />
     <item name="menu_browser" type="id" />
     <item name="menu_share_str" type="id" />
 </resources>

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

@@ -366,6 +366,39 @@
     <string name="bookmark_removed">Bookmark removed</string>
     <string name="swipe_to_add_bookmark">Swipe down to add bookmark</string>
     <string name="bookmark_swipe_release_to_add">Release to add bookmark</string>
+    <string name="bookmark_swipe_release_to_remove">Release to remove bookmark</string>
+    <string name="bookmark_badge">Bookmark badge</string>
+    <string name="bookmark_badge_default">Default</string>
+    <string name="bookmark_badge_custom">Custom image</string>
+    <string name="bookmark_badge_reset">Reset bookmark badge</string>
+    <string name="bookmark_badge_size">Badge size</string>
+    <string name="menu_mark">Mark line</string>
+    <string name="bookmark_mark">Mark line</string>
+    <string name="bookmark_mark_selection">Selected text</string>
+    <string name="bookmark_mark_style">Mark style</string>
+    <string name="bookmark_mark_style_source">Style source</string>
+    <string name="bookmark_mark_reuse_rule">Reuse highlight rule</string>
+    <string name="bookmark_mark_custom">Custom</string>
+    <string name="bookmark_mark_no_rules">No highlight rules in the current reading style</string>
+    <string name="bookmark_mark_text_color">Text color</string>
+    <string name="bookmark_mark_bg_color">Background color</string>
+    <string name="bookmark_mark_effect_solid">Solid</string>
+    <string name="bookmark_mark_effect_wave">Wave</string>
+    <string name="bookmark_mark_effect_dash">Dash</string>
+    <string name="bookmark_mark_effect_bg">Bg</string>
+    <string name="bookmark_mark_effect_text">Text</string>
+    <string name="bookmark_mark_custom_color">Custom color</string>
+    <string name="bookmark_mark_note">Note</string>
+    <string name="bookmark_mark_note_hint">Write a note…</string>
+    <string name="bookmark_mark_editing">Existing mark — saving will update it</string>
+    <string name="content_process_ai_section">AI edits</string>
+    <string name="marks">Marks</string>
+    <string name="marks_empty">No marks yet</string>
+    <string name="marks_other_source">Other source</string>
+    <string name="bookmark_target_may_shift">Position may have shifted</string>
+    <string name="bookmark_target_source_changed">This bookmark/mark was created under a different source, so its position may be off. Jump anyway?</string>
+    <string name="bookmark_target_title_mismatch">The target chapter title has changed; the position may be off. Jump anyway?</string>
+    <string name="bookmark_target_jump_anyway">Jump anyway</string>
     <string name="a11y_page_bookmarked">This page is bookmarked</string>
     <string name="action_del">Delete</string>
     <string name="load_over_time">Loading timeout</string>

+ 6 - 2
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -14,11 +14,11 @@ import kotlin.reflect.full.primaryConstructor
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化映射覆盖 ReadSettings 全部 105 个字段`() {
+    fun `gateway 持久化映射覆盖 ReadSettings 全部 108 个字段`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(106, actualKeys.size)
+        assertEquals(108, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 
@@ -204,6 +204,8 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(volumeKeyPageOnPlay = false),
         base.copy(keyPageOnLongPress = true),
         base.copy(swipeToAddBookmark = true),
+        base.copy(bookmarkBadgeImage = "badge.svg"),
+        base.copy(bookmarkBadgeSize = 24),
         base.copy(sliderVibrator = true),
         base.copy(selectVibrator = true),
         base.copy(autoChangeSource = false),
@@ -247,6 +249,8 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
     PreferKey.keyPageOnLongPress to keyPageOnLongPress,
     PreferKey.swipeToAddBookmark to swipeToAddBookmark,
+    PreferKey.bookmarkBadgeImage to bookmarkBadgeImage,
+    PreferKey.bookmarkBadgeSize to bookmarkBadgeSize,
     PreferKey.pageTouchSlop to pageTouchSlop,
     PreferKey.sliderVibrator to sliderVibrator,
     PreferKey.selectVibrator to selectVibrator,

+ 85 - 0
app/src/test/java/io/legado/app/domain/model/BookContentProcessEngineTest.kt

@@ -49,6 +49,91 @@ class BookContentProcessEngineTest {
         assertTrue(result.effectiveProcesses.isNotEmpty())
     }
 
+    @Test
+    fun `用户划线标记不改文本但计入 effectiveProcesses`() {
+        val mark = markProcess(
+            selectedText = "看见桌上的信",
+            kind = BookContentProcess.KIND_USER_UNDERLINE,
+        )
+
+        val result = BookContentProcessEngine.apply(
+            content = "他走进房间,看见桌上的信。",
+            processes = listOf(mark),
+        )
+
+        // 文本不被改写
+        assertEquals("他走进房间,看见桌上的信。", result.text)
+        // 锚点能解析 ⇒ 标记有效,计入 effectiveProcesses 供渲染层应用样式
+        assertEquals(listOf(mark), result.effectiveProcesses)
+    }
+
+    @Test
+    fun `锚点解析不到的标记不进 effectiveProcesses`() {
+        val mark = markProcess(
+            selectedText = "不存在的文本",
+            kind = BookContentProcess.KIND_USER_HIGHLIGHT,
+        )
+
+        val result = BookContentProcessEngine.apply(
+            content = "他走进房间,看见桌上的信。",
+            processes = listOf(mark),
+        )
+
+        assertTrue(result.effectiveProcesses.isEmpty())
+    }
+
+    @Test
+    fun `resolveRange 返回锚点文本对应的字符区间`() {
+        val anchor = TextProcessAnchor(
+            chapterIndex = 0,
+            chapterPosition = 4,
+            selectedText = "看见桌上的信",
+            normalizedTextHash = MD5Utils.md5Encode("看见桌上的信"),
+        )
+
+        val range = BookContentProcessEngine.resolveRange(
+            content = "他走进房间,看见桌上的信。",
+            anchor = anchor,
+        )
+
+        assertEquals(6 until 12, range)
+    }
+
+    private fun markProcess(
+        selectedText: String,
+        kind: String,
+    ): BookContentProcess {
+        val normalized = BookContentProcessEngine.normalizeProcessText(selectedText)
+        return BookContentProcess(
+            id = "mark-$kind",
+            bookUrl = "book",
+            chapterIndex = 0,
+            kind = kind,
+            stage = BookContentProcess.STAGE_CONTENT,
+            target = BookContentProcess.TARGET_SELECTION,
+            anchorJson = GSON.toJson(
+                TextProcessAnchor(
+                    chapterIndex = 0,
+                    chapterPosition = 0,
+                    selectedText = normalized,
+                    normalizedTextHash = MD5Utils.md5Encode(normalized),
+                )
+            ),
+            actionJson = GSON.toJson(
+                TextProcessAction(
+                    TextProcessAction.TYPE_MARK,
+                    text = normalized
+                )
+            ),
+            styleJson = GSON.toJson(
+                TextProcessStyle(
+                    underlineMode = 1,
+                    underlineColor = 0xFFFF0000.toInt()
+                )
+            ),
+        )
+    }
+
     private fun process(
         selectedText: String,
         replacement: String,

+ 102 - 0
app/src/test/java/io/legado/app/domain/model/MarkingEffectTest.kt

@@ -0,0 +1,102 @@
+package io.legado.app.domain.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MarkingEffectTest {
+
+    @Test
+    fun `效果到样式 - 单实线是 mode 1 加线色`() {
+        val style = MarkingEffect.SOLID.toStyle(0xFFFF0000.toInt())
+        assertEquals(1, style.underlineMode)
+        assertEquals(0xFFFF0000.toInt(), style.underlineColor)
+        assertEquals(null, style.bgColor)
+        assertEquals(null, style.textColor)
+        assertTrue(MarkingEffect.SOLID.isUnderline)
+    }
+
+    @Test
+    fun `效果到样式 - 波浪线和虚线映射到对应 mode`() {
+        assertEquals(3, MarkingEffect.WAVE.toStyle(0xFFFF0000.toInt()).underlineMode)
+        assertEquals(2, MarkingEffect.DASHED.toStyle(0xFFFF0000.toInt()).underlineMode)
+    }
+
+    @Test
+    fun `效果到样式 - 背景色自动加 ~20% 透明度`() {
+        val style = MarkingEffect.BG.toStyle(0xFFFFD54F.toInt())
+        assertEquals(0x33FFD54F.toInt(), style.bgColor)
+        assertEquals(0, style.underlineMode)
+        assertFalse(MarkingEffect.BG.isUnderline)
+    }
+
+    @Test
+    fun `效果到样式 - 字体色是字色`() {
+        val style = MarkingEffect.TEXT.toStyle(0xFFFF0000.toInt())
+        assertEquals(0xFFFF0000.toInt(), style.textColor)
+        assertEquals(null, style.bgColor)
+        assertEquals(0, style.underlineMode)
+        assertFalse(MarkingEffect.TEXT.isUnderline)
+    }
+
+    @Test
+    fun `样式到效果 - 下划线 mode 反推正确`() {
+        assertEquals(
+            MarkingEffect.SOLID,
+            MarkingEffect.fromStyle(TextProcessStyle(underlineMode = 1))
+        )
+        assertEquals(
+            MarkingEffect.DASHED,
+            MarkingEffect.fromStyle(TextProcessStyle(underlineMode = 2))
+        )
+        assertEquals(
+            MarkingEffect.WAVE,
+            MarkingEffect.fromStyle(TextProcessStyle(underlineMode = 3))
+        )
+    }
+
+    @Test
+    fun `样式到效果 - 背景与字体色反推`() {
+        assertEquals(
+            MarkingEffect.BG,
+            MarkingEffect.fromStyle(TextProcessStyle(bgColor = 0x33FFD54F.toInt()))
+        )
+        assertEquals(
+            MarkingEffect.TEXT,
+            MarkingEffect.fromStyle(TextProcessStyle(textColor = 0xFFFF0000.toInt()))
+        )
+    }
+
+    @Test
+    fun `样式到效果 - 未知模式回退单实线`() {
+        assertEquals(MarkingEffect.SOLID, MarkingEffect.fromStyle(TextProcessStyle()))
+        assertEquals(MarkingEffect.SOLID, MarkingEffect.fromStyle(null))
+        // mode 4(标题强调条)/ mode 5(SVG)不属于 5x1,回退单实线
+        assertEquals(
+            MarkingEffect.SOLID,
+            MarkingEffect.fromStyle(TextProcessStyle(underlineMode = 4))
+        )
+    }
+
+    @Test
+    fun `展示色 - 背景剥 alpha 取底色,下划线取线色`() {
+        assertEquals(
+            0xFFFFD54F.toInt(),
+            MarkingEffect.colorOf(TextProcessStyle(bgColor = 0x33FFD54F.toInt()))
+        )
+        assertEquals(
+            0xFFFF0000.toInt(),
+            MarkingEffect.colorOf(
+                TextProcessStyle(
+                    underlineMode = 1,
+                    underlineColor = 0xFFFF0000.toInt()
+                )
+            ),
+        )
+        assertEquals(
+            MarkingEffect.DEFAULT_COLOR,
+            MarkingEffect.colorOf(null),
+        )
+    }
+}

+ 33 - 0
app/src/test/java/io/legado/app/domain/usecase/RelocateMarkingTargetUseCaseTest.kt

@@ -0,0 +1,33 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.model.TextProcessAnchor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class RelocateMarkingTargetUseCaseTest {
+    private val useCase = RelocateMarkingTargetUseCase()
+
+    @Test
+    fun `uses surrounding context to find the current source position`() {
+        val target = useCase.locate(
+            TextProcessAnchor(2, 100, "同一句话", "门被推开,", "灯光亮起。", "hash"),
+            listOf(
+                RelocateMarkingTargetUseCase.Candidate(
+                    5,
+                    "序言。门被推开,同一句话灯光亮起。结尾。"
+                )
+            ),
+        )
+        assertEquals(RelocateMarkingTargetUseCase.Target(5, 8), target)
+    }
+
+    @Test
+    fun `does not automatically choose equally scored duplicate text`() {
+        val target = useCase.locate(
+            TextProcessAnchor(2, 100, "同一句话", normalizedTextHash = "hash"),
+            listOf(RelocateMarkingTargetUseCase.Candidate(2, "同一句话。中间。同一句话。")),
+        )
+        assertNull(target)
+    }
+}

+ 235 - 0
app/src/test/java/io/legado/app/domain/usecase/SaveMarkingUseCaseTest.kt

@@ -0,0 +1,235 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.BookMarking
+import io.legado.app.domain.gateway.BookMarkingGateway
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SaveMarkingUseCaseTest {
+
+    private val bookName = "书名"
+    private val bookAuthor = "作者"
+    private val bookUrl = "book-url"
+
+    @Test
+    fun `save 落 book_marks 一条标记并归一化选中文本`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+
+        useCase.save(
+            bookName = bookName,
+            bookAuthor = bookAuthor,
+            bookUrl = bookUrl,
+            chapterIndex = 2,
+            chapterPosition = 100,
+            selectedText = " 看见桌上的信 ",
+            style = TextProcessStyle(underlineMode = 1, underlineColor = 0xFFFF0000.toInt()),
+            chapterName = "第三章",
+            note = "这段很重要",
+        )
+
+        val marks = gateway.getByBook(bookName, bookAuthor, 2)
+        assertEquals(1, marks.size)
+        val mark = marks.first()
+        assertEquals(bookUrl, mark.bookUrl)
+        assertEquals(bookName, mark.bookName)
+        assertEquals(bookAuthor, mark.bookAuthor)
+        assertEquals("第三章", mark.chapterName)
+        assertEquals("这段很重要", mark.note)
+        val anchor = GSON.fromJsonObject<TextProcessAnchor>(mark.anchorJson).getOrNull()!!
+        assertEquals(100, anchor.chapterPosition)
+        assertEquals("看见桌上的信", anchor.selectedText) // trim 归一化
+        val style = GSON.fromJsonObject<TextProcessStyle>(mark.styleJson).getOrNull()!!
+        assertEquals(1, style.underlineMode)
+        assertEquals(0xFFFF0000.toInt(), style.underlineColor)
+    }
+
+    @Test
+    fun `同锚点重复保存原地更新而非重建`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+
+        val first = useCase.save(
+            bookName, bookAuthor, bookUrl, 2, 100, "看见桌上的信",
+            TextProcessStyle(underlineMode = 1), "第三章", "旧备注",
+        )
+        val second = useCase.save(
+            bookName, bookAuthor, bookUrl, 2, 100, "看见桌上的信",
+            TextProcessStyle(bgColor = 0x33FFD54F.toInt()), "第三章", "新备注",
+        )
+
+        // 保留 id 与 createdAt,只改 style/note —— 同锚点只有一条标记
+        assertEquals(first.id, second.id)
+        assertEquals(first.createdAt, second.createdAt)
+        assertEquals(1, gateway.getByBook(bookName, bookAuthor, 2).size)
+        val style = GSON.fromJsonObject<TextProcessStyle>(second.styleJson).getOrNull()!!
+        assertEquals(0x33FFD54F.toInt(), style.bgColor)
+        assertEquals("新备注", second.note)
+    }
+
+    @Test
+    fun `find 按锚点命中已有标记,用于再次选中划线预填`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+        useCase.save(
+            bookName, bookAuthor, bookUrl, 2, 100, "看见桌上的信",
+            TextProcessStyle(underlineMode = 2), "第三章", "备注",
+        )
+
+        val found = useCase.find(bookName, bookAuthor, 2, 100, "看见桌上的信")
+        assertEquals("备注", found?.note)
+
+        // 位置不同则不命中(视为新标记)
+        val missed = useCase.find(bookName, bookAuthor, 2, 200, "看见桌上的信")
+        assertEquals(null, missed)
+    }
+
+    @Test
+    fun `findById 按 id 取标记,供编辑模式预填`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+        val mark = useCase.save(
+            bookName, bookAuthor, bookUrl, 2, 100, "看见桌上的信",
+            TextProcessStyle(underlineMode = 1), "第三章", "备注",
+        )
+
+        val found = useCase.findById(mark.id)
+        assertEquals(mark.id, found?.id)
+        assertEquals("备注", found?.note)
+        assertEquals(null, useCase.findById("not-exist"))
+    }
+
+    @Test
+    fun `delete 只删 book_marks 行`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+        val mark = useCase.save(
+            bookName, bookAuthor, bookUrl, 2, 100, "看见桌上的信",
+            TextProcessStyle(underlineMode = 1),
+        )
+
+        useCase.delete(mark.id)
+
+        assertTrue(gateway.getByBook(bookName, bookAuthor, 2).isEmpty())
+    }
+
+    @Test
+    fun `并发保存同锚点只留一条标记`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+
+        // 快速双击:两个 save 并发(内部 withContext(IO))。Mutex 串行化后,
+        // 第二个能看到第一个的结果走原地更新,而不是各自查出「无旧标记」重复插入。
+        coroutineScope {
+            launch {
+                useCase.save(
+                    bookName, bookAuthor, bookUrl, 2, 100, "看见桌上的信",
+                    TextProcessStyle(underlineMode = 1),
+                )
+            }
+            launch {
+                useCase.save(
+                    bookName, bookAuthor, bookUrl, 2, 100, "看见桌上的信",
+                    TextProcessStyle(underlineMode = 2),
+                )
+            }
+        }
+
+        val marks = gateway.getByBook(bookName, bookAuthor, 2)
+        // Mutex 串行化后不会重复插入;两次保存的先后由 IO 调度决定,样式不具确定性
+        assertEquals(1, marks.size)
+    }
+
+    @Test
+    fun `空白选中文本拒绝保存`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+        var threw = false
+        try {
+            useCase.save(
+                bookName, bookAuthor, bookUrl, 2, 100, "   ",
+                TextProcessStyle(),
+            )
+        } catch (_: IllegalArgumentException) {
+            threw = true
+        }
+        assertTrue(threw)
+        assertTrue(gateway.getByBook(bookName, bookAuthor, 2).isEmpty())
+    }
+
+    @Test
+    fun `保存锚点前后文供换源定位消歧`() = runBlocking {
+        val gateway = FakeBookMarkingGateway()
+        val useCase = SaveMarkingUseCase(gateway)
+
+        val mark = useCase.save(
+            bookName = bookName,
+            bookAuthor = bookAuthor,
+            bookUrl = bookUrl,
+            chapterIndex = 2,
+            chapterPosition = 100,
+            selectedText = "看见桌上的信",
+            style = TextProcessStyle(),
+            contextBefore = "她停在门前。",
+            contextAfter = "信封已经泛黄。",
+        )
+
+        val anchor = GSON.fromJsonObject<TextProcessAnchor>(mark.anchorJson).getOrNull()!!
+        assertEquals("她停在门前。", anchor.contextBefore)
+        assertEquals("信封已经泛黄。", anchor.contextAfter)
+    }
+
+    private class FakeBookMarkingGateway : BookMarkingGateway {
+        private val marks = mutableListOf<BookMarking>()
+
+        override suspend fun getByBook(
+            bookName: String,
+            bookAuthor: String,
+            chapterIndex: Int?,
+        ): List<BookMarking> = getByBookSync(bookName, bookAuthor, chapterIndex)
+
+        override fun flowByBook(
+            bookName: String,
+            bookAuthor: String,
+        ): Flow<List<BookMarking>> = flowOf(getByBookSync(bookName, bookAuthor, null))
+
+        private fun getByBookSync(
+            bookName: String,
+            bookAuthor: String,
+            chapterIndex: Int?,
+        ): List<BookMarking> =
+            marks.filter {
+                it.bookName == bookName &&
+                        it.bookAuthor == bookAuthor &&
+                        (chapterIndex == null || it.chapterIndex == chapterIndex)
+            }
+
+        override suspend fun getById(id: String): BookMarking? =
+            marks.firstOrNull { it.id == id }
+
+        override suspend fun upsert(bookMarking: BookMarking) {
+            marks.removeAll { it.id == bookMarking.id }
+            marks.add(bookMarking)
+        }
+
+        override suspend fun setEnabled(id: String, enabled: Boolean) {
+            marks.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { i ->
+                marks[i] = marks[i].copy(enabled = enabled)
+            }
+        }
+
+        override suspend fun delete(id: String) {
+            marks.removeAll { it.id == id }
+        }
+    }
+}

+ 73 - 0
app/src/test/java/io/legado/app/domain/usecase/VerifyBookmarkTargetUseCaseTest.kt

@@ -0,0 +1,73 @@
+package io.legado.app.domain.usecase
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class VerifyBookmarkTargetUseCaseTest {
+
+    private val useCase = VerifyBookmarkTargetUseCase()
+
+    @Test
+    fun `源相同且章节标题一致则直接跳`() {
+        assertEquals(
+            BookmarkTargetVerdict.Match,
+            useCase.verify(
+                currentBookUrl = "srcA",
+                targetChapterTitle = "第三章",
+                storedBookUrl = "srcA",
+                storedChapterName = "第三章"
+            ),
+        )
+    }
+
+    @Test
+    fun `源指纹不同则提示源变更`() {
+        // 换源后书签/笔记位置可疑
+        assertEquals(
+            BookmarkTargetVerdict.SourceChanged("srcA"),
+            useCase.verify(
+                currentBookUrl = "srcB",
+                targetChapterTitle = "第三章",
+                storedBookUrl = "srcA",
+                storedChapterName = "第三章"
+            ),
+        )
+    }
+
+    @Test
+    fun `源未知且章节标题不符则提示标题变更`() {
+        // 旧数据 bookUrl 为空 → 跳过源校验,落到标题校验
+        assertEquals(
+            BookmarkTargetVerdict.TitleMismatch,
+            useCase.verify(
+                currentBookUrl = "srcB",
+                targetChapterTitle = "第二章",
+                storedBookUrl = "",
+                storedChapterName = "第三章"
+            ),
+        )
+    }
+
+    @Test
+    fun `目标标题拿不到时只看源校验`() {
+        // targetChapterTitle null(index 越界/未加载)→ 跳过标题校验
+        assertEquals(
+            BookmarkTargetVerdict.Match,
+            useCase.verify(
+                currentBookUrl = "srcA",
+                targetChapterTitle = null,
+                storedBookUrl = "srcA",
+                storedChapterName = "第三章"
+            ),
+        )
+        assertEquals(
+            BookmarkTargetVerdict.SourceChanged("srcA"),
+            useCase.verify(
+                currentBookUrl = "srcB",
+                targetChapterTitle = null,
+                storedBookUrl = "srcA",
+                storedChapterName = "第三章"
+            ),
+        )
+    }
+}

+ 3 - 0
app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.read
 
+import io.legado.app.ui.book.read.ConfigUpdateActionsInvariantTest.Companion.NO_RENDER_EFFECT
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import kotlin.reflect.KClass
@@ -103,6 +104,7 @@ class ConfigUpdateActionsInvariantTest {
             "MenuTopBarBlurMode",
             "MenuTopBarBlurSelection",
             "MenuTopBarLiquidGlassButtons",
+            "MenuTopBarMergeButtons",
             "MenuTopBarTitleCapsule",
             "MenuBottomBarBlurMode",
             "MenuBottomBarBlurStyle",
@@ -128,6 +130,7 @@ class ConfigUpdateActionsInvariantTest {
             "VolumeKeyPageOnPlay",
             "KeyPageOnLongPress",
             "SwipeToAddBookmark",
+            "BookmarkBadgeSize",
             "SliderVibrator",
             "SelectVibrator",
             "ClickImgWay",

+ 65 - 3
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.read
 
+import io.legado.app.ui.book.read.ReadBookDomainSplitBoundaryTest.Companion.DOMAINS
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
@@ -89,16 +90,42 @@ class ReadBookDomainSplitBoundaryTest {
      * 2523 → 2533:分支基准移动了 10 行。2523 是 PR 基于更早的 main(VM 2515 行)定
      * 的线;合入当前 main 后 VM 已 2525 行(上游页眉页脚对齐、书籍信息/目录改 Sheet
      * 等),本 PR 的 8 行接线叠加为 2533。溢出全部来自上游合并,不是新长出来的域。
+     *
+     * 2533 → 2546:自定义书签角标域。2533 是上一条合入后 VM 恰好在线的值;后续上游
+     * 又小幅增长到 2534,验收线本身已过时。本次新增 `BookmarkBadgeDelegate` 域留在
+     * VM 的是 12 行纯接线——构造参数四个、两个意图分支各一行,逐行都摘不掉:文件
+     * 拷贝需要 IO 协程与 context,只能住在 delegate,VM 只转发(与书签域同款)。
+     *
+     * 2546 → 2589:划线/高亮笔记域(初版联动了书签,`SaveBookmarkMarkingUseCase`
+     * 同批注入书签/正文处理两个 delegate 做双向删除)。VM 的 43 行增量全是接线——
+     * 新增构造参数与 import、delegate 装配(Host 三个方法)、三个意图分支。逐行都
+     * 摘不掉:意图入口与 Host 实现只能在 VM。
+     *
+     * 2589:该域后改为独立 `book_marks` 表(与书签、AI 正文处理完全解耦),
+     * `BookmarkMarking*` 更名为 `Marking*`,`SaveMarkingUseCase` 不再碰书签。查看迁到
+     * 目录 Sheet 的「笔记」页(TocViewModel 自持 flow),正文处理域退回纯 AI,VM 无
+     * bookMarkingGateway 注入,划线域接线总量反而下降,本线不缩。
+     *
+     * 2589 → 2595:划线笔记编辑入口。目录 Sheet 笔记页点标记项进 MarkingSheet 编辑,
+     * 新增 `EditMarking`/`DeleteMarking` 两个意图分支共 6 行,纯接线——意图入口只能在 VM。
+     *
+     * 2595 → 2608:编辑后返回原 sheet。从目录 Sheet 进编辑,保存/删除/取消要回目录而
+     * 非阅读页——`markingReturnSheet` 字段、恢复函数与三个意图分支各记几行,共 13 行。
+     * activeSheet 在 UiState 里,只有 VM 能管,摘不成 delegate。
+     *
+     * 2608 → 2643:书签/笔记跳转校验域。新增 `ReadBookmarkNavigateDelegate` 域,VM 留
+     * 35 行纯接线——构造 + Host(Host 的 jumpToChapter 要 onIntent 派发、setPendingTarget
+     * 要写 UiState,只能 VM)、四个意图分支、构造参数与 import。校验逻辑全在 delegate。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2533 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2643 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2533。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2643。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2533,
+            lineCount <= 2643,
         )
     }
 
@@ -277,6 +304,41 @@ class ReadBookDomainSplitBoundaryTest {
                     "upReplaceRules",
                 ),
             ),
+            // 书签角标域无自持状态:图片拷贝落盘与解码缓存都在 delegate / 渲染层,
+            // 靠 stateTypes 守「文件操作逻辑不回流 VM」(VM 只转发意图)
+            DomainSplit(
+                name = "书签角标",
+                delegateFile = "io/legado/app/ui/book/read/BookmarkBadgeDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf(
+                    "copyToAppStorage",
+                    "bookmark_badge.",
+                ),
+            ),
+            // 划线笔记域自持临时会话状态:配置会话与落库都在 delegate / use case,
+            // book_marks 表独立于书签与 AI 正文处理,靠 stateTypes 守「标记会话与
+            // 保存逻辑不回流 VM」(VM 只转发意图并注入 use case)
+            DomainSplit(
+                name = "划线笔记",
+                delegateFile = "io/legado/app/ui/book/read/MarkingDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf(
+                    "MarkingUiState",
+                    "saveMarkingUseCase.save",
+                    "highlightRuleRepository.load",
+                ),
+            ),
+            // 跳转校验域无自持状态:校验逻辑在 delegate,确认框状态 pendingBookmarkTarget
+            // 是瞬态对话框(同 activeDialog),留 UiState;靠 stateTypes 守「校验与跳转不回流 VM」
+            DomainSplit(
+                name = "跳转校验",
+                delegateFile = "io/legado/app/ui/book/read/ReadBookmarkNavigateDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf(
+                    "verifyUseCase.verify",
+                    "bookRepository.getChapterTitle",
+                ),
+            ),
         )
 
         val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")