From ba33e7cdd5e197b718eeb94bab583320242edc7e Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Tue, 25 Nov 2025 01:36:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8Compose=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E7=9A=84=E9=98=85=E8=AF=BB=E8=AE=B0=E5=BD=95=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=9A=84=E5=88=9D=E5=A7=8B=E7=89=88=E6=9C=AC=E3=80=82=20?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E9=98=85=E8=AF=BB=E8=AE=B0=E5=BD=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=9B=B4=E5=8A=A0=E5=BC=BA=E5=A4=A7=EF=BC=8C=E8=83=BD?= =?UTF-8?q?=E5=A4=9F=E8=8E=B7=E5=8F=96=E6=9F=90=E4=B8=80=E5=A4=A9=E6=89=80?= =?UTF-8?q?=E6=9C=89=E4=B9=A6=E7=B1=8D=E7=BB=9F=E8=AE=A1=E3=80=81=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E7=9A=84=E9=98=85=E8=AF=BB=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E7=AD=89=E3=80=82=20=E7=9B=AE=E5=89=8D=E4=BB=85?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=9F=BA=E6=9C=AC=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=B8=94=E5=B0=9A=E4=B8=8D=E5=AE=8C=E5=96=84=EF=BC=8C=E4=BB=85?= =?UTF-8?q?=E4=BE=9B=E5=B0=9D=E9=B2=9C=E4=BD=BF=E7=94=A8=E3=80=82=20?= =?UTF-8?q?=E7=94=B1=E4=BA=8E=E5=8E=9F=E7=89=88=E5=B9=B6=E6=97=A0=E7=B2=BE?= =?UTF-8?q?=E7=BB=86=E5=8C=96=E7=9A=84=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E6=89=80=E4=BB=A5=E6=97=B6=E9=97=B4=E7=BA=BF=E4=B8=8E?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E6=9F=A5=E7=9C=8B=E4=BB=85=E5=9C=A8=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=AD=A4=E7=89=88=E6=9C=AC=E5=90=8E=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E3=80=82=E5=8E=9F=E7=89=88=E7=9A=84=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E6=9C=80=E5=90=8E=E9=98=85=E8=AF=BB=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=BB=8D=E5=8F=AF=E4=BD=BF=E7=94=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 22 +- .../io.legado.app.data.AppDatabase/79.json | 1925 +++++++++++++++++ app/src/main/AndroidManifest.xml | 5 + app/src/main/java/io/legado/app/App.kt | 3 +- .../java/io/legado/app/data/AppDatabase.kt | 12 +- .../io/legado/app/data/dao/ReadRecordDao.kt | 93 +- .../entities/{ => readRecord}/ReadRecord.kt | 2 +- .../entities/readRecord/ReadRecordDetail.kt | 28 + .../entities/readRecord/ReadRecordSession.kt | 20 + .../data/repository/ReadRecordRepository.kt | 119 + .../java/io/legado/app/di/readRecordModule.kt | 15 + .../io/legado/app/help/storage/Restore.kt | 2 +- .../main/java/io/legado/app/model/ReadBook.kt | 101 +- .../java/io/legado/app/model/ReadManga.kt | 103 +- .../app/ui/book/manga/ReadMangaActivity.kt | 2 +- .../app/ui/book/read/ReadBookActivity.kt | 3 + .../io/legado/app/ui/book/read/ReadMenu.kt | 5 +- .../ui/book/readRecord/ReadRecordActivity.kt | 528 +++++ .../ui/book/readRecord/ReadRecordViewModel.kt | 90 + .../io/legado/app/ui/main/my/MyFragment.kt | 3 +- .../java/io/legado/app/utils/StringUtils.kt | 27 + 21 files changed, 3063 insertions(+), 45 deletions(-) create mode 100644 app/schemas/io.legado.app.data.AppDatabase/79.json rename app/src/main/java/io/legado/app/data/entities/{ => readRecord}/ReadRecord.kt (88%) create mode 100644 app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt create mode 100644 app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt create mode 100644 app/src/main/java/io/legado/app/di/readRecordModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4830a5ef..7a2ab6c11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -273,19 +273,27 @@ jobs: # --no-merges: 排除合并提交 COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges) - # 检查是否获取到内容 if [ -z "$COMMIT_LOG" ]; then - RELEASE_BODY="### 更新内容\n\n- 没有新的 Commit 记录." + RELEASE_BODY=$(cat <> $GITHUB_OUTPUT echo "$RELEASE_BODY" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - + echo "生成的 Release Body:" echo "$RELEASE_BODY" diff --git a/app/schemas/io.legado.app.data.AppDatabase/79.json b/app/schemas/io.legado.app.data.AppDatabase/79.json new file mode 100644 index 000000000..1635346f0 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/79.json @@ -0,0 +1,1925 @@ +{ + "formatVersion": 1, + "database": { + "version": 79, + "identityHash": "9f8bcb54f6faa9946037b60810c1d167", + "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, `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": "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, 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" + } + ], + "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, 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": "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" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "origin", + "link" + ] + } + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "record" + ] + } + }, + { + "tableName": "readRecordDetail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `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`, `date`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "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", + "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, `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": "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, 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" + } + ], + "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, `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "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" + ] + } + }, + { + "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, '9f8bcb54f6faa9946037b60810c1d167')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 820d955b5..92de3d3f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -332,6 +332,11 @@ android:hardwareAccelerated="true" android:theme="@style/AppTheme.Transparent" /> + + + /** + * 获取某一天所有书籍的详细统计 (用于日历页面总览) + */ + @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date") + suspend fun getDetailsByDate(deviceId: String, date: String): List + + // 清除每天的统计记录 + @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName") + fun deleteDetailByName(bookName: String) + + /** + * 插入阅读会话记录。 + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertSession(session: ReadRecordSession) + + /** 获取所有 ReadRecord,按最后阅读时间倒序排列 */ + @Query("SELECT * FROM readRecord ORDER BY lastRead DESC") + suspend fun getAllReadRecordsSortedByLastRead(): List + + /** 搜索 ReadRecord,按最后阅读时间倒序排列 */ + @Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC") + suspend fun searchReadRecordsByLastRead(query: String): List + + /** + * 获取某一天某一本书的所有会话记录 + */ + @Query(""" + SELECT * FROM readRecordSession + WHERE deviceId = :deviceId + AND bookName = :bookName + AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch')) = :date + ORDER BY startTime ASC + """) + suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List + + /** + * 获取某一天所有书籍的会话记录 + */ + @Query(""" + SELECT * FROM readRecordSession + WHERE deviceId = :deviceId + AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date + ORDER BY startTime DESC + """) + suspend fun getSessionsByDate(deviceId: String, date: String): List + + // 清除会话记录 + @Query("DELETE FROM readRecordSession WHERE bookName = :bookName") + fun deleteSessionByName(bookName: String) + + @Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC") + suspend fun getAllDetails(): List + + @Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC") + suspend fun searchDetails(query: String): List + + @Delete + suspend fun deleteDetail(detail: ReadRecordDetail) } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/ReadRecord.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt similarity index 88% rename from app/src/main/java/io/legado/app/data/entities/ReadRecord.kt rename to app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt index c1638ac31..7e9d3cf0e 100644 --- a/app/src/main/java/io/legado/app/data/entities/ReadRecord.kt +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt @@ -1,4 +1,4 @@ -package io.legado.app.data.entities +package io.legado.app.data.entities.readRecord import androidx.room.ColumnInfo import androidx.room.Entity 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 new file mode 100644 index 000000000..11e673271 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt @@ -0,0 +1,28 @@ +package io.legado.app.data.entities.readRecord + +import androidx.room.ColumnInfo +import androidx.room.Entity + +@Entity( + tableName = "readRecordDetail", + primaryKeys = ["deviceId", "bookName", "date"] +) +data class ReadRecordDetail( + val deviceId: String = "", + val bookName: String = "", + val date: String = "", + + // 当天阅读总时长 + @ColumnInfo(defaultValue = "0") + var readTime: Long = 0L, + + // 当天阅读总字数 + @ColumnInfo(defaultValue = "0") + var readWords: Long = 0L, + // 当天第一次阅读时间 + @ColumnInfo(defaultValue = "0") + var firstReadTime: Long = 0L, + // 当天最后一次阅读时间 + @ColumnInfo(defaultValue = "0") + var lastReadTime: Long = 0L +) 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 new file mode 100644 index 000000000..9c1389ac8 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt @@ -0,0 +1,20 @@ +package io.legado.app.data.entities.readRecord + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "readRecordSession") +data class ReadRecordSession( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + val deviceId: String = "", + val bookName: String = "", + + // 一次阅读的开始/结束 + val startTime: Long = 0, + val endTime: Long = 0, + + // 本次阅读的字数 + val words: Long = 0 +) 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 new file mode 100644 index 000000000..ebd76dd1a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -0,0 +1,119 @@ +package io.legado.app.data.repository + +import androidx.room.Transaction +import cn.hutool.core.date.DatePattern +import cn.hutool.core.date.DateUtil +import io.legado.app.data.dao.ReadRecordDao +import io.legado.app.data.entities.readRecord.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecordDetail +import io.legado.app.data.entities.readRecord.ReadRecordSession +import java.util.Date +import kotlin.math.max +import kotlin.math.min + +class ReadRecordRepository( + private val dao: ReadRecordDao +) { + + private fun getCurrentDeviceId(): String = "" + + /** + * 保存一个完整的阅读会话,并同步更新 ReadRecordDetail 和 ReadRecord。 + */ + @Transaction + suspend fun saveReadSession(session: ReadRecordSession) { + dao.insertSession(session) + + val sessionDuration = session.endTime - session.startTime + val dateString = DateUtil.format(Date(session.startTime), DatePattern.NORM_DATE_PATTERN) + + updateReadRecordDetail(session, sessionDuration, dateString) + updateReadRecord(session, sessionDuration) + } + + private suspend fun updateReadRecord(session: ReadRecordSession, sessionDuration: Long) { + val existingRecord = dao.getReadRecord(session.deviceId, session.bookName) + + if (existingRecord != null) { + val updatedRecord = existingRecord.copy( + readTime = existingRecord.readTime + sessionDuration, + lastRead = session.endTime + ) + dao.update(updatedRecord) + } else { + val newRecord = ReadRecord( + deviceId = session.deviceId, + bookName = session.bookName, + readTime = sessionDuration, + lastRead = session.endTime + ) + dao.insert(newRecord) + } + } + + private suspend fun updateReadRecordDetail(session: ReadRecordSession, sessionDuration: Long, dateString: String) { + val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString) + + if (existingDetail != null) { + existingDetail.readTime += sessionDuration + existingDetail.readWords += session.words + existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime) + existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime) + dao.insertDetail(existingDetail) + } else { + val newDetail = ReadRecordDetail( + deviceId = session.deviceId, + bookName = session.bookName, + date = dateString, + readTime = sessionDuration, + readWords = session.words, + firstReadTime = session.startTime, + lastReadTime = session.endTime + ) + dao.insertDetail(newDetail) + } + } + + suspend fun getDailyDetails(deviceId: String, date: String): List { + return dao.getDetailsByDate(deviceId, date) + } + + suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List { + return dao.getSessionsByBookAndDate(deviceId, bookName, date) + } + + suspend fun getLatestReadRecords(query: String = ""): List { + return if (query.isBlank()) { + dao.getAllReadRecordsSortedByLastRead() + } else { + dao.searchReadRecordsByLastRead(query) + } + } + + suspend fun getAllSessionsForDate(date: String): List { + val deviceId = getCurrentDeviceId() + return dao.getSessionsByDate(deviceId, date) + } + + suspend fun getAllRecordDetails(query: String = ""): List { + return if (query.isBlank()) { + dao.getAllDetails() + } else { + dao.searchDetails(query) + } + } + + suspend fun deleteDetail(detail: ReadRecordDetail) { + dao.deleteDetail(detail) + } + + suspend fun clearAll() { + dao.clear() // 清除总表 + // dao.clearDetails() + } + + // 暴露总时长 + val allTime: Long + get() = dao.allTime + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/readRecordModule.kt b/app/src/main/java/io/legado/app/di/readRecordModule.kt new file mode 100644 index 000000000..3182aa405 --- /dev/null +++ b/app/src/main/java/io/legado/app/di/readRecordModule.kt @@ -0,0 +1,15 @@ +package io.legado.app.di + +import io.legado.app.data.AppDatabase +import io.legado.app.data.repository.ReadRecordRepository +import io.legado.app.ui.book.readRecord.ReadRecordViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val readRecordModule = module { + single { get().readRecordDao } + single { get().bookDao } + single { ReadRecordRepository(get()) } + + viewModel { ReadRecordViewModel(get(), get()) } +} \ 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 a3bcafbc0..de3ab4676 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 @@ -18,7 +18,7 @@ import io.legado.app.data.entities.Bookmark import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.KeyboardAssist -import io.legado.app.data.entities.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssStar 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 3766c8944..e349dd870 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -8,7 +8,9 @@ import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookSource -import io.legado.app.data.entities.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecordSession +import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor @@ -50,6 +52,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import splitties.init.appCtx import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException @@ -58,7 +62,7 @@ import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") -object ReadBook : CoroutineScope by MainScope() { +object ReadBook : CoroutineScope by MainScope(), KoinComponent { var book: Book? = null var callBack: CallBack? = null var inBookshelf = false @@ -73,6 +77,8 @@ object ReadBook : CoroutineScope by MainScope() { var nextTextChapter: TextChapter? = null var bookSource: BookSource? = null var msg: String? = null + private val readRecordRepository: ReadRecordRepository by inject() + private var lastReadLength: Long = 0 private val loadingChapters = arrayListOf() private val readRecord = ReadRecord() private val chapterLoadingJobs = ConcurrentHashMap>() @@ -280,16 +286,91 @@ object ReadBook : CoroutineScope by MainScope() { } } + // 阅读暂停的阈值 + private const val PAUSE_THRESHOLD = 5 * 60 * 1000L + // 存储当前正在累积的阅读会话对象 + private var currentActiveSession: ReadRecordSession? = null + //占位 + private var currentReadLength: Long = 10L + fun upReadTime() { - executor.execute { - if (!AppConfig.enableReadRecord) { - return@execute - } - readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime - readStartTime = System.currentTimeMillis() - readRecord.lastRead = System.currentTimeMillis() - appDb.readRecordDao.insert(readRecord) + val currentLength = currentReadLength + if (!AppConfig.enableReadRecord) { + return } + val currentBookName = book?.name ?: return + val endTime = System.currentTimeMillis() + + //计算本次片段的时长和字数 + val duration = endTime - readStartTime + var wordChange = currentLength - lastReadLength + if (wordChange < 0) wordChange = 0 + + //过滤无效记录 + if (duration < 1000L && wordChange == 0L) { + readStartTime = endTime + lastReadLength = currentLength + return + } + + //检查是否中断 + if (currentActiveSession != null) { + // 计算从上一个片段结束到当前片段开始的时间间隔 + val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime + + if (timeSinceLastUpdate > PAUSE_THRESHOLD) { + // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话 + commitReadSession() + } + } + + + //累加或创建新会话 + if (currentActiveSession == null) { + // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间 + currentActiveSession = ReadRecordSession( + deviceId = "", + bookName = currentBookName, + startTime = readStartTime, + endTime = endTime, + words = 0 + ) + } else { + // 累加数据:只更新结束时间和总字数 + currentActiveSession = currentActiveSession!!.copy( + endTime = endTime, + words = currentActiveSession!!.words + wordChange + ) + } + + readStartTime = endTime + lastReadLength = currentLength + } + + /** + * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。 + * 此方法必须在后台线程上调用。 + */ + fun commitReadSession() { + // 检查是否有需要保存的数据 + val sessionToSave = currentActiveSession ?: return + + // 确保时长和字数有效 + if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) { + currentActiveSession = null + return + } + + //使用 runBlocking 确保数据库保存操作在当前线程上同步完成 + try { + kotlinx.coroutines.runBlocking { + readRecordRepository.saveReadSession(sessionToSave) + } + } catch (e: Exception) { + AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e) + } + + currentActiveSession = null } fun upMsg(msg: String?) { 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 1f0940184..c21a0b9f4 100644 --- a/app/src/main/java/io/legado/app/model/ReadManga.kt +++ b/app/src/main/java/io/legado/app/model/ReadManga.kt @@ -6,7 +6,9 @@ import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookSource -import io.legado.app.data.entities.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecordSession +import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.help.AppWebDav import io.legado.app.help.ConcurrentRateLimiter import io.legado.app.help.book.BookHelp @@ -39,10 +41,13 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.getValue import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") -object ReadManga : CoroutineScope by MainScope() { +object ReadManga : CoroutineScope by MainScope() , KoinComponent{ var inBookshelf = false var book: Book? = null val executor = globalExecutor @@ -68,6 +73,9 @@ object ReadManga : CoroutineScope by MainScope() { val mangaContents get() = buildMangaContent() val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1 + private val readRecordRepository: ReadRecordRepository by inject() + + private var lastReadLength: Long = 0 fun resetData(book: Book) { ReadManga.book = book readRecord.bookName = book.name @@ -127,16 +135,91 @@ object ReadManga : CoroutineScope by MainScope() { } //每次切换章节更新阅读记录 + // 阅读暂停的阈值 + private const val PAUSE_THRESHOLD = 5 * 60 * 1000L + // 存储当前正在累积的阅读会话对象 + private var currentActiveSession: ReadRecordSession? = null + //占位 + private var currentReadLength: Long = 10L + fun upReadTime() { - executor.execute { - if (!AppConfig.enableReadRecord) { - return@execute - } - readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime - readStartTime = System.currentTimeMillis() - readRecord.lastRead = System.currentTimeMillis() - appDb.readRecordDao.insert(readRecord) + val currentLength = currentReadLength + if (!AppConfig.enableReadRecord) { + return } + val currentBookName = book?.name ?: return + val endTime = System.currentTimeMillis() + + //计算本次片段的时长和字数 + val duration = endTime - readStartTime + var wordChange = currentLength - lastReadLength + if (wordChange < 0) wordChange = 0 + + //过滤无效记录 + if (duration < 1000L && wordChange == 0L) { + readStartTime = endTime + lastReadLength = currentLength + return + } + + //检查是否中断 + if (currentActiveSession != null) { + // 计算从上一个片段结束到当前片段开始的时间间隔 + val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime + + if (timeSinceLastUpdate > PAUSE_THRESHOLD) { + // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话 + commitReadSession() + } + } + + + //累加或创建新会话 + if (currentActiveSession == null) { + // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间 + currentActiveSession = ReadRecordSession( + deviceId = "", + bookName = currentBookName, + startTime = readStartTime, + endTime = endTime, + words = 0 + ) + } else { + // 累加数据:只更新结束时间和总字数 + currentActiveSession = currentActiveSession!!.copy( + endTime = endTime, + words = currentActiveSession!!.words + wordChange + ) + } + + readStartTime = endTime + lastReadLength = currentLength + } + + /** + * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。 + * 此方法必须在后台线程上调用。 + */ + fun commitReadSession() { + // 检查是否有需要保存的数据 + val sessionToSave = currentActiveSession ?: return + + // 确保时长和字数有效 + if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) { + currentActiveSession = null + return + } + + //使用 runBlocking 确保数据库保存操作在当前线程上同步完成 + try { + kotlinx.coroutines.runBlocking { + readRecordRepository.saveReadSession(sessionToSave) + } + } catch (e: Exception) { + AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e) + } + + currentActiveSession = null } @Synchronized diff --git a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt index c2a02230a..6a63a21a3 100644 --- a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt @@ -13,7 +13,6 @@ import android.view.animation.LinearInterpolator import androidx.activity.addCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog -import androidx.core.graphics.toColorInt import androidx.core.view.doOnLayout import androidx.core.view.isGone import androidx.core.view.isVisible @@ -445,6 +444,7 @@ class ReadMangaActivity : VMBaseActivity + lifecycleScope.launch { + val book = withContext(Dispatchers.IO) { + appDb.bookDao.findByName(bookName).firstOrNull() + } + if (book != null) startActivityForBook(book) + } + } + ) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReadRecordScreen( + viewModel: ReadRecordViewModel, + onBackClick: () -> Unit, + onBookClick: (String) -> Unit +) { + val state by viewModel.uiState.collectAsState() + val displayMode by viewModel.displayMode.collectAsState() + var showSearch by remember { mutableStateOf(false) } + var searchText by remember { mutableStateOf("") } + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + LaunchedEffect(showSearch) { + if (!showSearch) { + viewModel.loadData("") + } + } + + LaunchedEffect(searchText) { + if (showSearch) { + kotlinx.coroutines.delay(100L) + viewModel.loadData(searchText) + } + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + Column { + LargeTopAppBar( + title = { Text("阅读记录") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { + val newMode = when (displayMode) { + DisplayMode.AGGREGATE -> DisplayMode.TIMELINE + DisplayMode.TIMELINE -> DisplayMode.LATEST + DisplayMode.LATEST -> DisplayMode.AGGREGATE + } + viewModel.setDisplayMode(newMode) + }) { + val icon = when (displayMode) { + DisplayMode.AGGREGATE -> Icons.Default.Timeline + DisplayMode.TIMELINE -> Icons.Default.Schedule + DisplayMode.LATEST -> Icons.AutoMirrored.Filled.List + } + val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate" + Icon(icon, description) + } + IconButton(onClick = { showSearch = !showSearch }) { + Icon(Icons.Default.Search, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + + AnimatedVisibility(visible = showSearch) { + SearchBarSection( + query = searchText, + onQueryChange = { searchText = it } + ) + } + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + + TotalTimeHeader(state.totalReadTime) + + LazyColumn { + when(displayMode){ + DisplayMode.AGGREGATE -> { + state.groupedRecords.forEach { (date, details) -> + + val dailyTotalTime = details.sumOf { it.readTime } + + stickyHeader { + DateHeader(date, dailyTotalTime) + } + + items( + items = details, + key = { it.bookName + it.readTime.toString() } + ) { detail -> + ReadRecordItem( + detail = detail, + viewModel = viewModel, + onClick = { onBookClick(detail.bookName) }, + onDelete = { viewModel.deleteDetail(detail) }, + modifier = Modifier.animateItem() + ) + } + } + } + DisplayMode.TIMELINE -> { + state.timelineRecords.forEach { (date, sessions) -> + val dailyTotalTime = sessions.sumOf { it.endTime - it.startTime } + stickyHeader { DateHeader(date, dailyTotalTime) } + + val timelineItems = sessions.mapIndexed { index, session -> + val previousSession = sessions.getOrNull(index - 1) + + val showHeader = index == 0 || session.bookName != previousSession?.bookName + TimelineItem(session, showHeader) + } + + items(items = timelineItems, key = { it.session.id }) { item -> + TimelineSessionItem( + item = item, + onBookClick = onBookClick, + viewModel = viewModel + ) + } + } + } + DisplayMode.LATEST -> { + items(items = state.latestRecords, key = { it.bookName + it.deviceId }) { record -> + LatestReadItem( + record = record, + viewModel = viewModel, + onClick = { onBookClick(record.bookName) }, + modifier = Modifier.animateItem() + ) + } + } + } + + } + } + } +} + +@Composable +fun LatestReadItem( + record: ReadRecord, + viewModel: ReadRecordViewModel, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + var coverPath by remember { mutableStateOf(null) } + + LaunchedEffect(record.bookName) { + coverPath = viewModel.getBookCover(record.bookName) + } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BookCoverWithPlaceholder(coverPath) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + 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 + ) + Text( + text = "最后阅读: ${DateUtil.format(java.util.Date(record.lastRead), "yyyy-MM-dd HH:mm")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +fun TimelineSessionItem( + item: TimelineItem, + viewModel: ReadRecordViewModel, + onBookClick: (String) -> Unit +) { + val session = item.session + var coverPath by remember { mutableStateOf(null) } + + LaunchedEffect(session.bookName) { + coverPath = viewModel.getBookCover(session.bookName) + } + + val startTimeText = DateUtil.format(java.util.Date(session.startTime), "HH:mm") + val endTimeText = DateUtil.format(java.util.Date(session.endTime), "HH:mm") + val duration = session.endTime - session.startTime + + val nodeRadius = 4.dp + val lineWidth = 2.dp + val timelineX = 24.dp + val contentPaddingStart = 32.dp + + val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + val nodeColor = MaterialTheme.colorScheme.primary + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 0.dp) + .clickable { onBookClick(session.bookName) } + .drawBehind { + val x = timelineX.toPx() + val h = size.height + val cy = h / 2f + + drawLine( + color = lineColor, + start = Offset(x, 0f), + end = Offset(x, h), + strokeWidth = lineWidth.toPx() + ) + + drawCircle( + color = nodeColor, + radius = nodeRadius.toPx(), + center = Offset(x, cy) + ) + + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = contentPaddingStart, end = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.width(48.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = startTimeText, + style = MaterialTheme.typography.bodySmall + ) + } + + Column(modifier = Modifier.weight(1f)) { + if (item.showHeader) { + Row(verticalAlignment = Alignment.CenterVertically) { + BookCoverWithPlaceholder(coverPath) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = session.bookName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1 + ) + } + Spacer(modifier = Modifier.height(6.dp)) + } + + Text( + "时长: ${formatDuring(duration)}", + style = MaterialTheme.typography.bodySmall + ) + // Text( + // "字数: ${session.words}", + // style = MaterialTheme.typography.bodySmall, + // color = MaterialTheme.colorScheme.onSurfaceVariant + // ) + } + } + } +} + +@Composable +fun ReadRecordItem( + detail: ReadRecordDetail, + viewModel: ReadRecordViewModel, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + var coverPath by remember { mutableStateOf(null) } + + LaunchedEffect(detail.bookName) { + coverPath = viewModel.getBookCover(detail.bookName) + } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BookCoverWithPlaceholder(coverPath) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = detail.bookName, + style = MaterialTheme.typography.titleMedium, + maxLines = 2 + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "阅读时长: ${formatDuring(detail.readTime)}", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + + IconButton(onClick = onDelete) { + Icon(Icons.Default.Close, "Delete", tint = Color.LightGray) + } + } +} + +@Composable +fun BookCoverWithPlaceholder(path: String?) { + Box( + modifier = Modifier + .width(48.dp) + .height(68.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFFEEEEEE)), // 灰色背景 + contentAlignment = Alignment.Center + ) { + if (path == null) { + Icon(Icons.Default.Book, null, tint = Color.Gray) + } else { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(path) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Composable +fun DateHeader( + date: String, + dailyTotalTime: Long +) { + val dateText = formatFriendlyDate(date) + val totalTimeText = "阅读时长: ${formatDuring(dailyTotalTime)}" + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = dateText, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.secondary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = totalTimeText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +fun TotalTimeHeader(time: Long) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("总阅读时长", style = MaterialTheme.typography.labelMedium) + Text( + text = formatDuring(time), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchBarSection( + query: String, + onQueryChange: (String) -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + + TextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("搜索书名…") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Default.Clear, contentDescription = null) + } + } + } + }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ) + ) + } +} + +fun formatDuring(mss: Long): String { + val days = mss / (1000 * 60 * 60 * 24) + val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60) + val minutes = mss % (1000 * 60 * 60) / (1000 * 60) + val seconds = mss % (1000 * 60) / 1000 + val d = if (days > 0) "${days}天" else "" + val h = if (hours > 0) "${hours}小时" else "" + 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 new file mode 100644 index 000000000..c4938e51e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt @@ -0,0 +1,90 @@ +package io.legado.app.ui.book.readRecord + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.legado.app.data.dao.BookDao +import io.legado.app.data.entities.readRecord.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecordDetail +import io.legado.app.data.entities.readRecord.ReadRecordSession +import io.legado.app.data.repository.ReadRecordRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class ReadRecordUiState( + val isLoading: Boolean = true, + val totalReadTime: Long = 0, + //每日聚合明细 + val groupedRecords: Map> = emptyMap(), + //每日所有阅读会话 + val timelineRecords: Map> = emptyMap(), + //最后阅读列表 + val latestRecords: List = emptyList() +) + +enum class DisplayMode { + AGGREGATE, + TIMELINE, + LATEST +} + +class ReadRecordViewModel( + private val repository: ReadRecordRepository, + private val bookDao: BookDao +) : ViewModel() { + private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE) + val displayMode = _displayMode.asStateFlow() + private val _uiState = MutableStateFlow(ReadRecordUiState()) + val uiState = _uiState.asStateFlow() + + init { + loadData() + } + + fun setDisplayMode(mode: DisplayMode) { + _displayMode.value = mode + } + + fun loadData(query: String = "") { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + + val details = repository.getAllRecordDetails(query) + val grouped = details.groupBy { it.date } + val uniqueDates = grouped.keys.toList() + val timelineMap = LinkedHashMap>() + for (date in uniqueDates) { + val sessions = repository.getAllSessionsForDate(date) + timelineMap[date] = sessions + } + val latest = repository.getLatestReadRecords(query) + val totalTime = withContext(Dispatchers.IO) { repository.allTime } + _uiState.update { + it.copy( + isLoading = false, + totalReadTime = totalTime, + groupedRecords = grouped, + timelineRecords = timelineMap, + latestRecords = latest + ) + } + } + } + + fun deleteDetail(detail: ReadRecordDetail) { + viewModelScope.launch { + repository.deleteDetail(detail) + loadData() + } + } + + suspend fun getBookCover(bookName: String): String? { + return withContext(Dispatchers.IO) { + bookDao.findByName(bookName).firstOrNull()?.getDisplayCover() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt b/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt index 8ff0020db..9f00ff398 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt +++ b/app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt @@ -15,11 +15,10 @@ import io.legado.app.databinding.FragmentMyConfigBinding import io.legado.app.lib.dialogs.selector import io.legado.app.lib.prefs.SwitchPreference import io.legado.app.lib.prefs.fragment.PreferenceFragment -//import io.legado.app.lib.theme.primaryColor import io.legado.app.service.WebService import io.legado.app.ui.about.AboutActivity -import io.legado.app.ui.about.ReadRecordActivity import io.legado.app.ui.book.bookmark.AllBookmarkActivity +import io.legado.app.ui.book.readRecord.ReadRecordActivity import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity import io.legado.app.ui.config.ConfigActivity diff --git a/app/src/main/java/io/legado/app/utils/StringUtils.kt b/app/src/main/java/io/legado/app/utils/StringUtils.kt index acc118d12..a56ac52e3 100644 --- a/app/src/main/java/io/legado/app/utils/StringUtils.kt +++ b/app/src/main/java/io/legado/app/utils/StringUtils.kt @@ -8,6 +8,9 @@ import java.io.ByteArrayOutputStream import java.io.IOException import java.text.DecimalFormat import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit import java.util.Calendar import java.util.Locale import java.util.regex.Matcher @@ -97,6 +100,30 @@ object StringUtils { return "" } + /** + * 将日期字符串转换为“今天”、“昨天”、“N天前”或标准日期格式 + * @param dateString 待转换的日期字符串 yyyy-MM-dd + * @param pattern 用于解析 dateString 的格式,通常是 yyyy-MM-dd + */ + fun formatFriendlyDate(dateString: String, pattern: String = "yyyy-MM-dd"): String { + return kotlin.runCatching { + val format = SimpleDateFormat(pattern, Locale.getDefault()) + val date = format.parse(dateString) ?: return@runCatching dateString + val inputDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + val today = LocalDate.now(ZoneId.systemDefault()) + val daysBetween = ChronoUnit.DAYS.between(inputDate, today) + when (daysBetween) { + 0L -> "今天" + 1L -> "昨天" + in 2L..5L -> "${daysBetween}天前" + else -> dateString + } + }.getOrElse { + it.printStackTrace() + dateString + } + } + /** * 首字母大写 */