diff --git a/app/schemas/io.legado.app.data.AppDatabase/83.json b/app/schemas/io.legado.app.data.AppDatabase/83.json new file mode 100644 index 000000000..dfe518f50 --- /dev/null +++ b/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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt index c46c8067f..7a2b25c0a 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt index 3dc41d31e..f0745618e 100644 --- a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt +++ b/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 -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index edbf02d20..7ef0e7f9f 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/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 @@ -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 @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 // 清除每天的统计记录 - @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> /** 搜索 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> + @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 + /** * 获取某一天某一本书的所有会话记录 */ @@ -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 + suspend fun getSessionsByBookAndDate( + deviceId: String, + bookName: String, + bookAuthor: String, + date: String + ): List /** * 获取某一天所有书籍的会话记录 @@ -149,24 +162,24 @@ interface ReadRecordDao { """) suspend fun getSessionsByDate(deviceId: String, date: String): List - @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 // 清除会话记录 - @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> - @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> @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC") fun getAllSessions(deviceId: String): Flow> - @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName") - suspend fun getSessionsByBook(deviceId: String, bookName: String): List + @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor") + suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List @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") - suspend fun deleteSessionsByBook(deviceId: String, bookName: String) -} \ No newline at end of file + @Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor") + suspend fun deleteSessionsByBook(deviceId: String, bookName: String, bookAuthor: String) + + @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor") + suspend fun getDetailsByBook(deviceId: String, bookName: String, bookAuthor: String): List +} diff --git a/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt b/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt index 293270276..7db51d188 100644 --- a/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt +++ b/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 -) \ No newline at end of file +) diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt index 7e9d3cf0e..fa9a57cd7 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt +++ b/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") diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt index 11e673271..ca929e2a2 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt +++ b/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 = "", // 当天阅读总时长 diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt index 9c1389ac8..1e4bde3af 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt index e0f3d5b35..ec400b120 100644 --- a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index 144c8ed5c..7fb86c548 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/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 { + 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) } -} \ No newline at end of file + @Transaction + suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List) { + 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) + } + +} diff --git a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt b/app/src/main/java/io/legado/app/help/book/BookExtensions.kt index 4523d1340..53c57add3 100644 --- a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt +++ b/app/src/main/java/io/legado/app/help/book/BookExtensions.kt @@ -121,6 +121,29 @@ fun Book.contains(word: String?): Boolean { || intro?.contains(word) == true } +/** + * 仅在目标bookUrl未被其他书占用,或判定为同一本书时,允许迁移主键。 + */ +fun Book.canSafelyRebindTo(newBookUrl: String): Boolean { + if (newBookUrl == bookUrl) return true + val targetBook = appDb.bookDao.getBook(newBookUrl) ?: return true + + val sameOriginName = originName.isNotBlank() && originName == targetBook.originName + val sameNameAuthor = name.isNotBlank() && author.isNotBlank() + && name == targetBook.name && author == targetBook.author + val sameOrigin = origin.isNotBlank() && origin == targetBook.origin + val canMerge = sameOriginName && (sameNameAuthor || sameOrigin) + if (!canMerge) { + AppLog.put( + "书籍重定位冲突,已跳过迁移\n" + + "old=$bookUrl\nnew=$newBookUrl\n" + + "oldName=$name oldAuthor=$author oldOriginName=$originName\n" + + "targetName=${targetBook.name} targetAuthor=${targetBook.author} targetOriginName=${targetBook.originName}" + ) + } + return canMerge +} + private val localUriCache by lazy { ConcurrentHashMap() } @@ -150,17 +173,31 @@ fun Book.getLocalUri(): Uri { // 查找书籍保存目录 if (!defaultBookDir.isNullOrBlank()) { - val treeUri = Uri.parse(defaultBookDir) + val treeUri = defaultBookDir.toUri() val treeFileDoc = FileDoc.fromUri(treeUri, true) + if (!treeFileDoc.exists()) { appCtx.toastOnUi("书籍保存目录失效,请重新设置!") } else { val fileDoc = treeFileDoc.find(originName, 5, 100) if (fileDoc != null) { - localUriCache[bookUrl] = fileDoc.uri - //更新bookUrl 重启不用再找一遍 - bookUrl = fileDoc.toString() - save() + val newBookUrl = fileDoc.toString() + val oldBook = copy() + if (!oldBook.canSafelyRebindTo(newBookUrl)) { + return fileDoc.uri + } + appDb.runInTransaction { + + if (oldBook.bookUrl == newBookUrl) { + save() + } else { + val newBook = oldBook.copy(bookUrl = newBookUrl) + appDb.bookDao.replace(oldBook, newBook) + BookHelp.updateCacheFolder(oldBook, newBook) + this.bookUrl = newBookUrl + } + } + localUriCache[newBookUrl] = fileDoc.uri return fileDoc.uri } } @@ -176,9 +213,23 @@ fun Book.getLocalUri(): Uri { val treeFileDoc = FileDoc.fromUri(treeUri, true) val fileDoc = treeFileDoc.find(originName, 5, 100) if (fileDoc != null) { - localUriCache[bookUrl] = fileDoc.uri - bookUrl = fileDoc.toString() - save() + val newBookUrl = fileDoc.toString() + val oldBook = copy() + if (!oldBook.canSafelyRebindTo(newBookUrl)) { + return fileDoc.uri + } + + appDb.runInTransaction { + if (oldBook.bookUrl == newBookUrl) { + save() + } else { + val newBook = oldBook.copy(bookUrl = newBookUrl) + appDb.bookDao.replace(oldBook, newBook) + BookHelp.updateCacheFolder(oldBook, newBook) + this.bookUrl = newBookUrl + } + } + localUriCache[newBookUrl] = fileDoc.uri return fileDoc.uri } } @@ -438,4 +489,4 @@ fun tryParesExportFileName(jsStr: String): Boolean { RhinoScriptEngine.eval(jsStr, bindings) true }.getOrDefault(false) -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/help/storage/Restore.kt b/app/src/main/java/io/legado/app/help/storage/Restore.kt index 8a454ad9a..3cc79fe4c 100644 --- a/app/src/main/java/io/legado/app/help/storage/Restore.kt +++ b/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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/ReadBook.kt b/app/src/main/java/io/legado/app/model/ReadBook.kt index 98806123f..3893bb4f2 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/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() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/ReadManga.kt b/app/src/main/java/io/legado/app/model/ReadManga.kt index a4c29c940..ed7260b64 100644 --- a/app/src/main/java/io/legado/app/model/ReadManga.kt +++ b/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 } diff --git a/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt b/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt index e95ef0de4..12149714a 100644 --- a/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt +++ b/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt @@ -23,6 +23,8 @@ import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.addType import io.legado.app.help.book.archiveName +import io.legado.app.help.book.cacheLocalUri +import io.legado.app.help.book.canSafelyRebindTo import io.legado.app.help.book.getArchiveUri import io.legado.app.help.book.getLocalUri import io.legado.app.help.book.getRemoteUrl @@ -37,6 +39,7 @@ import io.legado.app.help.config.AppConfig import io.legado.app.lib.webdav.WebDav import io.legado.app.lib.webdav.WebDavException import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.model.analyzeRule.CustomUrl import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils @@ -50,6 +53,7 @@ import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isContentScheme import io.legado.app.utils.isDataUrl import io.legado.app.utils.printOnDebug +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.runBlocking import org.apache.commons.text.StringEscapeUtils import splitties.init.appCtx @@ -60,7 +64,6 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.InputStream import java.util.regex.Pattern -import kotlin.coroutines.coroutineContext /** * 书籍文件导入 目录正文解析 @@ -403,7 +406,7 @@ object LocalBook { val inputStream = when { str.isAbsUrl() -> AnalyzeUrl( str, source = source, callTimeout = 0, - coroutineContext = coroutineContext + coroutineContext = currentCoroutineContext() ).getInputStreamAwait() str.isDataUrl() -> ByteArrayInputStream( @@ -458,7 +461,7 @@ object LocalBook { fun isOnBookShelf( fileName: String ): Boolean { - return appDb.bookDao.hasFile(fileName) == true + return appDb.bookDao.hasFile(fileName) } //文件类书源 合并在线书籍信息 在线 > 本地 @@ -501,9 +504,31 @@ object LocalBook { localBook.bookUrl = newBook.bookUrl } else { // txt epub pdf umd + val oldBook = localBook.copy() val fileUri = saveBookFile(it, localBook.originName) - localBook.bookUrl = FileDoc.fromUri(fileUri, false).toString() - localBook.save() + val newBookUrl = FileDoc.fromUri(fileUri, false).toString() + if (!oldBook.canSafelyRebindTo(newBookUrl)) { + localBook.cacheLocalUri(fileUri) + return true + } + + appDb.runInTransaction { + if (oldBook.bookUrl == newBookUrl) { + localBook.origin = + BookType.webDavTag + CustomUrl(webDavUrl).toString() + + localBook.save() + } else { + val newBook = oldBook.copy( + bookUrl = newBookUrl, + origin = BookType.webDavTag + CustomUrl(webDavUrl).toString() + ) + appDb.bookDao.replace(oldBook, newBook) + BookHelp.updateCacheFolder(oldBook, newBook) + localBook.bookUrl = newBookUrl + localBook.origin = newBook.origin + } + } } } return true diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt index 0e5629126..34ed3c536 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt @@ -26,10 +26,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) else { diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index 5ef204f9e..f390256f7 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween 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 @@ -26,6 +27,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 @@ -39,6 +41,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 @@ -51,6 +55,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 @@ -65,8 +70,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer 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 @@ -93,6 +101,7 @@ import io.legado.app.ui.widget.components.heatmap.rememberWeeks 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 @@ -103,8 +112,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) } @@ -114,6 +126,7 @@ fun ReadRecordScreen( var skipDeleteConfirm by remember { mutableStateOf(false) } var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var mergeDialogData by remember { mutableStateOf>?>(null) } val onConfirmDelete: (() -> Unit) -> Unit = { action -> if (skipDeleteConfirm) { action() @@ -130,6 +143,7 @@ fun ReadRecordScreen( Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { Column { GlassMediumFlexibleTopAppBar( @@ -231,7 +245,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 + } + } + } ) } } @@ -315,6 +339,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 @@ -329,7 +412,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( @@ -350,7 +433,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 = { } ) @@ -495,16 +578,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( @@ -518,7 +603,7 @@ fun LazyListScope.renderListByMode( ReadRecordItem( detail, viewModel, - onClick = { onBookClick(detail.bookName) }) + onClick = { onBookClick(detail.bookName, detail.bookAuthor) }) } } } @@ -549,7 +634,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( @@ -558,16 +643,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) } + ) + } } } - } } } @@ -580,8 +672,8 @@ fun LatestReadItem( ) { var coverPath by remember { mutableStateOf(null) } - LaunchedEffect(record.bookName) { - coverPath = viewModel.getBookCover(record.bookName) + LaunchedEffect(record.bookName, record.bookAuthor) { + coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor) } Row( @@ -599,18 +691,36 @@ fun LatestReadItem( Text( text = record.bookName, style = MaterialTheme.typography.titleMedium, - maxLines = 2 - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "总时长: ${formatDuring(record.readTime)}", - style = MaterialTheme.typography.bodySmall, - color = Color.Gray + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Text( - text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}", + text = record.bookAuthor.ifBlank { "未知作者" }, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.outline, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + 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 ) } } @@ -620,15 +730,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(null) } var chapterTitle by remember { mutableStateOf("加载中...") } - 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} 章" } @@ -645,7 +755,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 @@ -690,14 +800,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 ) @@ -716,8 +833,8 @@ fun ReadRecordItem( ) { var coverPath by remember { mutableStateOf(null) } - LaunchedEffect(detail.bookName) { - coverPath = viewModel.getBookCover(detail.bookName) + LaunchedEffect(detail.bookName, detail.bookAuthor) { + coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor) } Row( @@ -735,13 +852,18 @@ fun ReadRecordItem( Text( text = detail.bookName, style = MaterialTheme.typography.titleMedium, - maxLines = 2 + maxLines = 1 ) - Spacer(modifier = Modifier.height(4.dp)) + Text( + text = detail.bookAuthor.ifBlank { "未知作者" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + Spacer(modifier = Modifier.height(8.dp)) Text( text = "阅读时长: ${formatDuring(detail.readTime)}", - style = MaterialTheme.typography.bodySmall, - color = Color.Gray + color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.labelSmall ) } } @@ -782,14 +904,14 @@ fun ReadingSummaryCard( title: String, bookCount: Int, totalTimeMillis: Long, - bookNamesForCover: List, + bookNamesForCover: List>, 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) } } @@ -899,4 +1021,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" -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt index 669b3b74d..7bd1904f9 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt @@ -91,10 +91,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) -> @@ -152,7 +151,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()) @@ -161,12 +163,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 { + return repository.getMergeCandidates(targetRecord) + } + + fun mergeReadRecords(targetRecord: ReadRecord, sourceRecords: List) { + if (sourceRecords.isEmpty()) return + viewModelScope.launch { + repository.mergeReadRecordInto(targetRecord, sourceRecords) + } } private data class LoadedData( @@ -175,4 +188,4 @@ class ReadRecordViewModel( val latestRecords: List, val sessions: List ) -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt index c7934267b..5d76bffa2 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt +++ b/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" - ) - - 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.StartToEnd -> { + startAction?.let { action -> + SwipeBackground( + action = action, + progress = progress, + alignStart = true + ) + } } + + 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 + ) + } } \ No newline at end of file