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

[修复] bookName 导致同名不同作者的书籍会被错误合并阅读时长

Kudomaga 8 месяцев назад
Родитель
Сommit
afd295f3f1

+ 2044 - 0
app/schemas/io.legado.app.data.AppDatabase/83.json

@@ -0,0 +1,2044 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 83,
+    "identityHash": "698a30613a6ad9c2197b1ac9e55c6f8b",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          }
+        ]
+      },
+      {
+        "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, 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"
+          }
+        ],
+        "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, 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"
+          }
+        ],
+        "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, `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": "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, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `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": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "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, `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
+          },
+          {
+            "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, `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
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "rule",
+            "columnName": "rule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      }
+    ],
+    "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, '698a30613a6ad9c2197b1ac9e55c6f8b')"
+    ]
+  }
+}

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

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

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

@@ -20,6 +20,7 @@ object DatabaseMigrations {
             migration_31_32, migration_32_33, migration_33_34, migration_34_35,
             migration_35_36, migration_36_37, migration_37_38, migration_38_39,
             migration_39_40, migration_40_41, migration_41_42, migration_42_43,
+            migration_82_83,
         )
     }
 
@@ -324,6 +325,132 @@ object DatabaseMigrations {
         }
     }
 
+    private val migration_82_83 = object : Migration(82, 83) {
+        override fun migrate(database: SupportSQLiteDatabase) {
+            database.execSQL("ALTER TABLE readRecord RENAME TO readRecord_old")
+            database.execSQL(
+                """
+                CREATE TABLE IF NOT EXISTS `readRecord` (
+                    `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`)
+                )
+                """
+            )
+            database.execSQL(
+                """
+                INSERT INTO readRecord(deviceId, bookName, bookAuthor, readTime, lastRead)
+                SELECT
+                    rr.deviceId,
+                    rr.bookName,
+                    IFNULL(
+                        (
+                            SELECT CASE
+                                WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
+                                ELSE ''
+                            END
+                            FROM books b
+                            WHERE b.name = rr.bookName
+                        ),
+                        ''
+                    ) AS bookAuthor,
+                    rr.readTime,
+                    rr.lastRead
+                FROM readRecord_old rr
+                """
+            )
+            database.execSQL("DROP TABLE readRecord_old")
+
+            database.execSQL("ALTER TABLE readRecordDetail RENAME TO readRecordDetail_old")
+            database.execSQL(
+                """
+                CREATE TABLE IF NOT EXISTS `readRecordDetail` (
+                    `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`)
+                )
+                """
+            )
+            database.execSQL(
+                """
+                INSERT INTO readRecordDetail(
+                    deviceId, bookName, bookAuthor, date, readTime, readWords, firstReadTime, lastReadTime
+                )
+                SELECT
+                    rd.deviceId,
+                    rd.bookName,
+                    IFNULL(
+                        (
+                            SELECT CASE
+                                WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
+                                ELSE ''
+                            END
+                            FROM books b
+                            WHERE b.name = rd.bookName
+                        ),
+                        ''
+                    ) AS bookAuthor,
+                    rd.date,
+                    rd.readTime,
+                    rd.readWords,
+                    rd.firstReadTime,
+                    rd.lastReadTime
+                FROM readRecordDetail_old rd
+                """
+            )
+            database.execSQL("DROP TABLE readRecordDetail_old")
+
+            database.execSQL("ALTER TABLE readRecordSession RENAME TO readRecordSession_old")
+            database.execSQL(
+                """
+                CREATE TABLE IF NOT EXISTS `readRecordSession` (
+                    `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
+                )
+                """
+            )
+            database.execSQL(
+                """
+                INSERT INTO readRecordSession(id, deviceId, bookName, bookAuthor, startTime, endTime, words)
+                SELECT
+                    rs.id,
+                    rs.deviceId,
+                    rs.bookName,
+                    IFNULL(
+                        (
+                            SELECT CASE
+                                WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
+                                ELSE ''
+                            END
+                            FROM books b
+                            WHERE b.name = rs.bookName
+                        ),
+                        ''
+                    ) AS bookAuthor,
+                    rs.startTime,
+                    rs.endTime,
+                    rs.words
+                FROM readRecordSession_old rs
+                """
+            )
+            database.execSQL("DROP TABLE readRecordSession_old")
+        }
+    }
+
 
     @Suppress("ClassName")
     class Migration_54_55 : AutoMigrationSpec {
@@ -371,4 +498,4 @@ object DatabaseMigrations {
     )
     class Migration_64_65 : AutoMigrationSpec
 
-}
+}

+ 55 - 33
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -26,10 +26,10 @@ interface ReadRecordDao {
 
     @get:Query(
         """
-        select bookName, sum(readTime) as readTime, max(lastRead) as lastRead 
+        select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead 
         from readRecord 
-        group by bookName 
-        order by bookName collate localized"""
+        group by bookName, bookAuthor 
+        order by bookName collate localized, bookAuthor collate localized"""
     )
     val allShow: List<ReadRecordShow>
 
@@ -38,22 +38,22 @@ interface ReadRecordDao {
 
     @Query(
         """
-        select bookName, sum(readTime) as readTime, max(lastRead) as lastRead 
+        select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead 
         from readRecord 
-        where bookName like '%' || :searchKey || '%'
-        group by bookName 
-        order by bookName collate localized"""
+        where bookName like '%' || :searchKey || '%' or bookAuthor like '%' || :searchKey || '%'
+        group by bookName, bookAuthor 
+        order by bookName collate localized, bookAuthor collate localized"""
     )
     fun search(searchKey: String): List<ReadRecordShow>
 
     @Query("select sum(readTime) from readRecord where bookName = :bookName")
     fun getReadTime(bookName: String): Long?
 
-    @Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName")
-    fun getReadTime(deviceId: String, bookName: String): Long?
+    @Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName and bookAuthor = :bookAuthor")
+    fun getReadTime(deviceId: String, bookName: String, bookAuthor: String): Long?
 
-    @Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName")
-    suspend fun getReadRecord(deviceId: String, bookName: String): ReadRecord?
+    @Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
+    suspend fun getReadRecord(deviceId: String, bookName: String, bookAuthor: String): ReadRecord?
 
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     suspend fun insert(vararg readRecord: ReadRecord)
@@ -67,8 +67,8 @@ interface ReadRecordDao {
     @Query("delete from readRecord")
     fun clear()
 
-    @Query("delete from readRecord where bookName = :bookName")
-    fun deleteByName(bookName: String)
+    @Query("delete from readRecord where bookName = :bookName and bookAuthor = :bookAuthor")
+    fun deleteByName(bookName: String, bookAuthor: String)
 
     /**
      * 插入或更新每日聚合统计记录。
@@ -80,8 +80,8 @@ interface ReadRecordDao {
      * 获取某一本书某一天的详细统计
      * @param date 日期, 推荐格式: YYYY-MM-DD
      */
-    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND date = :date")
-    suspend fun getDetail(deviceId: String, bookName: String, date: String): ReadRecordDetail?
+    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor AND date = :date")
+    suspend fun getDetail(deviceId: String, bookName: String, bookAuthor: String, date: String): ReadRecordDetail?
 
     /**
      * 查询所有发生过阅读的日期(用于日历标记)
@@ -96,15 +96,15 @@ interface ReadRecordDao {
     suspend fun getDetailsByDate(deviceId: String, date: String): List<ReadRecordDetail>
 
     // 清除每天的统计记录
-    @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName")
-    fun deleteDetailByName(bookName: String)
+    @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
+    fun deleteDetailByName(bookName: String, bookAuthor: String)
 
     /**
      * 获取指定书籍的最后一条阅读会话
      * 用于判断是否可以合并
      */
-    @Query("SELECT * FROM readRecordSession WHERE bookName = :bookName ORDER BY endTime DESC LIMIT 1")
-    suspend fun getLatestSessionByBook(bookName: String): ReadRecordSession?
+    @Query("SELECT * FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY endTime DESC LIMIT 1")
+    suspend fun getLatestSessionByBook(bookName: String, bookAuthor: String): ReadRecordSession?
 
     /**
      * 更新现有的会话
@@ -123,9 +123,16 @@ interface ReadRecordDao {
     fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>>
 
     /** 搜索 ReadRecord,按最后阅读时间倒序排列 */
-    @Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
+    @Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%' ORDER BY lastRead DESC")
     fun searchReadRecordsByLastRead(query: String): Flow<List<ReadRecord>>
 
+    @Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor != :excludeAuthor ORDER BY lastRead DESC")
+    suspend fun getReadRecordsByNameExcludingAuthor(
+        deviceId: String,
+        bookName: String,
+        excludeAuthor: String
+    ): List<ReadRecord>
+
     /**
      * 获取某一天某一本书的所有会话记录
      */
@@ -133,10 +140,16 @@ interface ReadRecordDao {
         SELECT * FROM readRecordSession 
         WHERE deviceId = :deviceId 
         AND bookName = :bookName 
+        AND bookAuthor = :bookAuthor 
         AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date 
         ORDER BY startTime ASC
     """)
-    suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession>
+    suspend fun getSessionsByBookAndDate(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String,
+        date: String
+    ): List<ReadRecordSession>
 
     /**
      * 获取某一天所有书籍的会话记录
@@ -149,24 +162,24 @@ interface ReadRecordDao {
     """)
     suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
 
-    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'")
+    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND (bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%')")
     suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
 
     // 清除会话记录
-    @Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
-    fun deleteSessionByName(bookName: String)
+    @Query("DELETE FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
+    fun deleteSessionByName(bookName: String, bookAuthor: String)
 
     @Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
     fun getAllDetails(): Flow<List<ReadRecordDetail>>
 
-    @Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
+    @Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
     fun searchDetails(query: String): Flow<List<ReadRecordDetail>>
 
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
     fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
 
-    @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
-    suspend fun getSessionsByBook(deviceId: String, bookName: String): List<ReadRecordSession>
+    @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
+    suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordSession>
 
     @Delete
     suspend fun deleteDetail(detail: ReadRecordDetail)
@@ -176,10 +189,16 @@ interface ReadRecordDao {
         DELETE FROM readRecordSession 
         WHERE deviceId = :deviceId 
         AND bookName = :bookName 
+        AND bookAuthor = :bookAuthor 
         AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
     """
     )
-    suspend fun deleteSessionsByBookAndDate(deviceId: String, bookName: String, date: String)
+    suspend fun deleteSessionsByBookAndDate(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String,
+        date: String
+    )
 
     @Delete
     suspend fun deleteSession(session: ReadRecordSession)
@@ -187,9 +206,12 @@ interface ReadRecordDao {
     @Delete
     suspend fun deleteReadRecord(record: ReadRecord)
 
-    @Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName")
-    suspend fun deleteDetailsByBook(deviceId: String, bookName: String)
+    @Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
+    suspend fun deleteDetailsByBook(deviceId: String, bookName: String, bookAuthor: String)
+
+    @Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
+    suspend fun deleteSessionsByBook(deviceId: String, bookName: String, bookAuthor: String)
 
-    @Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
-    suspend fun deleteSessionsByBook(deviceId: String, bookName: String)
-}
+    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
+    suspend fun getDetailsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordDetail>
+}

+ 2 - 1
app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt

@@ -2,6 +2,7 @@ package io.legado.app.data.entities
 
 data class ReadRecordShow(
     var bookName: String,
+    var bookAuthor: String,
     var readTime: Long,
     var lastRead: Long
-)
+)

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

@@ -3,10 +3,12 @@ package io.legado.app.data.entities.readRecord
 import androidx.room.ColumnInfo
 import androidx.room.Entity
 
-@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName"])
+@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName", "bookAuthor"])
 data class ReadRecord(
     var deviceId: String = "",
     var bookName: String = "",
+    @ColumnInfo(defaultValue = "")
+    var bookAuthor: String = "",
     @ColumnInfo(defaultValue = "0")
     var readTime: Long = 0L,
     @ColumnInfo(defaultValue = "0")

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

@@ -5,11 +5,13 @@ import androidx.room.Entity
 
 @Entity(
     tableName = "readRecordDetail",
-    primaryKeys = ["deviceId", "bookName", "date"]
+    primaryKeys = ["deviceId", "bookName", "bookAuthor", "date"]
 )
 data class ReadRecordDetail(
     val deviceId: String = "",
     val bookName: String = "",
+    @ColumnInfo(defaultValue = "")
+    val bookAuthor: String = "",
     val date: String = "",
 
     // 当天阅读总时长

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

@@ -10,6 +10,7 @@ data class ReadRecordSession(
 
     val deviceId: String = "",
     val bookName: String = "",
+    val bookAuthor: String = "",
 
     // 一次阅读的开始/结束
     val startTime: Long = 0,

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

@@ -11,15 +11,15 @@ class BookRepository(
     private val bookDao: BookDao,
     private val bookChapterDao: BookChapterDao
 ) {
-    suspend fun getBookCoverByName(bookName: String): String? {
+    suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? {
         return withContext(Dispatchers.IO) {
-            bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
+            bookDao.getBook(bookName, bookAuthor)?.getDisplayCover()
         }
     }
 
-    suspend fun getChapterTitle(bookName: String, chapterIndex: Int): String? {
+    suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndex: Int): String? {
         return withContext(Dispatchers.IO) {
-            val book = bookDao.findByName(bookName).firstOrNull()
+            val book = bookDao.getBook(bookName, bookAuthor)
             val bookUrl = book?.bookUrl
             if (bookUrl.isNullOrEmpty()) return@withContext null
 

+ 139 - 28
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -51,6 +51,14 @@ class ReadRecordRepository(
         return dao.getAllSessions(getCurrentDeviceId())
     }
 
+    suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
+        return dao.getReadRecordsByNameExcludingAuthor(
+            targetRecord.deviceId,
+            targetRecord.bookName,
+            targetRecord.bookAuthor
+        )
+    }
+
     /**
      * 保存一个完整的阅读会话.
      */
@@ -64,21 +72,22 @@ class ReadRecordRepository(
 
     private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
         if (durationDelta <= 0) return
-        val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
+        val existingRecord = dao.getReadRecord(session.deviceId, session.bookName, session.bookAuthor)
         if (existingRecord != null) {
             dao.update(
                 existingRecord.copy(
-                readTime = existingRecord.readTime + durationDelta,
-                lastRead = session.endTime
+                    readTime = existingRecord.readTime + durationDelta,
+                    lastRead = session.endTime
                 )
             )
         } else {
             dao.insert(
                 ReadRecord(
-                deviceId = session.deviceId,
-                bookName = session.bookName,
-                readTime = durationDelta,
-                lastRead = session.endTime
+                    deviceId = session.deviceId,
+                    bookName = session.bookName,
+                    bookAuthor = session.bookAuthor,
+                    readTime = durationDelta,
+                    lastRead = session.endTime
                 )
             )
         }
@@ -91,7 +100,12 @@ class ReadRecordRepository(
         dateString: String
     ) {
         if (durationDelta <= 0 && wordsDelta <= 0) return
-        val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
+        val existingDetail = dao.getDetail(
+            session.deviceId,
+            session.bookName,
+            session.bookAuthor,
+            dateString
+        )
         if (existingDetail != null) {
             existingDetail.readTime += durationDelta
             existingDetail.readWords += wordsDelta
@@ -101,13 +115,14 @@ class ReadRecordRepository(
         } else {
             dao.insertDetail(
                 ReadRecordDetail(
-                deviceId = session.deviceId,
-                bookName = session.bookName,
-                date = dateString,
-                readTime = durationDelta,
-                readWords = wordsDelta,
-                firstReadTime = session.startTime,
-                lastReadTime = session.endTime
+                    deviceId = session.deviceId,
+                    bookName = session.bookName,
+                    bookAuthor = session.bookAuthor,
+                    date = dateString,
+                    readTime = durationDelta,
+                    readWords = wordsDelta,
+                    firstReadTime = session.startTime,
+                    lastReadTime = session.endTime
                 )
             )
         }
@@ -115,8 +130,13 @@ class ReadRecordRepository(
 
     suspend fun deleteDetail(detail: ReadRecordDetail) {
         dao.deleteDetail(detail)
-        dao.deleteSessionsByBookAndDate(detail.deviceId, detail.bookName, detail.date)
-        updateReadRecordTotal(detail.deviceId, detail.bookName)
+        dao.deleteSessionsByBookAndDate(
+            detail.deviceId,
+            detail.bookName,
+            detail.bookAuthor,
+            detail.date
+        )
+        updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
     }
 
     @Transaction
@@ -125,10 +145,20 @@ class ReadRecordRepository(
 
         val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
         val remainingSessions =
-            dao.getSessionsByBookAndDate(session.deviceId, session.bookName, dateString)
+            dao.getSessionsByBookAndDate(
+                session.deviceId,
+                session.bookName,
+                session.bookAuthor,
+                dateString
+            )
 
         if (remainingSessions.isEmpty()) {
-            val detail = dao.getDetail(session.deviceId, session.bookName, dateString)
+            val detail = dao.getDetail(
+                session.deviceId,
+                session.bookName,
+                session.bookAuthor,
+                dateString
+            )
             detail?.let { dao.deleteDetail(it) }
         } else {
             val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
@@ -136,7 +166,12 @@ class ReadRecordRepository(
             val firstRead = remainingSessions.minOf { it.startTime }
             val lastRead = remainingSessions.maxOf { it.endTime }
 
-            val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
+            val existingDetail = dao.getDetail(
+                session.deviceId,
+                session.bookName,
+                session.bookAuthor,
+                dateString
+            )
             existingDetail?.copy(
                 readTime = totalTime,
                 readWords = totalWords,
@@ -145,19 +180,19 @@ class ReadRecordRepository(
             )?.let { dao.insertDetail(it) }
         }
 
-        updateReadRecordTotal(session.deviceId, session.bookName)
+        updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
     }
 
-    private suspend fun updateReadRecordTotal(deviceId: String, bookName: String) {
-        val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName)
+    private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
+        val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
 
         if (allRemainingSessions.isEmpty()) {
-            dao.getReadRecord(deviceId, bookName)?.let { dao.deleteReadRecord(it) }
+            dao.getReadRecord(deviceId, bookName, bookAuthor)?.let { dao.deleteReadRecord(it) }
         } else {
             val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
             val lastRead = allRemainingSessions.maxOf { it.endTime }
 
-            dao.getReadRecord(deviceId, bookName)?.copy(
+            dao.getReadRecord(deviceId, bookName, bookAuthor)?.copy(
                 readTime = totalTime,
                 lastRead = lastRead
             )?.let { dao.update(it) }
@@ -166,8 +201,84 @@ class ReadRecordRepository(
 
     suspend fun deleteReadRecord(record: ReadRecord) {
         dao.deleteReadRecord(record)
-        dao.deleteDetailsByBook(record.deviceId, record.bookName)
-        dao.deleteSessionsByBook(record.deviceId, record.bookName)
+        dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
+        dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
+    }
+
+    @Transaction
+    suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
+        sourceRecords.forEach { sourceRecord ->
+            mergeSingleReadRecordInto(targetRecord, sourceRecord)
+        }
+    }
+
+    @Transaction
+    private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
+        if (targetRecord == sourceRecord) return
+        if (targetRecord.deviceId != sourceRecord.deviceId) return
+        if (targetRecord.bookName != sourceRecord.bookName) return
+
+        val source = dao.getReadRecord(
+            sourceRecord.deviceId,
+            sourceRecord.bookName,
+            sourceRecord.bookAuthor
+        ) ?: return
+
+        val target = dao.getReadRecord(
+            targetRecord.deviceId,
+            targetRecord.bookName,
+            targetRecord.bookAuthor
+        ) ?: targetRecord
+
+        dao.insert(
+            target.copy(
+                readTime = target.readTime + source.readTime,
+                lastRead = max(target.lastRead, source.lastRead)
+            )
+        )
+
+        val sourceDetails = dao.getDetailsByBook(
+            sourceRecord.deviceId,
+            sourceRecord.bookName,
+            sourceRecord.bookAuthor
+        )
+        sourceDetails.forEach { detail ->
+            val existingTargetDetail = dao.getDetail(
+                targetRecord.deviceId,
+                targetRecord.bookName,
+                targetRecord.bookAuthor,
+                detail.date
+            )
+            if (existingTargetDetail == null) {
+                dao.insertDetail(
+                    detail.copy(
+                        bookAuthor = targetRecord.bookAuthor
+                    )
+                )
+            } else {
+                dao.insertDetail(
+                    existingTargetDetail.copy(
+                        readTime = existingTargetDetail.readTime + detail.readTime,
+                        readWords = existingTargetDetail.readWords + detail.readWords,
+                        firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime),
+                        lastReadTime = max(existingTargetDetail.lastReadTime, detail.lastReadTime)
+                    )
+                )
+            }
+        }
+        dao.deleteDetailsByBook(sourceRecord.deviceId, sourceRecord.bookName, sourceRecord.bookAuthor)
+
+        val sourceSessions = dao.getSessionsByBook(
+            sourceRecord.deviceId,
+            sourceRecord.bookName,
+            sourceRecord.bookAuthor
+        )
+        sourceSessions.forEach { session ->
+            dao.updateSession(session.copy(bookAuthor = targetRecord.bookAuthor))
+        }
+
+        dao.deleteReadRecord(source)
+        updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor)
     }
 
-}
+}

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

@@ -177,7 +177,7 @@ object Restore {
                     appDb.readRecordDao.insert(readRecord)
                 } else {
                     val time = appDb.readRecordDao
-                        .getReadTime(readRecord.deviceId, readRecord.bookName)
+                        .getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor)
                     if (time == null || time < readRecord.readTime) {
                         appDb.readRecordDao.insert(readRecord)
                     }
@@ -325,4 +325,4 @@ object Restore {
         return null
     }
 
-}
+}

+ 13 - 4
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -117,7 +117,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     fun resetData(book: Book) {
         ReadBook.book = book
         readRecord.bookName = book.name
-        readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
+        readRecord.bookAuthor = book.author
+        readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
         chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
         simulatedChapterSize = if (book.readSimulating()) {
             book.simulatedTotalChapterNum()
@@ -307,7 +308,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun initReadTime() {
         val currentBookName = book?.name ?: return
-        if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
+        val currentBookAuthor = book?.author ?: ""
+        if (currentActiveSession != null &&
+            (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
+        ) {
             commitReadSession()
         }
 
@@ -316,6 +320,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             currentActiveSession = ReadRecordSession(
                 deviceId = "",
                 bookName = currentBookName,
+                bookAuthor = currentBookAuthor,
                 startTime = readStartTime,
                 endTime = readStartTime,
                 words = durChapterIndex.toLong()
@@ -326,9 +331,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     fun upReadTime() {
         val currentLength = currentReadLength
         val currentBookName = book?.name ?: return
+        val currentBookAuthor = book?.author ?: ""
         val endTime = System.currentTimeMillis()
 
-        if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
+        if (currentActiveSession == null ||
+            currentActiveSession!!.bookName != currentBookName ||
+            currentActiveSession!!.bookAuthor != currentBookAuthor
+        ) {
             initReadTime()
             return
         }
@@ -1131,4 +1140,4 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         fun cancelSelect()
     }
 
-}
+}

+ 12 - 3
app/src/main/java/io/legado/app/model/ReadManga.kt

@@ -90,7 +90,8 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
     fun resetData(book: Book) {
         ReadManga.book = book
         readRecord.bookName = book.name
-        readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
+        readRecord.bookAuthor = book.author
+        readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
         chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
         simulatedChapterSize = if (book.readSimulating()) {
             book.simulatedTotalChapterNum()
@@ -147,7 +148,10 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
 
     fun initReadTime() {
         val currentBookName = book?.name ?: return
-        if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
+        val currentBookAuthor = book?.author ?: ""
+        if (currentActiveSession != null &&
+            (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
+        ) {
             commitReadSession()
         }
 
@@ -156,6 +160,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
             currentActiveSession = ReadRecordSession(
                 deviceId = "",
                 bookName = currentBookName,
+                bookAuthor = currentBookAuthor,
                 startTime = readStartTime,
                 endTime = readStartTime,
                 words = durChapterIndex.toLong()
@@ -166,9 +171,13 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
     fun upReadTime() {
         val currentLength = currentReadLength
         val currentBookName = book?.name ?: return
+        val currentBookAuthor = book?.author ?: ""
         val endTime = System.currentTimeMillis()
 
-        if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
+        if (currentActiveSession == null ||
+            currentActiveSession!!.bookName != currentBookName ||
+            currentActiveSession!!.bookAuthor != currentBookAuthor
+        ) {
             initReadTime()
             return
         }

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt

@@ -25,10 +25,10 @@ class ReadRecordActivity : BaseComposeActivity() {
         AppTheme {
             ReadRecordScreen(
                 onBackClick = { finish() },
-                onBookClick = { bookName ->
+                onBookClick = { bookName, bookAuthor ->
                     lifecycleScope.launch {
                         val book = withContext(Dispatchers.IO) {
-                            appDb.bookDao.findByName(bookName).firstOrNull()
+                            appDb.bookDao.getBook(bookName, bookAuthor)
                         }
                         if (book != null) startActivityForBook(book)
                     }

+ 165 - 43
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.readRecord
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.background
+import androidx.compose.foundation.basicMarquee
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.List
 import androidx.compose.material.icons.filled.CalendarMonth
 import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Merge
 import androidx.compose.material.icons.filled.Schedule
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Timeline
@@ -36,6 +38,8 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
@@ -47,6 +51,7 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.produceState
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -56,8 +61,11 @@ import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.zIndex
 import cn.hutool.core.date.DateUtil
@@ -75,6 +83,7 @@ import io.legado.app.ui.widget.components.button.SmallTopBarButton
 import io.legado.app.ui.widget.components.swipe.SwipeAction
 import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
 import io.legado.app.utils.StringUtils.formatFriendlyDate
+import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import java.time.LocalDate
 import java.time.format.DateTimeFormatter
@@ -85,8 +94,11 @@ import java.util.Date
 fun ReadRecordScreen(
     viewModel: ReadRecordViewModel = koinViewModel(),
     onBackClick: () -> Unit,
-    onBookClick: (String) -> Unit
+    onBookClick: (String, String) -> Unit
 ) {
+    val snackbarHostState = remember { SnackbarHostState() }
+    val scope = rememberCoroutineScope()
+
     val state by viewModel.uiState.collectAsState()
     val displayMode by viewModel.displayMode.collectAsState()
     var showSearch by remember { mutableStateOf(false) }
@@ -96,6 +108,7 @@ fun ReadRecordScreen(
 
     var skipDeleteConfirm by remember { mutableStateOf(false) }
     var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+    var mergeDialogData by remember { mutableStateOf<Pair<ReadRecord, List<ReadRecord>>?>(null) }
     val onConfirmDelete: (() -> Unit) -> Unit = { action ->
         if (skipDeleteConfirm) {
             action()
@@ -112,6 +125,7 @@ fun ReadRecordScreen(
 
     Scaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        snackbarHost = { SnackbarHost(snackbarHostState) },
         topBar = {
             Column {
                 GlassMediumFlexibleTopAppBar(
@@ -226,7 +240,17 @@ fun ReadRecordScreen(
                                 state = state,
                                 viewModel = viewModel,
                                 onBookClick = onBookClick,
-                                onConfirmDelete = onConfirmDelete
+                                onConfirmDelete = onConfirmDelete,
+                                onMergeClick = { record ->
+                                    scope.launch {
+                                        val candidates = viewModel.getMergeCandidates(record)
+                                        if (candidates.isEmpty()) {
+                                            snackbarHostState.showSnackbar("没有可合并的同名记录")
+                                        } else {
+                                            mergeDialogData = record to candidates
+                                        }
+                                    }
+                                }
                             )
                         }
                     }
@@ -286,6 +310,65 @@ fun ReadRecordScreen(
             }
         )
     }
+
+    mergeDialogData?.let { (targetRecord, candidates) ->
+
+        var selectedAuthors by remember(targetRecord, candidates) {
+            mutableStateOf(candidates.map { it.bookAuthor }.toSet())
+        }
+
+        AlertDialog(
+            onDismissRequest = { mergeDialogData = null },
+            title = { Text("合并阅读记录") },
+            text = {
+                Column {
+                    Text("将以下作者的“${targetRecord.bookName}”合并到 ${targetRecord.bookAuthor.ifBlank { "未知作者" }}")
+                    Spacer(modifier = Modifier.height(8.dp))
+
+                    candidates.forEach { candidate ->
+                        val author = candidate.bookAuthor.ifBlank { "未知作者" }
+                        val isChecked = selectedAuthors.contains(candidate.bookAuthor)
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .clickable {
+                                    selectedAuthors =
+                                        if (isChecked) selectedAuthors - candidate.bookAuthor
+                                        else selectedAuthors + candidate.bookAuthor
+                                }
+                                .padding(vertical = 6.dp),
+                            verticalAlignment = Alignment.CenterVertically
+                        ) {
+                            Checkbox(checked = isChecked, onCheckedChange = null)
+                            Text(
+                                text = "$author(${formatDuring(candidate.readTime)})",
+                                modifier = Modifier.padding(start = 8.dp),
+                                style = MaterialTheme.typography.bodyMedium
+                            )
+                        }
+                    }
+                }
+            },
+            confirmButton = {
+                TextButton(
+                    onClick = {
+                        viewModel.mergeReadRecords(
+                            targetRecord,
+                            candidates.filter { selectedAuthors.contains(it.bookAuthor) }
+                        )
+                        mergeDialogData = null
+                    }
+                ) {
+                    Text("合并")
+                }
+            },
+            dismissButton = {
+                TextButton(onClick = { mergeDialogData = null }) {
+                    Text("取消")
+                }
+            }
+        )
+    }
 }
 
 @Composable
@@ -300,7 +383,7 @@ fun SummarySection(
         val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
 
         if (dailyDetails.isNotEmpty()) {
-            val distinctBooks = dailyDetails.map { it.bookName }.distinct()
+            val distinctBooks = dailyDetails.map { it.bookName to it.bookAuthor }.distinct()
             val dailyTime = dailyDetails.sumOf { it.readTime }
 
             ReadingSummaryCard(
@@ -321,7 +404,7 @@ fun SummarySection(
                 title = "累计阅读成就",
                 bookCount = allBooksCount,
                 totalTimeMillis = totalTime,
-                bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
+                bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor },
                 viewModel = viewModel,
                 onClick = {  }
             )
@@ -333,16 +416,18 @@ fun LazyListScope.renderListByMode(
     displayMode: DisplayMode,
     state: ReadRecordUiState,
     viewModel: ReadRecordViewModel,
-    onBookClick: (String) -> Unit,
-    onConfirmDelete: (() -> Unit) -> Unit
+    onBookClick: (String, String) -> Unit,
+    onConfirmDelete: (() -> Unit) -> Unit,
+    onMergeClick: (ReadRecord) -> Unit
 ) {
+
     when (displayMode) {
         DisplayMode.AGGREGATE -> {
             state.groupedRecords.forEach { (date, details) ->
                 stickyHeader(key = "header_$date") {
                     DateHeader(date, details.sumOf { it.readTime })
                 }
-                items(items = details, key = { "${it.bookName}_${it.date}" }) { detail ->
+                items(items = details, key = { "${it.bookName}_${it.bookAuthor}_${it.date}" }) { detail ->
                     SwipeActionContainer(
                         modifier = Modifier.animateItem(),
                         startAction = SwipeAction(
@@ -356,7 +441,7 @@ fun LazyListScope.renderListByMode(
                         ReadRecordItem(
                             detail,
                             viewModel,
-                            onClick = { onBookClick(detail.bookName) })
+                            onClick = { onBookClick(detail.bookName, detail.bookAuthor) })
                     }
                 }
             }
@@ -387,7 +472,7 @@ fun LazyListScope.renderListByMode(
         }
 
         DisplayMode.LATEST -> {
-            items(items = state.latestRecords, key = { it.bookName }) { record ->
+            items(items = state.latestRecords, key = { "${it.bookName}_${it.bookAuthor}" }) { record ->
                 SwipeActionContainer(
                     modifier = Modifier.animateItem(),
                     startAction = SwipeAction(
@@ -396,16 +481,23 @@ fun LazyListScope.renderListByMode(
                         onSwipe = {
                             onConfirmDelete { viewModel.deleteReadRecord(record) }
                         }
+                    ),
+                    endAction = SwipeAction(
+                        icon = Icons.Default.Merge,
+                        background = MaterialTheme.colorScheme.primary,
+                        onSwipe = {
+                            onMergeClick(record)
+                        }
                     )
                 ) {
-                    LatestReadItem(
-                        record = record,
-                        viewModel = viewModel,
-                        onClick = { onBookClick(record.bookName) }
-                    )
+                        LatestReadItem(
+                            record = record,
+                            viewModel = viewModel,
+                            onClick = { onBookClick(record.bookName, record.bookAuthor) }
+                        )
+                    }
                 }
             }
-        }
     }
 }
 
@@ -418,8 +510,8 @@ fun LatestReadItem(
 ) {
     var coverPath by remember { mutableStateOf<String?>(null) }
 
-    LaunchedEffect(record.bookName) {
-        coverPath = viewModel.getBookCover(record.bookName)
+    LaunchedEffect(record.bookName, record.bookAuthor) {
+        coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor)
     }
 
     Row(
@@ -437,18 +529,36 @@ fun LatestReadItem(
             Text(
                 text = record.bookName,
                 style = MaterialTheme.typography.titleMedium,
-                maxLines = 2
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis
             )
-            Spacer(modifier = Modifier.height(4.dp))
             Text(
-                text = "总时长: ${formatDuring(record.readTime)}",
+                text = record.bookAuthor.ifBlank { "未知作者" },
                 style = MaterialTheme.typography.bodySmall,
-                color = Color.Gray
+                color = MaterialTheme.colorScheme.outline,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis
             )
+            Spacer(modifier = Modifier.height(8.dp))
             Text(
-                text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
-                style = MaterialTheme.typography.bodySmall,
-                color = MaterialTheme.colorScheme.primary
+                modifier = Modifier
+                    .basicMarquee(
+                        iterations = Int.MAX_VALUE,
+                        repeatDelayMillis = 2000,
+                        initialDelayMillis = 1000
+                    ),
+                text = buildAnnotatedString {
+                    withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.outline)) {
+                        append(formatDuring(record.readTime))
+                        append(" • ")
+                    }
+                    withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
+                        append("${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}")
+                    }
+                },
+                style = MaterialTheme.typography.labelSmall,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis
             )
         }
     }
@@ -458,15 +568,15 @@ fun LatestReadItem(
 fun TimelineSessionItem(
     item: TimelineItem,
     viewModel: ReadRecordViewModel,
-    onBookClick: (String) -> Unit
+    onBookClick: (String, String) -> Unit
 ) {
     val session = item.session
     var coverPath by remember { mutableStateOf<String?>(null) }
     var chapterTitle by remember { mutableStateOf<String?>("加载中...") }
 
-    LaunchedEffect(session.bookName) {
-        coverPath = viewModel.getBookCover(session.bookName)
-        val title = viewModel.getChapterTitle(session.bookName, session.words)
+    LaunchedEffect(session.bookName, session.bookAuthor) {
+        coverPath = viewModel.getBookCover(session.bookName, session.bookAuthor)
+        val title = viewModel.getChapterTitle(session.bookName, session.bookAuthor, session.words)
         chapterTitle = title ?: "第 ${session.words} 章"
     }
 
@@ -483,7 +593,7 @@ fun TimelineSessionItem(
     Box(
         modifier = Modifier
             .fillMaxWidth()
-            .clickable { onBookClick(session.bookName) }
+            .clickable { onBookClick(session.bookName, session.bookAuthor) }
             .drawBehind {
                 val x = timelineX.toPx()
                 val h = size.height
@@ -528,14 +638,21 @@ fun TimelineSessionItem(
                     Text(
                         text = session.bookName,
                         style = MaterialTheme.typography.titleMedium,
-                        maxLines = 2,
-                        minLines = 2,
+                        maxLines = 1,
                         overflow = TextOverflow.Ellipsis
                     )
                     Text(
-                        text = chapterTitle.orEmpty(),
+                        text = session.bookAuthor.ifBlank { "未知作者" },
                         style = MaterialTheme.typography.bodySmall,
-                        color = Color.Gray,
+                        color = MaterialTheme.colorScheme.outline,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                    Spacer(modifier = Modifier.height(8.dp))
+                    Text(
+                        text = chapterTitle.orEmpty(),
+                        style = MaterialTheme.typography.labelSmall,
+                        color = MaterialTheme.colorScheme.outline,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis
                     )
@@ -554,8 +671,8 @@ fun ReadRecordItem(
 ) {
     var coverPath by remember { mutableStateOf<String?>(null) }
 
-    LaunchedEffect(detail.bookName) {
-        coverPath = viewModel.getBookCover(detail.bookName)
+    LaunchedEffect(detail.bookName, detail.bookAuthor) {
+        coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor)
     }
 
     Row(
@@ -573,13 +690,18 @@ fun ReadRecordItem(
             Text(
                 text = detail.bookName,
                 style = MaterialTheme.typography.titleMedium,
-                maxLines = 2
+                maxLines = 1
             )
-            Spacer(modifier = Modifier.height(4.dp))
             Text(
-                text = "阅读时长: ${formatDuring(detail.readTime)}",
+                text = detail.bookAuthor.ifBlank { "未知作者" },
                 style = MaterialTheme.typography.bodySmall,
-                color = Color.Gray
+                color = MaterialTheme.colorScheme.outline,
+            )
+            Spacer(modifier = Modifier.height(8.dp))
+            Text(
+                text = "阅读时长: ${formatDuring(detail.readTime)}",
+                color = MaterialTheme.colorScheme.outline,
+                style = MaterialTheme.typography.labelSmall
             )
         }
     }
@@ -636,14 +758,14 @@ fun ReadingSummaryCard(
     title: String,
     bookCount: Int,
     totalTimeMillis: Long,
-    bookNamesForCover: List<String>,
+    bookNamesForCover: List<Pair<String, String>>,
     viewModel: ReadRecordViewModel,
     onClick: () -> Unit
 ) {
 
     val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
-        value = bookNamesForCover.map { name ->
-            viewModel.getBookCover(name)
+        value = bookNamesForCover.map { (name, author) ->
+            viewModel.getBookCover(name, author)
         }
     }
 
@@ -753,4 +875,4 @@ fun formatDuring(mss: Long): String {
     val m = if (minutes > 0) "${minutes}分钟" else ""
     val s = if (seconds > 0) "${seconds}秒" else ""
     return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
-}
+}

+ 23 - 10
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -80,10 +80,9 @@ class ReadRecordViewModel(
             .filter { session ->
                 val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
                 (dateStr == null || sDate == dateStr) &&
-                        (searchKey.isEmpty() || session.bookName.contains(
-                            searchKey,
-                            ignoreCase = true
-                        ))
+                        (searchKey.isEmpty() ||
+                                session.bookName.contains(searchKey, ignoreCase = true) ||
+                                session.bookAuthor.contains(searchKey, ignoreCase = true))
             }
             .groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
             .mapValues { (_, sessions) ->
@@ -139,7 +138,10 @@ class ReadRecordViewModel(
         for (i in 1 until sessions.size) {
             val current = sessions[i]
             val last = mergedList.last()
-            if (current.bookName == last.bookName && (current.startTime - last.endTime) <= gapLimit) {
+            if (current.bookName == last.bookName &&
+                current.bookAuthor == last.bookAuthor &&
+                (current.startTime - last.endTime) <= gapLimit
+            ) {
                 mergedList[mergedList.lastIndex] = last.copy(endTime = current.endTime)
             } else {
                 mergedList.add(current.copy())
@@ -148,12 +150,23 @@ class ReadRecordViewModel(
         return mergedList
     }
 
-    suspend fun getChapterTitle(bookName: String, chapterIndexLong: Long): String? {
-        return bookRepository.getChapterTitle(bookName, chapterIndexLong.toInt())
+    suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndexLong: Long): String? {
+        return bookRepository.getChapterTitle(bookName, bookAuthor, chapterIndexLong.toInt())
     }
 
-    suspend fun getBookCover(bookName: String): String? {
-        return bookRepository.getBookCoverByName(bookName)
+    suspend fun getBookCover(bookName: String, bookAuthor: String): String? {
+        return bookRepository.getBookCoverByNameAndAuthor(bookName, bookAuthor)
+    }
+
+    suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
+        return repository.getMergeCandidates(targetRecord)
+    }
+
+    fun mergeReadRecords(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
+        if (sourceRecords.isEmpty()) return
+        viewModelScope.launch {
+            repository.mergeReadRecordInto(targetRecord, sourceRecords)
+        }
     }
 
     private data class LoadedData(
@@ -162,4 +175,4 @@ class ReadRecordViewModel(
         val latestRecords: List<ReadRecord>,
         val sessions: List<ReadRecordSession>
     )
-}
+}

+ 83 - 36
app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt

@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
 fun SwipeActionContainer(
     modifier: Modifier = Modifier,
     startAction: SwipeAction? = null,
+    endAction: SwipeAction? = null,
     content: @Composable () -> Unit
 ) {
     val haptic = LocalHapticFeedback.current
@@ -39,8 +40,6 @@ fun SwipeActionContainer(
             totalDistance * 0.6f
         }
     )
-    val isThresholdReached =
-        dismissState.progress > 0.5f && dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd
 
     LaunchedEffect(dismissState.targetValue) {
         if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
@@ -51,9 +50,18 @@ fun SwipeActionContainer(
     }
 
     LaunchedEffect(dismissState.settledValue) {
-        if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) {
-            startAction?.onSwipe?.invoke()
-            dismissState.reset()
+        when (dismissState.settledValue) {
+            SwipeToDismissBoxValue.StartToEnd -> {
+                startAction?.onSwipe?.invoke()
+                dismissState.reset()
+            }
+
+            SwipeToDismissBoxValue.EndToStart -> {
+                endAction?.onSwipe?.invoke()
+                dismissState.reset()
+            }
+
+            else -> {}
         }
     }
 
@@ -61,44 +69,34 @@ fun SwipeActionContainer(
         state = dismissState,
         modifier = modifier,
         enableDismissFromStartToEnd = startAction != null,
-        enableDismissFromEndToStart = false,
+        enableDismissFromEndToStart = endAction != null,
         backgroundContent = {
             val direction = dismissState.dismissDirection
             val progress = dismissState.progress
 
-            if (direction == SwipeToDismissBoxValue.StartToEnd) {
-                val backgroundColor by animateColorAsState(
-                    targetValue = if (isThresholdReached) startAction!!.background
-                    else MaterialTheme.colorScheme.surfaceVariant,
-                    label = "bgColor"
-                )
+            when (direction) {
 
-                Box(
-                    modifier = Modifier
-                        .fillMaxSize()
-                        .background(backgroundColor)
-                        .padding(horizontal = 24.dp),
-                    contentAlignment = Alignment.CenterStart
-                ) {
-                    val iconScale by animateFloatAsState(
-                        targetValue = if (isThresholdReached) 1.3f else progress.coerceIn(0.5f, 1f),
-                        label = "iconScale"
-                    )
+                SwipeToDismissBoxValue.StartToEnd -> {
+                    startAction?.let { action ->
+                        SwipeBackground(
+                            action = action,
+                            progress = progress,
+                            alignStart = true
+                        )
+                    }
+                }
 
-                    Icon(
-                        imageVector = startAction!!.icon,
-                        contentDescription = null,
-                        modifier = Modifier
-                            .size(24.dp)
-                            .graphicsLayer {
-                                scaleX = iconScale
-                                scaleY = iconScale
-                            },
-                        tint = if (isThresholdReached)
-                            contentColorFor(startAction.background)
-                        else MaterialTheme.colorScheme.onSurfaceVariant
-                    )
+                SwipeToDismissBoxValue.EndToStart -> {
+                    endAction?.let { action ->
+                        SwipeBackground(
+                            action = action,
+                            progress = progress,
+                            alignStart = false
+                        )
+                    }
                 }
+
+                else -> {}
             }
         },
         content = {
@@ -111,4 +109,53 @@ fun SwipeActionContainer(
             }
         }
     )
+}
+
+@Composable
+private fun SwipeBackground(
+    action: SwipeAction,
+    progress: Float,
+    alignStart: Boolean
+) {
+    val isThresholdReached = progress > 0.6f
+
+    val backgroundColor by animateColorAsState(
+        targetValue = if (isThresholdReached)
+            action.background
+        else
+            MaterialTheme.colorScheme.surfaceVariant,
+        label = "bgColor"
+    )
+
+    val iconScale by animateFloatAsState(
+        targetValue = if (isThresholdReached) 1.3f
+        else progress.coerceIn(0.5f, 1f),
+        label = "iconScale"
+    )
+
+    Box(
+        modifier = Modifier
+            .fillMaxSize()
+            .background(backgroundColor)
+            .padding(horizontal = 24.dp),
+        contentAlignment = if (alignStart)
+            Alignment.CenterStart
+        else
+            Alignment.CenterEnd
+    ) {
+        Icon(
+            imageVector = action.icon,
+            contentDescription = null,
+            modifier = Modifier
+                .size(24.dp)
+                .graphicsLayer {
+                    scaleX = iconScale
+                    scaleY = iconScale
+                },
+            tint = if (isThresholdReached)
+                contentColorFor(action.background)
+            else
+                MaterialTheme.colorScheme.onSurfaceVariant
+        )
+    }
 }