diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd28bd5c5..0865e1e46 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,7 +51,7 @@ android { applicationId = "io.legato.kazusa" minSdk = 26 targetSdk = 37 - versionCode = System.getenv("COMMIT_NUMBER")?.toInt()?.let { 10000 + it } ?: 32640 + versionCode = System.getenv("COMMIT_NUMBER")?.toInt()?.let { 10000 + it } ?: 32641 versionName = System.getenv("APP_VERSION_NAME") ?: projectVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -152,6 +152,22 @@ android { } } +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.forEach { output -> + val abi = output.filters + .firstOrNull { it.filterType.name == "ABI" } + ?.identifier + ?: "universal" + output.outputFileName.set( + output.versionName.zip(output.versionCode) { versionName, versionCode -> + "QieKan-$abi-$versionName-vc$versionCode.apk" + } + ) + } + } +} + kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/app/schemas/io.legado.app.data.AppDatabase/94.json b/app/schemas/io.legado.app.data.AppDatabase/94.json new file mode 100644 index 000000000..9723f49fe --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/94.json @@ -0,0 +1,3104 @@ +{ + "formatVersion": 1, + "database": { + "version": 94, + "identityHash": "98b13caf6e843799cbd18dc9f6ebb6cb", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'loc_book'" + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT" + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT" + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT" + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT" + }, + { + "fieldPath": "remark", + "columnName": "remark", + "affinity": "TEXT" + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT" + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT" + }, + { + "fieldPath": "syncTime", + "columnName": "syncTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookUrl" + ] + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": false, + "columnNames": [ + "name", + "author" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + }, + { + "name": "index_books_durChapterTime", + "unique": false, + "columnNames": [ + "durChapterTime" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)" + } + ] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, `isPrivate` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableRefresh", + "columnName": "enableRefresh", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "bookSort", + "columnName": "bookSort", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "isPrivate", + "columnName": "isPrivate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + } + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT" + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT" + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "coverDecodeJs", + "columnName": "coverDecodeJs", + "affinity": "TEXT" + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT" + }, + { + "fieldPath": "variableComment", + "columnName": "variableComment", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "exploreScreen", + "columnName": "exploreScreen", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT" + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleReview", + "columnName": "ruleReview", + "affinity": "TEXT" + }, + { + "fieldPath": "eventListener", + "columnName": "eventListener", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customButton", + "columnName": "customButton", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "homepageModules", + "columnName": "homepageModules", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookSourceUrl" + ] + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `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, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variableComment", + "columnName": "variableComment", + "affinity": "TEXT" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "coverDecodeJs", + "columnName": "coverDecodeJs", + "affinity": "TEXT" + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT" + }, + { + "fieldPath": "contentWhitelist", + "columnName": "contentWhitelist", + "affinity": "TEXT" + }, + { + "fieldPath": "contentBlacklist", + "columnName": "contentBlacklist", + "affinity": "TEXT" + }, + { + "fieldPath": "shouldOverrideUrlLoading", + "columnName": "shouldOverrideUrlLoading", + "affinity": "TEXT" + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT" + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "injectJs", + "columnName": "injectJs", + "affinity": "TEXT" + }, + { + "fieldPath": "preloadJs", + "columnName": "preloadJs", + "affinity": "TEXT" + }, + { + "fieldPath": "startHtml", + "columnName": "startHtml", + "affinity": "TEXT" + }, + { + "fieldPath": "startStyle", + "columnName": "startStyle", + "affinity": "TEXT" + }, + { + "fieldPath": "startJs", + "columnName": "startJs", + "affinity": "TEXT" + }, + { + "fieldPath": "showWebLog", + "columnName": "showWebLog", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "preload", + "columnName": "preload", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "cacheFirst", + "columnName": "cacheFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "redirectPolicy", + "columnName": "redirectPolicy", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ASK_CROSS_ORIGIN'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceUrl" + ] + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "time" + ] + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'默认分组'" + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "origin", + "link", + "sort" + ] + } + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "record" + ] + }, + "indices": [ + { + "name": "index_rssReadRecords_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)" + } + ] + }, + { + "tableName": "readRecordDetail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `bookUrl` TEXT, `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": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readWords", + "columnName": "readWords", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "firstReadTime", + "columnName": "firstReadTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastReadTime", + "columnName": "lastReadTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "bookName", + "bookAuthor", + "date" + ] + } + }, + { + "tableName": "readRecordSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `bookUrl` TEXT, `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT" + }, + { + "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 '', `bookUrl` TEXT, `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": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "bookName", + "bookAuthor" + ] + } + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "defaultValue": "'0'" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "dictRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "urlRule", + "columnName": "urlRule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showRule", + "columnName": "showRule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "keyboardAssists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "serialNo", + "columnName": "serialNo", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "type", + "key" + ] + } + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT" + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "search_content_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_content_history_bookName_bookAuthor_query", + "unique": true, + "columnNames": [ + "bookName", + "bookAuthor", + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)" + } + ] + }, + { + "tableName": "homepage_modules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "moduleKey", + "columnName": "moduleKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "args", + "columnName": "args", + "affinity": "TEXT" + }, + { + "fieldPath": "layoutConfig", + "columnName": "layoutConfig", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customSetId", + "columnName": "customSetId", + "affinity": "TEXT" + }, + { + "fieldPath": "isUserCreated", + "columnName": "isUserCreated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customTitle", + "columnName": "customTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "customSetTitle", + "columnName": "customSetTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceJsonHash", + "columnName": "sourceJsonHash", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedAt", + "columnName": "syncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "homepage_custom_sets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "highlightRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleText", + "columnName": "sampleText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetScope", + "columnName": "targetScope", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "textColor", + "columnName": "textColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "bgColor", + "columnName": "bgColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "underlineMode", + "columnName": "underlineMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "underlineColor", + "columnName": "underlineColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "underlineWidth", + "columnName": "underlineWidth", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "underlineOffset", + "columnName": "underlineOffset", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "underlineSvgPath", + "columnName": "underlineSvgPath", + "affinity": "TEXT" + }, + { + "fieldPath": "bgImage", + "columnName": "bgImage", + "affinity": "TEXT" + }, + { + "fieldPath": "bgImageFit", + "columnName": "bgImageFit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bgImageScale", + "columnName": "bgImageScale", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "configName", + "columnName": "configName", + "affinity": "TEXT" + }, + { + "fieldPath": "fontPath", + "columnName": "fontPath", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ai_provider_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `modelsUrl` TEXT, `apiKey` TEXT NOT NULL, `authType` TEXT NOT NULL, `secretRef` TEXT, `headersJson` TEXT, `chatPath` TEXT, `responsesPath` TEXT, `messagesPath` TEXT, `modelsPath` TEXT, `customHeadersJson` TEXT, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelsUrl", + "columnName": "modelsUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "apiKey", + "columnName": "apiKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authType", + "columnName": "authType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secretRef", + "columnName": "secretRef", + "affinity": "TEXT" + }, + { + "fieldPath": "headersJson", + "columnName": "headersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "chatPath", + "columnName": "chatPath", + "affinity": "TEXT" + }, + { + "fieldPath": "responsesPath", + "columnName": "responsesPath", + "affinity": "TEXT" + }, + { + "fieldPath": "messagesPath", + "columnName": "messagesPath", + "affinity": "TEXT" + }, + { + "fieldPath": "modelsPath", + "columnName": "modelsPath", + "affinity": "TEXT" + }, + { + "fieldPath": "customHeadersJson", + "columnName": "customHeadersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ai_model_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `providerId` TEXT NOT NULL, `displayName` TEXT NOT NULL, `modelId` TEXT NOT NULL, `contextWindow` INTEGER NOT NULL, `maxOutputTokens` INTEGER NOT NULL, `capabilities` TEXT NOT NULL, `defaultParamsJson` TEXT, `enabled` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "providerId", + "columnName": "providerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextWindow", + "columnName": "contextWindow", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOutputTokens", + "columnName": "maxOutputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultParamsJson", + "columnName": "defaultParamsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ai_model_profiles_providerId", + "unique": false, + "columnNames": [ + "providerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_model_profiles_providerId` ON `${TABLE_NAME}` (`providerId`)" + } + ] + }, + { + "tableName": "ai_task_presets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `promptTemplate` TEXT NOT NULL, `paramsJson` TEXT, `chunkPolicyJson` TEXT, `enabled` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskType", + "columnName": "taskType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelProfileId", + "columnName": "modelProfileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "promptTemplate", + "columnName": "promptTemplate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paramsJson", + "columnName": "paramsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "chunkPolicyJson", + "columnName": "chunkPolicyJson", + "affinity": "TEXT" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ai_task_presets_taskType", + "unique": false, + "columnNames": [ + "taskType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_taskType` ON `${TABLE_NAME}` (`taskType`)" + }, + { + "name": "index_ai_task_presets_modelProfileId", + "unique": false, + "columnNames": [ + "modelProfileId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_modelProfileId` ON `${TABLE_NAME}` (`modelProfileId`)" + } + ] + }, + { + "tableName": "ai_artifacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `contentHash` TEXT NOT NULL, `promptHash` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `status` INTEGER NOT NULL, `output` TEXT, `errorMessage` TEXT, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskType", + "columnName": "taskType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentHash", + "columnName": "contentHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "promptHash", + "columnName": "promptHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelProfileId", + "columnName": "modelProfileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaVersion", + "columnName": "schemaVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ai_artifacts_bookUrl_chapterIndex_taskType", + "unique": false, + "columnNames": [ + "bookUrl", + "chapterIndex", + "taskType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_bookUrl_chapterIndex_taskType` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `taskType`)" + }, + { + "name": "index_ai_artifacts_contentHash_promptHash_modelProfileId", + "unique": false, + "columnNames": [ + "contentHash", + "promptHash", + "modelProfileId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_contentHash_promptHash_modelProfileId` ON `${TABLE_NAME}` (`contentHash`, `promptHash`, `modelProfileId`)" + } + ] + }, + { + "tableName": "ai_chat_conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `reasoningLevel` TEXT NOT NULL, `modelProfileId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reasoningLevel", + "columnName": "reasoningLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelProfileId", + "columnName": "modelProfileId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ai_chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `role` TEXT NOT NULL, `partsJson` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `branchIndex` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `parentMessageId` TEXT, `thinkingDuration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "partsJson", + "columnName": "partsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "branchIndex", + "columnName": "branchIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parentMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "thinkingDuration", + "columnName": "thinkingDuration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ai_chat_messages_conversationId_createdAt", + "unique": false, + "columnNames": [ + "conversationId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_chat_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)" + } + ] + }, + { + "tableName": "ai_memory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`, `key`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId", + "key" + ] + }, + "indices": [ + { + "name": "index_ai_memory_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_memory_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ] + }, + { + "tableName": "highlight_tag_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `pattern` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "tag_group_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, '98b13caf6e843799cbd18dc9f6ebb6cb')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/values-zh-rCN/strings.xml b/app/src/debug/res/values-zh-rCN/strings.xml index e4464c88d..e56dea754 100644 --- a/app/src/debug/res/values-zh-rCN/strings.xml +++ b/app/src/debug/res/values-zh-rCN/strings.xml @@ -1,4 +1,4 @@ - 阅读·D - 阅读·D·搜索 - \ No newline at end of file + 且看 + 且看·搜索 + diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index ffc41e3cd..e56dea754 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,4 +1,4 @@ - legado·D - legado·D·search - \ No newline at end of file + 且看 + 且看·搜索 + diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index d82bbd94f..8a671f04b 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -11,6 +11,7 @@ object PreferKey { const val showUnread = "showUnread" const val showUnreadNew = "showUnreadNew" const val bookGroupStyle = "bookGroupStyle" + const val bookshelfPrivacyMode = "bookshelfPrivacyMode" const val useDefaultCover = "useDefaultCover" const val loadCoverOnlyWifi = "loadCoverOnlyWifi" const val coverShowName = "coverShowName" 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 187d0b071..599d8e490 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -90,7 +90,7 @@ val appDb by lazy { } @Database( - version = 93, + version = 94, exportSchema = true, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, @@ -153,7 +153,8 @@ val appDb by lazy { AutoMigration(from = 89, to = 90), AutoMigration(from = 90, to = 91), AutoMigration(from = 91, to = 92), - AutoMigration(from = 92, to = 93) + AutoMigration(from = 92, to = 93), + AutoMigration(from = 93, to = 94, spec = DatabaseMigrations.Migration_93_94::class) ] ) abstract class AppDatabase : RoomDatabase() { 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 6dcaa5559..04345770b 100644 --- a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt +++ b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt @@ -496,4 +496,35 @@ object DatabaseMigrations { columnName = "enabledReview" ) class Migration_64_65 : AutoMigrationSpec + + @Suppress("ClassName") + class Migration_93_94 : AutoMigrationSpec { + + override fun onPostMigrate(db: SupportSQLiteDatabase) { + backfillBookUrl(db, "readRecord") + backfillBookUrl(db, "readRecordDetail") + backfillBookUrl(db, "readRecordSession") + } + + private fun backfillBookUrl(db: SupportSQLiteDatabase, tableName: String) { + db.execSQL( + """ + UPDATE `$tableName` + SET bookUrl = ( + SELECT MIN(book.bookUrl) + FROM books AS book + WHERE TRIM(book.name) = TRIM(`$tableName`.bookName) + AND (`$tableName`.bookAuthor = '' OR book.author = `$tableName`.bookAuthor) + ) + WHERE bookUrl IS NULL + AND 1 = ( + SELECT COUNT(*) + FROM books AS book + WHERE TRIM(book.name) = TRIM(`$tableName`.bookName) + AND (`$tableName`.bookAuthor = '' OR book.author = `$tableName`.bookAuthor) + ) + """.trimIndent() + ) + } + } } diff --git a/app/src/main/java/io/legado/app/data/dao/BookDao.kt b/app/src/main/java/io/legado/app/data/dao/BookDao.kt index d4faef07d..fd145e056 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookDao.kt @@ -22,11 +22,14 @@ data class GroupBookCount( val count: Int ) +private const val USER_GROUP_ID_FILTER = + "(groupId > 0 OR groupId = ${Long.MIN_VALUE})" + private const val PRIVATE_GROUP_MASK = - "(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0 AND isPrivate = 1)" + "(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE $USER_GROUP_ID_FILTER AND isPrivate = 1)" private const val PUBLIC_GROUP_MASK = - "(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0 AND isPrivate = 0)" + "(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE $USER_GROUP_ID_FILTER AND isPrivate = 0)" private const val PUBLIC_BOOK_FILTER = "(`group` = 0 OR (`group` & $PRIVATE_GROUP_MASK) = 0)" @@ -162,6 +165,21 @@ interface BookDao { ) fun flowBookShelf(): Flow> + @Query( + """ + SELECT + bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind, wordCount + FROM books + ORDER BY durChapterTime DESC + """ + ) + fun flowBookShelfIncludingPrivate(): Flow> + @Query("SELECT * FROM books WHERE type & ${BookType.audio} > 0") fun flowAudio(): Flow> @@ -235,7 +253,7 @@ interface BookDao { @Query( """ select * from books where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0 - and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 + and ((SELECT sum(groupId) FROM book_groups where groupId > 0 or groupId = ${Long.MIN_VALUE}) & `group`) = 0 """ ) fun flowNetNoGroup(): Flow> @@ -276,7 +294,7 @@ interface BookDao { @Query( """ select * from books where type & ${BookType.local} > 0 - and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 + and ((SELECT sum(groupId) FROM book_groups where groupId > 0 or groupId = ${Long.MIN_VALUE}) & `group`) = 0 """ ) fun flowLocalNoGroup(): Flow> @@ -314,7 +332,7 @@ interface BookDao { ) fun flowBookShelfLocalNoGroup(): Flow> - @Query("SELECT * FROM books WHERE (`group` & :group) > 0") + @Query("SELECT * FROM books WHERE (`group` & :group) != 0") fun flowByUserGroup(group: Long): Flow> @Query( @@ -343,7 +361,7 @@ interface BookDao { kind, wordCount FROM books - WHERE (`group` & :group) > 0 + WHERE (`group` & :group) != 0 AND ((SELECT isPrivate FROM book_groups WHERE groupId = :group) = 1 OR $PUBLIC_BOOK_FILTER) """ ) @@ -671,7 +689,7 @@ interface BookDao { ) fun flowBookShelfText(): Flow> - @Query("SELECT * FROM books WHERE (`group` & :group) > 0") + @Query("SELECT * FROM books WHERE (`group` & :group) != 0") fun getBooksByGroup(group: Long): List @Query("SELECT * FROM books WHERE `name` in (:names)") @@ -703,6 +721,15 @@ interface BookDao { @Query("SELECT * FROM books WHERE name = :name and author = :author") fun getBook(name: String, author: String): Book? + @Query( + """ + SELECT * FROM books + WHERE name = :name AND author = :author + ORDER BY durChapterTime DESC, bookUrl ASC + """ + ) + fun getBooks(name: String, author: String): List + @Query("""select distinct bs.* from books, book_sources bs where origin == bookSourceUrl and origin not like '${BookType.localTag}%' and origin not like '${BookType.webDavTag}%'""") @@ -774,7 +801,7 @@ interface BookDao { @Query("update books set `group` = :newGroupId where `group` = :oldGroupId") fun upGroup(oldGroupId: Long, newGroupId: Long) - @Query("update books set `group` = `group` - :group where `group` & :group > 0") + @Query("update books set `group` = `group` & ~:group where `group` & :group != 0") fun removeGroup(group: Long) @Query("delete from books where type & ${BookType.notShelf} > 0") @@ -818,7 +845,7 @@ interface BookDao { @Query( """ SELECT COUNT(*) FROM books - WHERE (`group` & :groupId) > 0 + WHERE (`group` & :groupId) != 0 AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER) """ ) @@ -1096,7 +1123,7 @@ interface BookDao { type, `group`, `order`, canUpdate, ifnull(customIntro, intro) as intro, kind, wordCount FROM books - WHERE (`group` & :groupId) > 0 + WHERE (`group` & :groupId) != 0 AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER) ORDER BY durChapterTime DESC LIMIT 10 diff --git a/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt b/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt index d27ea74c0..b767262ae 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt @@ -27,16 +27,16 @@ interface BookGroupDao { """ with const as ( SELECT - COALESCE(SUM(CASE WHEN groupId > 0 AND isPrivate = 0 THEN groupId ELSE 0 END), 0) sumPublicGroupId, - COALESCE(SUM(CASE WHEN groupId > 0 AND isPrivate = 1 THEN groupId ELSE 0 END), 0) sumPrivateGroupId + COALESCE(SUM(CASE WHEN (groupId > 0 OR groupId = ${Long.MIN_VALUE}) AND isPrivate = 0 THEN groupId ELSE 0 END), 0) sumPublicGroupId, + COALESCE(SUM(CASE WHEN (groupId > 0 OR groupId = ${Long.MIN_VALUE}) AND isPrivate = 1 THEN groupId ELSE 0 END), 0) sumPrivateGroupId FROM book_groups ) SELECT book_groups.* FROM book_groups join const where show > 0 and ( - (groupId >= 0 and exists ( + ((groupId >= 0 or groupId = ${Long.MIN_VALUE}) and exists ( select 1 from books - where `group` & book_groups.groupId > 0 + where `group` & book_groups.groupId != 0 and (book_groups.isPrivate = 1 or `group` = 0 or (const.sumPrivateGroupId & `group`) = 0) )) or groupId = ${BookGroup.IdAll} @@ -124,13 +124,13 @@ interface BookGroupDao { ) val show: LiveData> - @Query("SELECT * FROM book_groups where groupId >= 0 ORDER BY `order`") + @Query("SELECT * FROM book_groups where groupId >= 0 or groupId = ${Long.MIN_VALUE} ORDER BY `order`") fun flowSelect(): Flow> - @get:Query("SELECT sum(groupId) FROM book_groups where groupId >= 0") + @get:Query("SELECT sum(groupId) FROM book_groups where groupId >= 0 or groupId = ${Long.MIN_VALUE}") val idsSum: Long - @get:Query("SELECT MAX(`order`) FROM book_groups where groupId >= 0") + @get:Query("SELECT MAX(`order`) FROM book_groups where groupId >= 0 or groupId = ${Long.MIN_VALUE}") val maxOrder: Int @get:Query("SELECT * FROM book_groups ORDER BY `order`") @@ -142,7 +142,7 @@ interface BookGroupDao { @Query("update book_groups set show = 1 where groupId = :groupId") fun enableGroup(groupId: Long) - @Query("select groupName from book_groups where groupId > 0 and (groupId & :id) > 0") + @Query("select groupName from book_groups where (groupId > 0 or groupId = ${Long.MIN_VALUE}) and (groupId & :id) != 0") fun getGroupNames(id: Long): List @Insert(onConflict = OnConflictStrategy.REPLACE) 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 4035116c0..e68ad361b 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 @@ -5,6 +5,7 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import androidx.room.Update import io.legado.app.data.entities.readRecord.HomeRecentBookRow import io.legado.app.data.entities.readRecord.ReadRecord @@ -44,17 +45,26 @@ interface ReadRecordDao { """ WITH recent AS ( SELECT - bookName, - bookAuthor, - MAX(lastRead) AS lastRead - FROM readRecord - GROUP BY bookName, bookAuthor - ORDER BY lastRead DESC - LIMIT :limit + base.bookName, + base.bookAuthor, + ( + SELECT latest.bookUrl + FROM readRecord AS latest + WHERE latest.bookName = base.bookName + AND latest.bookAuthor = base.bookAuthor + AND latest.bookUrl IS NOT NULL + AND latest.bookUrl != '' + ORDER BY latest.lastRead DESC, latest.deviceId ASC + LIMIT 1 + ) AS recordBookUrl, + MAX(base.lastRead) AS lastRead + FROM readRecord AS base + GROUP BY base.bookName, base.bookAuthor ) SELECT recent.bookName AS recordName, recent.bookAuthor AS recordAuthor, + recent.recordBookUrl AS recordBookUrl, book.bookUrl AS bookUrl, book.origin AS origin, book.coverUrl AS coverUrl, @@ -63,18 +73,26 @@ interface ReadRecordDao { book.totalChapterNum AS totalChapterNum, book.durChapterIndex AS chapterIndex FROM recent - LEFT JOIN books AS book ON book.bookUrl = ( - SELECT candidate.bookUrl - FROM books AS candidate - WHERE candidate.name = recent.bookName - AND candidate.author = recent.bookAuthor - ORDER BY candidate.durChapterTime DESC, candidate.bookUrl ASC - LIMIT 1 + LEFT JOIN books AS book ON book.bookUrl = COALESCE( + ( + SELECT currentBook.bookUrl + FROM books AS currentBook + WHERE currentBook.bookUrl = recent.recordBookUrl + LIMIT 1 + ), + ( + SELECT candidate.bookUrl + FROM books AS candidate + WHERE candidate.name = recent.bookName + AND candidate.author = recent.bookAuthor + ORDER BY candidate.durChapterTime DESC, candidate.bookUrl ASC + LIMIT 1 + ) ) ORDER BY recent.lastRead DESC """ ) - fun observeRecentHomeBooks(limit: Int): Flow> + fun observeRecentHomeBooks(): Flow> @Query("select sum(readTime) from readRecord where bookName = :bookName") fun getReadTime(bookName: String): Long? @@ -85,6 +103,33 @@ interface ReadRecordDao { @Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor") suspend fun getReadRecord(deviceId: String, bookName: String, bookAuthor: String): ReadRecord? + @Query( + """ + SELECT bookUrl FROM books + WHERE TRIM(name) = TRIM(:bookName) + AND (:bookAuthor = '' OR author = :bookAuthor) + ORDER BY durChapterTime DESC, bookUrl ASC + LIMIT 2 + """ + ) + suspend fun findMatchingBookUrls(bookName: String, bookAuthor: String): List + + @Query("UPDATE readRecord SET bookUrl = :newBookUrl WHERE bookUrl = :oldBookUrl") + fun replaceReadRecordBookUrl(oldBookUrl: String, newBookUrl: String) + + @Query("UPDATE readRecordDetail SET bookUrl = :newBookUrl WHERE bookUrl = :oldBookUrl") + fun replaceReadRecordDetailBookUrl(oldBookUrl: String, newBookUrl: String) + + @Query("UPDATE readRecordSession SET bookUrl = :newBookUrl WHERE bookUrl = :oldBookUrl") + fun replaceReadRecordSessionBookUrl(oldBookUrl: String, newBookUrl: String) + + @Transaction + fun replaceBookUrl(oldBookUrl: String, newBookUrl: String) { + replaceReadRecordBookUrl(oldBookUrl, newBookUrl) + replaceReadRecordDetailBookUrl(oldBookUrl, newBookUrl) + replaceReadRecordSessionBookUrl(oldBookUrl, newBookUrl) + } + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(vararg readRecord: ReadRecord) diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt index 3f0fd6ddc..39ee1c7f5 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt @@ -3,6 +3,7 @@ package io.legado.app.data.entities.readRecord data class HomeRecentBookRow( val recordName: String, val recordAuthor: String, + val recordBookUrl: String?, val bookUrl: String?, val origin: String?, val coverUrl: String?, 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 fa9a57cd7..74cf860f7 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 @@ -9,8 +9,9 @@ data class ReadRecord( var bookName: String = "", @ColumnInfo(defaultValue = "") var bookAuthor: String = "", + var bookUrl: String? = null, @ColumnInfo(defaultValue = "0") var readTime: Long = 0L, @ColumnInfo(defaultValue = "0") var lastRead: Long = System.currentTimeMillis() -) \ No newline at end of file +) 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 ca929e2a2..3a082f5d6 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 @@ -12,6 +12,7 @@ data class ReadRecordDetail( val bookName: String = "", @ColumnInfo(defaultValue = "") val bookAuthor: String = "", + val bookUrl: String? = null, 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 8d9d4b861..452a86e70 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 @@ -13,6 +13,7 @@ data class ReadRecordSession( val bookName: String = "", @ColumnInfo(defaultValue = "") val bookAuthor: String = "", + val bookUrl: String? = null, // 一次阅读的开始/结束 val startTime: Long = 0, diff --git a/app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt b/app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt index ae0cd2a65..395b7d5aa 100644 --- a/app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt @@ -19,6 +19,7 @@ import io.legado.app.help.book.ContentProcessor import io.legado.app.utils.GSON import io.legado.app.utils.MD5Utils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext class AiToolRepository( @@ -27,7 +28,8 @@ class AiToolRepository( private val bookmarkDao: BookmarkDao, private val readRecordDao: ReadRecordDao, private val aiArtifactDao: AiArtifactDao, - private val aiMemoryGateway: AiMemoryGateway + private val aiMemoryGateway: AiMemoryGateway, + private val bookPrivacyRepository: BookPrivacyRepository, ) : AiToolGateway { override fun availableTools(): List = tools @@ -175,11 +177,15 @@ class AiToolRepository( return GSON.toJson(mapOf("bookmarks" to bookmarks)) } - private fun getReadingStats(args: JsonObject): String { + private suspend fun getReadingStats(args: JsonObject): String { val query = args.string("query").orEmpty().trim() val date = args.string("date")?.trim().orEmpty() val limit = args.int("limit", 10).coerceIn(1, 30) - val records = readRecordDao.all + val privacyState = bookPrivacyRepository.observe().first() + val visibleRecords = readRecordDao.all.filter { + privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) + } + val records = visibleRecords .asSequence() .filter { query.isBlank() || @@ -200,7 +206,8 @@ class AiToolRepository( val dailyDetails = readRecordDao.allDetail .asSequence() .filter { - (date.isBlank() || it.date == date) && + privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) && + (date.isBlank() || it.date == date) && (query.isBlank() || it.bookName.contains(query, ignoreCase = true) || it.bookAuthor.contains(query, ignoreCase = true)) @@ -222,7 +229,7 @@ class AiToolRepository( .toList() return GSON.toJson( mapOf( - "totalReadTimeMillis" to readRecordDao.all.sumOf { it.readTime }, + "totalReadTimeMillis" to visibleRecords.sumOf { it.readTime }, "recentRecords" to records, "dailyDetails" to dailyDetails ) diff --git a/app/src/main/java/io/legado/app/data/repository/BookPrivacyRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookPrivacyRepository.kt new file mode 100644 index 000000000..1402af4a9 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/BookPrivacyRepository.kt @@ -0,0 +1,102 @@ +package io.legado.app.data.repository + +import io.legado.app.constant.PreferKey +import io.legado.app.data.dao.BookDao +import io.legado.app.data.dao.BookGroupDao +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import java.text.Normalizer +import java.util.Locale + +data class BookRecordKey( + val name: String, + val author: String, +) + +data class BookPrivacyState( + val privacyModeEnabled: Boolean, + val privateGroupMask: Long, + val visibleBookUrls: Set, + val visibleBookKeys: Set, + val visibleBookNames: Set, + val hiddenBookUrls: Set, + val hiddenBookKeys: Set, + val hiddenBookNames: Set, +) { + + fun isReadRecordVisible( + bookUrl: String?, + bookName: String, + bookAuthor: String = "", + ): Boolean { + if (!privacyModeEnabled) return true + if (!bookUrl.isNullOrBlank()) { + if (bookUrl in hiddenBookUrls) return false + if (bookUrl in visibleBookUrls) return true + } + val normalizedName = normalizeBookName(bookName) + val normalizedAuthor = normalizeBookName(bookAuthor) + if (normalizedAuthor.isNotEmpty()) { + val key = BookRecordKey(normalizedName, normalizedAuthor) + if (key in hiddenBookKeys) return false + if (key in visibleBookKeys) return true + } + return normalizedName in visibleBookNames && normalizedName !in hiddenBookNames + } + + companion object { + fun create( + books: List, + groups: List, + privacyModeEnabled: Boolean, + ): BookPrivacyState { + val privateGroupMask = groups.asSequence() + .filter { + (it.groupId > 0L || it.groupId == Long.MIN_VALUE) && it.isPrivate + } + .fold(0L) { mask, group -> mask or group.groupId } + val hiddenBooks = books.filter { (it.group and privateGroupMask) != 0L } + val visibleBooks = books.filterNot { (it.group and privateGroupMask) != 0L } + return BookPrivacyState( + privacyModeEnabled = privacyModeEnabled, + privateGroupMask = privateGroupMask, + visibleBookUrls = visibleBooks.mapTo(hashSetOf()) { it.bookUrl }, + visibleBookKeys = visibleBooks.mapTo(hashSetOf()) { + BookRecordKey(normalizeBookName(it.name), normalizeBookName(it.author)) + }, + visibleBookNames = visibleBooks.mapTo(hashSetOf()) { + normalizeBookName(it.name) + }, + hiddenBookUrls = hiddenBooks.mapTo(hashSetOf()) { it.bookUrl }, + hiddenBookKeys = hiddenBooks.mapTo(hashSetOf()) { + BookRecordKey(normalizeBookName(it.name), normalizeBookName(it.author)) + }, + hiddenBookNames = hiddenBooks.mapTo(hashSetOf()) { + normalizeBookName(it.name) + }, + ) + } + } +} + +class BookPrivacyRepository( + private val bookDao: BookDao, + private val bookGroupDao: BookGroupDao, + private val settingsRepository: SettingsRepository, +) { + + fun observe(): Flow = combine( + bookDao.flowAll(), + bookGroupDao.flowAll(), + settingsRepository.getBoolean(PreferKey.bookshelfPrivacyMode, true), + BookPrivacyState::create, + ).distinctUntilChanged() +} + +internal fun normalizeBookName(name: String): String = + Normalizer.normalize(name, Normalizer.Form.NFKC) + .lowercase(Locale.ROOT) + .filterNot { it.isWhitespace() || it == '\u200B' || it == '\uFEFF' } 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 358e5005c..eab6c1880 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 @@ -8,32 +8,48 @@ import io.legado.app.data.entities.BookChapter import io.legado.app.ui.main.bookshelf.BookShelfItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext class BookRepository( private val bookDao: BookDao, - private val bookChapterDao: BookChapterDao + private val bookChapterDao: BookChapterDao, + private val bookPrivacyRepository: BookPrivacyRepository, ) { fun getAllBooks(): Flow> { return bookDao.flowAll() } - suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? { - return withContext(Dispatchers.IO) { - bookDao.getBook(bookName, bookAuthor)?.getDisplayCover() + fun getVisibleBooks(): Flow> { + return combine(bookDao.flowAll(), bookPrivacyRepository.observe()) { books, privacyState -> + books.filter { + privacyState.isReadRecordVisible(it.bookUrl, it.name, it.author) + } } } - suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndex: Int): String? { - return withContext(Dispatchers.IO) { - val book = bookDao.getBook(bookName, bookAuthor) - val bookUrl = book?.bookUrl - if (bookUrl.isNullOrEmpty()) return@withContext null + suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? { + return getVisibleBook(bookName, bookAuthor)?.getDisplayCover() + } + suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndex: Int): String? { + val bookUrl = getVisibleBook(bookName, bookAuthor)?.bookUrl ?: return null + return withContext(Dispatchers.IO) { bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex) } } + suspend fun getVisibleBook(name: String, author: String): Book? { + return withContext(Dispatchers.IO) { + val privacyState = bookPrivacyRepository.observe().first() + bookDao.getBooks(name, author).firstOrNull { + privacyState.isReadRecordVisible(it.bookUrl, it.name, it.author) + } + } + } + suspend fun getBook(bookUrl: String): Book? { return withContext(Dispatchers.IO) { bookDao.getBook(bookUrl) @@ -50,6 +66,12 @@ class BookRepository( return bookDao.flowBookShelfByGroup(groupId) } + fun flowBookShelfIncludingPrivate(): Flow> { + return bookDao.flowBookShelfIncludingPrivate().map { books -> + books.filterNot { it.isNotShelf } + } + } + fun flowSystemGroupCounts(): Flow> { return bookDao.flowSystemGroupCounts() } diff --git a/app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt b/app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt index 9793543b7..e4cd01e30 100644 --- a/app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt @@ -8,50 +8,67 @@ import io.legado.app.domain.model.DEFAULT_HOME_DASHBOARD_SECTIONS import io.legado.app.domain.model.HomeDashboardSection import io.legado.app.domain.model.HomeReadingBook import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map class HomeDashboardRepository( private val readRecordDao: ReadRecordDao, private val localPreferencesRepository: LocalPreferencesRepository, + private val readRecordRepository: ReadRecordRepository, + private val bookPrivacyRepository: BookPrivacyRepository, ) : HomeDashboardGateway { override fun observeTotalReadBooks(): Flow = - readRecordDao.observeTotalReadBookCount() + readRecordRepository.getTotalReadBookCount() override fun observeTotalReadTime(): Flow = - readRecordDao.getTotalReadTime().map { it ?: 0L } + readRecordRepository.getTotalReadTime() override fun observeReadTime(date: String): Flow = - readRecordDao.observeReadTimeByDate(date).map { it ?: 0L } + readRecordRepository.getReadTime(date) - override fun observeRecentBooks(limit: Int): Flow> = - readRecordDao.observeRecentHomeBooks(limit).map { rows -> - rows.map { row -> - HomeReadingBook( - bookUrl = row.bookUrl, - name = row.recordName, - author = row.recordAuthor, - origin = row.origin, - coverPath = if (row.customCoverUrl.isNullOrEmpty()) { - row.coverUrl - } else { - row.customCoverUrl - }, - chapterTitle = row.chapterTitle, - chapterProgress = if ( - row.totalChapterNum != null && - row.totalChapterNum > 0 && - row.chapterIndex != null - ) { - (row.chapterIndex + 1) - .coerceIn(0, row.totalChapterNum) - .toFloat() / row.totalChapterNum - } else { - null - }, - ) - } + override fun observeRecentBooks(limit: Int): Flow> { + return combine( + readRecordDao.observeRecentHomeBooks(), + bookPrivacyRepository.observe(), + ) { rows, privacyState -> + rows.asSequence() + .filter { row -> + privacyState.isReadRecordVisible( + row.recordBookUrl, + row.recordName, + row.recordAuthor, + ) + } + .take(limit) + .map { row -> + HomeReadingBook( + bookUrl = row.bookUrl, + name = row.recordName, + author = row.recordAuthor, + origin = row.origin, + coverPath = if (row.customCoverUrl.isNullOrEmpty()) { + row.coverUrl + } else { + row.customCoverUrl + }, + chapterTitle = row.chapterTitle, + chapterProgress = if ( + row.totalChapterNum != null && + row.totalChapterNum > 0 && + row.chapterIndex != null + ) { + (row.chapterIndex + 1) + .coerceIn(0, row.totalChapterNum) + .toFloat() / row.totalChapterNum + } else { + null + }, + ) + } + .toList() } + } override fun observeDailyGoal(defaultValue: Int): Flow = localPreferencesRepository.getPreference( 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 53367eb20..991b471ca 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 @@ -10,6 +10,8 @@ import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import java.util.Date import kotlin.math.max @@ -18,6 +20,7 @@ import kotlin.math.min class ReadRecordRepository( private val dao: ReadRecordDao, private val database: AppDatabase, + private val bookPrivacyRepository: BookPrivacyRepository, ) { private fun getCurrentDeviceId(): String = "" @@ -25,37 +28,73 @@ class ReadRecordRepository( * 获取总阅读时长流 */ fun getTotalReadTime(): Flow { - return dao.getTotalReadTime().map { it ?: 0L } + return getLatestReadRecords().map { records -> records.sumOf { it.readTime } } + } + + fun getTotalReadBookCount(): Flow { + return getLatestReadRecords().map { records -> + records.distinctBy { it.bookName to it.bookAuthor }.size + } + } + + fun getReadTime(date: String): Flow { + return getAllRecordDetails().map { details -> + details.filter { it.date == date }.sumOf { it.readTime } + } } /** * 根据搜索关键字获取最新的阅读书籍列表流 */ fun getLatestReadRecords(query: String = ""): Flow> { - return if (query.isBlank()) { + val records = if (query.isBlank()) { dao.getAllReadRecordsSortedByLastRead() } else { dao.searchReadRecordsByLastRead(query) } + return combine(records, bookPrivacyRepository.observe()) { items, privacyState -> + items.filter { + privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) + } + } } /** * 获取所有的每日统计详情流 */ fun getAllRecordDetails(query: String = ""): Flow> { - return if (query.isBlank()) { + val details = if (query.isBlank()) { dao.getAllDetails() } else { dao.searchDetails(query) } + return combine(details, bookPrivacyRepository.observe()) { items, privacyState -> + items.filter { + privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) + } + } } fun getAllSessions(): Flow> { - return dao.getAllSessions(getCurrentDeviceId()) + return combine( + dao.getAllSessions(getCurrentDeviceId()), + bookPrivacyRepository.observe(), + ) { sessions, privacyState -> + sessions.filter { + privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) + } + } } fun getBookSessions(bookName: String, bookAuthor: String): Flow> { - return dao.getSessionsByBookFlow(getCurrentDeviceId(), bookName, bookAuthor) + return combine( + dao.getSessionsByBookFlow(getCurrentDeviceId(), bookName, bookAuthor), + bookPrivacyRepository.observe(), + ) { sessions, privacyState -> + sessions.filter { + privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) + } + } } fun getBookTimelineDays(bookName: String, bookAuthor: String): Flow> { @@ -72,15 +111,27 @@ class ReadRecordRepository( } fun getBookReadTime(bookName: String, bookAuthor: String): Flow { - return dao.getReadTimeFlow(getCurrentDeviceId(), bookName, bookAuthor).map { it ?: 0L } + return combine( + dao.getReadTimeFlow(getCurrentDeviceId(), bookName, bookAuthor), + bookPrivacyRepository.observe(), + ) { readTime, privacyState -> + if (privacyState.isReadRecordVisible(null, bookName, bookAuthor)) { + readTime ?: 0L + } else { + 0L + } + } } suspend fun getMergeCandidates(targetRecord: ReadRecord): List { + val privacyState = bookPrivacyRepository.observe().first() return dao.getReadRecordsByNameExcludingAuthor( targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor - ) + ).filter { + privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) + } } /** @@ -104,6 +155,7 @@ class ReadRecordRepository( if (existingRecord != null) { dao.update( existingRecord.copy( + bookUrl = session.bookUrl ?: existingRecord.bookUrl, readTime = existingRecord.readTime + durationDelta, lastRead = session.endTime ) @@ -114,6 +166,7 @@ class ReadRecordRepository( deviceId = session.deviceId, bookName = session.bookName, bookAuthor = session.bookAuthor, + bookUrl = session.bookUrl, readTime = durationDelta, lastRead = session.endTime ) @@ -135,17 +188,22 @@ class ReadRecordRepository( dateString ) if (existingDetail != null) { - existingDetail.readTime += durationDelta - existingDetail.readWords += wordsDelta - existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime) - existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime) - dao.insertDetail(existingDetail) + dao.insertDetail( + existingDetail.copy( + bookUrl = session.bookUrl ?: existingDetail.bookUrl, + readTime = existingDetail.readTime + durationDelta, + readWords = existingDetail.readWords + wordsDelta, + firstReadTime = min(existingDetail.firstReadTime, session.startTime), + lastReadTime = max(existingDetail.lastReadTime, session.endTime), + ) + ) } else { dao.insertDetail( ReadRecordDetail( deviceId = session.deviceId, bookName = session.bookName, bookAuthor = session.bookAuthor, + bookUrl = session.bookUrl, date = dateString, readTime = durationDelta, readWords = wordsDelta, @@ -204,6 +262,7 @@ class ReadRecordRepository( ) dao.insertDetail( existingDetail?.copy( + bookUrl = existingDetail.bookUrl ?: remainingSessions.resolvedBookUrl(), readTime = totalTime, readWords = totalWords, firstReadTime = firstRead, @@ -212,6 +271,7 @@ class ReadRecordRepository( deviceId = session.deviceId, bookName = session.bookName, bookAuthor = session.bookAuthor, + bookUrl = remainingSessions.resolvedBookUrl(), date = dateString, readTime = totalTime, readWords = totalWords, @@ -233,6 +293,7 @@ class ReadRecordRepository( } else { val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime } val lastRead = allRemainingSessions.maxOf { it.endTime } + val bookUrl = allRemainingSessions.resolvedBookUrl() val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor) if (existingRecord == null) { @@ -241,6 +302,7 @@ class ReadRecordRepository( deviceId = deviceId, bookName = bookName, bookAuthor = bookAuthor, + bookUrl = bookUrl, readTime = totalTime, lastRead = lastRead, ) @@ -248,6 +310,7 @@ class ReadRecordRepository( } else { dao.update( existingRecord.copy( + bookUrl = existingRecord.bookUrl ?: bookUrl, readTime = totalTime, lastRead = lastRead ) @@ -288,9 +351,11 @@ class ReadRecordRepository( targetRecord.bookName, targetRecord.bookAuthor ) ?: targetRecord + val targetBookUrl = target.bookUrl ?: source.bookUrl dao.insert( target.copy( + bookUrl = targetBookUrl, readTime = target.readTime + source.readTime, lastRead = max(target.lastRead, source.lastRead) ) @@ -311,12 +376,14 @@ class ReadRecordRepository( if (existingTargetDetail == null) { dao.insertDetail( detail.copy( - bookAuthor = targetRecord.bookAuthor + bookAuthor = targetRecord.bookAuthor, + bookUrl = targetBookUrl, ) ) } else { dao.insertDetail( existingTargetDetail.copy( + bookUrl = targetBookUrl ?: existingTargetDetail.bookUrl, readTime = existingTargetDetail.readTime + detail.readTime, readWords = existingTargetDetail.readWords + detail.readWords, firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime), @@ -333,11 +400,22 @@ class ReadRecordRepository( sourceRecord.bookAuthor ) sourceSessions.forEach { session -> - dao.updateSession(session.copy(bookAuthor = targetRecord.bookAuthor)) + dao.updateSession( + session.copy( + bookAuthor = targetRecord.bookAuthor, + bookUrl = targetBookUrl, + ) + ) } dao.deleteReadRecord(source) updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor) } + private fun List.resolvedBookUrl(): String? = + asSequence() + .mapNotNull { it.bookUrl?.takeIf(String::isNotBlank) } + .distinct() + .singleOrNull() + } diff --git a/app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt index 2d73b08ac..cdcdd5d3e 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt @@ -410,7 +410,7 @@ class ReadSettingsRepository( textBottomJustify = this[Keys.TextBottomJustify] ?: true, adaptSpecialStyle = this[Keys.AdaptSpecialStyle] ?: true, useZhLayout = this[Keys.UseZhLayout] ?: false, - showBrightnessView = this[Keys.ShowBrightnessView] ?: "1", + showBrightnessView = getStringCompat(PreferKey.showBrightnessView, "1"), brightnessVwPos = this[Keys.BrightnessVwPos] ?: "1", readBrightness = this[Keys.ReadBrightness] ?: 100, brightnessAuto = this[Keys.BrightnessAuto] ?: false, diff --git a/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt b/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt index 65b55b489..c5ca5b0f9 100644 --- a/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt @@ -27,6 +27,17 @@ val Context.dataStore: DataStore by preferencesDataStore( } ) +internal fun normalizeLegacyPreferenceValue(key: String, value: Any): Any = + when { + key == PreferKey.showBrightnessView && value is Boolean -> if (value) "1" else "0" + else -> value + } + +internal fun Preferences.getStringCompat(key: String, defaultValue: String): String { + val value = asMap().entries.firstOrNull { it.key.name == key }?.value ?: return defaultValue + return normalizeLegacyPreferenceValue(key, value) as? String ?: defaultValue +} + /** * 设置仓储 * 以 DataStore 为唯一写入源,读取以 DataStore 为准。 @@ -110,15 +121,15 @@ class SettingsRepository(private val context: Context) { suspend fun batchPutFromMap(map: Map) { dataStore.edit { preferences -> map.forEach { (key, value) -> - when (value) { - is String -> preferences[stringPreferencesKey(key)] = value - is Int -> preferences[intPreferencesKey(key)] = value - is Boolean -> preferences[booleanPreferencesKey(key)] = value - is Long -> preferences[longPreferencesKey(key)] = value - is Float -> preferences[floatPreferencesKey(key)] = value + when (val normalizedValue = value?.let { normalizeLegacyPreferenceValue(key, it) }) { + is String -> preferences[stringPreferencesKey(key)] = normalizedValue + is Int -> preferences[intPreferencesKey(key)] = normalizedValue + is Boolean -> preferences[booleanPreferencesKey(key)] = normalizedValue + is Long -> preferences[longPreferencesKey(key)] = normalizedValue + is Float -> preferences[floatPreferencesKey(key)] = normalizedValue is Set<*> -> { @Suppress("UNCHECKED_CAST") - preferences[stringSetPreferencesKey(key)] = value as Set + preferences[stringSetPreferencesKey(key)] = normalizedValue as Set } } } diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index f2a9f13cd..33f8de7a6 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -18,6 +18,7 @@ import io.legado.app.data.repository.BackupRestoreRepository import io.legado.app.data.repository.BookCacheCleanupRepository import io.legado.app.data.repository.BookDomainRepositoryImpl import io.legado.app.data.repository.BookGroupRepository +import io.legado.app.data.repository.BookPrivacyRepository import io.legado.app.data.repository.BookRepository import io.legado.app.data.repository.BookSourceCallbackRepository import io.legado.app.data.repository.BookSourceRepository @@ -179,8 +180,11 @@ val appModule = module { single { get().bookGroupDao } single { get().bookSourceDao } + singleOf(::BookPrivacyRepository) singleOf(::ReadRecordRepository) - single { HomeDashboardRepository(get(), get()) } + single { + HomeDashboardRepository(get(), get(), get(), get()) + } singleOf(::BookRepository) singleOf(::BookGroupRepository) singleOf(::BookSourceRepository) @@ -230,7 +234,9 @@ val appModule = module { single { AiChatRepository(get()) } single { AiMemoryRepository(get()) } single { AiTextRepositoryImpl() } - single { AiToolRepository(get(), get(), get(), get(), get(), get()) } + single { + AiToolRepository(get(), get(), get(), get(), get(), get(), get()) + } single { AppStartupRepository(get()) } single { BackupRestoreRepository() } single { CacheBookDownloadRepository(get()) } diff --git a/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt index e972954c2..b7e5a3cb3 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt @@ -4,6 +4,7 @@ import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.data.dao.BookChapterDao import io.legado.app.data.dao.BookDao +import io.legado.app.data.dao.ReadRecordDao import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource @@ -68,6 +69,7 @@ enum class BatchChangeSourcePreviewStatus { class ChangeBookSourceUseCase( private val bookDao: BookDao, private val bookChapterDao: BookChapterDao, + private val readRecordDao: ReadRecordDao, ) { fun applyMigration( @@ -97,6 +99,7 @@ class ChangeBookSourceUseCase( bookChapterDao.delByBook(oldBook.bookUrl) bookDao.delete(oldBook) bookDao.insert(newBook) + readRecordDao.replaceBookUrl(oldBookUrl, newBook.bookUrl) if (options.migrateChapters) { bookChapterDao.insert(*chapters.toTypedArray()) ReadBook.onChapterListUpdated(newBook) 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 83ae74453..39dee962e 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 @@ -214,17 +214,28 @@ object Restore : KoinComponent { } fileToListT(path, "readRecord.json")?.let { it.forEach { readRecord -> - if (readRecord.deviceId != androidId) { + val restoredRecord = readRecord.copy( + bookUrl = resolveBookUrl( + readRecord.bookUrl, + readRecord.bookName, + readRecord.bookAuthor, + ) + ) + if (restoredRecord.deviceId != androidId) { try { - appDb.readRecordDao.insert(readRecord) + appDb.readRecordDao.insert(restoredRecord) } catch (_: SQLiteConstraintException) { } } else { val time = appDb.readRecordDao - .getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor) - if (time == null || time < readRecord.readTime) { + .getReadTime( + restoredRecord.deviceId, + restoredRecord.bookName, + restoredRecord.bookAuthor, + ) + if (time == null || time < restoredRecord.readTime) { try { - appDb.readRecordDao.insert(readRecord) + appDb.readRecordDao.insert(restoredRecord) } catch (_: SQLiteConstraintException) { } } @@ -234,7 +245,15 @@ object Restore : KoinComponent { fileToListT(path, "readRecordDetail.json")?.let { it.forEach { detail -> try { - appDb.readRecordDao.insertDetail(detail) + appDb.readRecordDao.insertDetail( + detail.copy( + bookUrl = resolveBookUrl( + detail.bookUrl, + detail.bookName, + detail.bookAuthor, + ) + ) + ) } catch (_: SQLiteConstraintException) { } } @@ -242,7 +261,15 @@ object Restore : KoinComponent { fileToListT(path, "readRecordSession.json")?.let { it.forEach { session -> try { - appDb.readRecordDao.insertSession(session) + appDb.readRecordDao.insertSession( + session.copy( + bookUrl = resolveBookUrl( + session.bookUrl, + session.bookName, + session.bookAuthor, + ) + ) + ) } catch (_: SQLiteConstraintException) { } } @@ -410,6 +437,15 @@ object Restore : KoinComponent { return map } + private suspend fun resolveBookUrl( + existingBookUrl: String?, + bookName: String, + bookAuthor: String, + ): String? { + if (!existingBookUrl.isNullOrBlank()) return existingBookUrl + return appDb.readRecordDao.findMatchingBookUrls(bookName, bookAuthor).singleOrNull() + } + private inline fun fileToListT(path: String, fileName: String): List? { try { val file = File(path, fileName) diff --git a/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt b/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt index c4352562a..1c0861b1b 100644 --- a/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt +++ b/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt @@ -68,11 +68,12 @@ data class Asset( val downloadCount: Int, val id: Int, val name: String, - val state: String, + val state: String?, val url: String ) { val isValid: Boolean - get() = (contentType == "application/vnd.android.package-archive") && (state == "uploaded") + get() = name.endsWith(".apk", ignoreCase = true) && + (state == null || state == "uploaded") fun assetToAppReleaseInfo(preRelease: Boolean, note: String, version: String): AppReleaseInfo { val instant = Instant.parse(createdAt) @@ -89,4 +90,4 @@ data class Asset( versionName = version ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt b/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt index 09c943dcb..da408b12b 100644 --- a/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt +++ b/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt @@ -14,6 +14,9 @@ import kotlinx.coroutines.CoroutineScope object AppUpdateGitHub : AppUpdate.AppUpdateInterface { + private const val RELEASES_API = + "https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases" + private val checkVariant: AppVariant get() = when (AppConfig.updateToVariant) { "official_version" -> AppVariant.OFFICIAL @@ -24,9 +27,9 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { private suspend fun getLatestRelease(): List { val url = if (checkVariant == AppVariant.OFFICIAL) - "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest" + "$RELEASES_API/latest" else - "https://api.github.com/repos/HapeLee/legado-with-MD3/releases" + RELEASES_API val res = okHttpClient.newCallResponse { url(url) } if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})") @@ -65,7 +68,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { } suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? { - val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag" + val url = "$RELEASES_API/tags/$tag" val res = okHttpClient.newCallResponse { url(url) } if (!res.isSuccessful) return null @@ -165,4 +168,4 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { fun String.versionCompare(other: String): Int { return SemVer.parse(this).compareTo(SemVer.parse(other)) } -} \ 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 1c8a357ff..73d1ab9e0 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -124,6 +124,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { ReadBook.book = book readRecord.bookName = book.name readRecord.bookAuthor = book.author + readRecord.bookUrl = book.bookUrl readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0 chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { @@ -330,6 +331,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent { deviceId = "", bookName = currentBookName, bookAuthor = currentBookAuthor, + bookUrl = book?.bookUrl, startTime = readStartTime, endTime = readStartTime, words = durChapterIndex.toLong() 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 1531107c5..47c22b81f 100644 --- a/app/src/main/java/io/legado/app/model/ReadManga.kt +++ b/app/src/main/java/io/legado/app/model/ReadManga.kt @@ -92,6 +92,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{ ReadManga.book = book readRecord.bookName = book.name readRecord.bookAuthor = book.author + readRecord.bookUrl = book.bookUrl readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0 chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { @@ -162,6 +163,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{ deviceId = "", bookName = currentBookName, bookAuthor = currentBookAuthor, + bookUrl = book?.bookUrl, startTime = readStartTime, endTime = readStartTime, words = durChapterIndex.toLong() diff --git a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt index e8bc447fd..c4a0feef6 100644 --- a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt @@ -55,7 +55,7 @@ fun AboutScreen( state: AboutUiState, onIntent: (AboutIntent) -> Unit, onBack: () -> Unit = {}, - versionName: String = appInfo.versionName, + versionName: String = "${appInfo.versionName} (${appInfo.versionCode})", ) { if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { MiuixAboutScreen( @@ -145,13 +145,13 @@ private fun MaterialAboutScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) { + FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }) { Icon( painter = painterResource(R.drawable.ic_web_outline), contentDescription = stringResource(R.string.back) ) } - FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) { + FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }) { Icon( painter = painterResource(R.drawable.ic_github), contentDescription = stringResource(R.string.back) @@ -172,7 +172,7 @@ private fun MaterialAboutScreen( SettingItemWithDivider { SettingItem( title = stringResource(R.string.contributors), - onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) } + onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) } ) } SettingItemWithDivider { diff --git a/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt b/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt index 53141d010..d41c3791a 100644 --- a/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt @@ -84,7 +84,7 @@ fun MiuixAboutScreen( state: AboutUiState, onIntent: (AboutIntent) -> Unit, onBack: () -> Unit = {}, - versionName: String = appInfo.versionName, + versionName: String = "${appInfo.versionName} (${appInfo.versionCode})", ) { val topAppBarScrollBehavior = MiuixScrollBehavior() val lazyListState = rememberLazyListState() @@ -395,7 +395,7 @@ private fun AboutContent( endActions = { ValueText("GitHub") }, - onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }, + onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }, ) } diff --git a/app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt b/app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt index be4a20797..4e13fb4fb 100644 --- a/app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt +++ b/app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt @@ -115,7 +115,7 @@ fun GroupEditContent( val sortEntryValues = remember(sortOptions) { Array(sortOptions.size) { (it - 1).toString() } } - val canSetPrivate = group == null || group.groupId > 0 + val canSetPrivate = group == null || group.groupId > 0 || group.groupId == Long.MIN_VALUE val selectImage = rememberLauncherForActivityResult(SelectImageContract()) { result -> result.uri?.let { uri -> diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt index f2814285e..d33a1abb8 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt @@ -53,7 +53,7 @@ class ReadRecordOverviewViewModel( _referenceDate, repository.getAllRecordDetails(""), repository.getLatestReadRecords(""), - bookRepository.getAllBooks() + bookRepository.getVisibleBooks() ) { period, refDate, details, latestRecords, allBooks -> getReadRecordOverviewUseCase(period, refDate, details, latestRecords, allBooks) }.stateIn( diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index c7ee1fccf..ad6ba1fdc 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -20,6 +20,7 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay import io.legado.app.help.config.AppConfig +import io.legado.app.data.repository.BookRepository import io.legado.app.model.Download import io.legado.app.ui.about.AboutEffect import io.legado.app.ui.about.AboutScreen @@ -74,11 +75,10 @@ import io.legado.app.utils.startActivityForBook import io.legado.app.utils.toastOnUi import io.legado.app.utils.toggleSystemBar import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf @OptIn(ExperimentalSharedTransitionApi::class) @@ -559,13 +559,12 @@ fun MainActivity.mainEntryProvider( } entry { + val bookRepository = koinInject() ReadRecordScreen( onBackClick = { onNavigateBack() }, onBookClick = { name, author -> lifecycleScope.launch { - val book = withContext(IO) { - io.legado.app.data.appDb.bookDao.getBook(name, author) - } + val book = bookRepository.getVisibleBook(name, author) if (book != null) this@mainEntryProvider.startActivityForBook(book) else { onNavigateToRoute(MainRouteSearch(key = name)) @@ -579,13 +578,12 @@ fun MainActivity.mainEntryProvider( } entry { + val bookRepository = koinInject() ReadRecordOverviewScreen( onBackClick = { onNavigateBack() }, onBookClick = { name, author -> lifecycleScope.launch { - val book = withContext(IO) { - io.legado.app.data.appDb.bookDao.getBook(name, author) - } + val book = bookRepository.getVisibleBook(name, author) if (book != null) this@mainEntryProvider.startActivityForBook(book) else { onNavigateToRoute(MainRouteSearch(key = name)) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt index f7e3f0246..39c64a9aa 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt @@ -55,6 +55,7 @@ import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.image.cover.BookshelfCover import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.text.hiddenAttributeUnderline import io.legado.app.utils.toTimeAgo /** @@ -83,6 +84,7 @@ fun BookshelfItem( titleMaxLines: Int = 2, coverShadow: Boolean = false, titleColor: Color? = null, + titleHidden: Boolean = false, descAnnotated: AnnotatedString? = null, coverWidth: Int = 84, onClick: () -> Unit, @@ -151,6 +153,7 @@ fun BookshelfItem( modifier = Modifier .align(Alignment.BottomStart) .fillMaxWidth() + .hiddenAttributeUnderline(titleHidden, Color.White) .background( Brush.verticalGradient( colors = listOf( @@ -173,6 +176,10 @@ fun BookshelfItem( textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start, modifier = Modifier .fillMaxWidth() + .hiddenAttributeUnderline( + titleHidden, + LegadoTheme.colorScheme.onSurfaceVariant + ) .padding(start = 4.dp, end = 4.dp, bottom = 4.dp) ) } @@ -237,7 +244,12 @@ fun BookshelfItem( }, maxLines = BookshelfConfig.bookshelfTitleMaxLines, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .hiddenAttributeUnderline( + titleHidden, + LegadoTheme.colorScheme.onSurfaceVariant + ) ) titleEnd?.let { Box(modifier = Modifier.padding(top = 4.dp, start = 4.dp)) { @@ -436,6 +448,7 @@ fun BookGroupItemGrid( ) }, title = group.groupName, + titleHidden = group.isPrivate, modifier = modifier, titleSmallFont = titleSmallFont, titleCenter = titleCenter, @@ -491,6 +504,7 @@ fun BookGroupItemList( isCompact = BookshelfConfig.bookshelfGroupListStyle == 1 || isCompact, cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) }, title = group.groupName, + titleHidden = group.isPrivate, subTitle = countText, descAnnotated = descAnnotated, titleSmallFont = titleSmallFont, @@ -549,6 +563,10 @@ fun BookGroupItemHorizontalCovers( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) + .hiddenAttributeUnderline( + group.isPrivate, + LegadoTheme.colorScheme.onSurfaceVariant + ) ) if (countText != null) { AppText( @@ -689,6 +707,7 @@ fun BookItem( ) }, title = book.name, + titleHidden = bookUi.isHidden, subTitle = if (layoutMode == 0 && isCompact) { stringResource(R.string.author_read, book.author, unreadCount) } else { diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt index 70acf40cd..2064af3e8 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt @@ -50,7 +50,7 @@ data class BookShelfItem( /** * 将 DTO 转换为专为 Compose 设计的 UI 状态 */ - fun toUiItem(): BookUiItem { + fun toUiItem(privateGroupMask: Long = 0L): BookUiItem { val tagList = mutableListOf() kind?.splitNotBlank(",", "\n")?.filter { it.isNotBlank() }?.let { tagList.addAll(it) @@ -61,7 +61,8 @@ data class BookShelfItem( return BookUiItem( book = this, - displayTags = tagList.toImmutableList() + displayTags = tagList.toImmutableList(), + isHidden = privateGroupMask != 0L && (group and privateGroupMask) != 0L ) } } @@ -72,7 +73,8 @@ data class BookShelfItem( @Stable data class BookUiItem( val book: BookShelfItem, - val displayTags: ImmutableList + val displayTags: ImmutableList, + val isHidden: Boolean = false ) { fun matches(key: String): Boolean { return book.name.contains(key, true) || diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 95f06c76f..e89e5f697 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -65,6 +65,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.outlined.ViewCarousel import androidx.compose.material3.ExperimentalMaterial3Api @@ -73,6 +74,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -238,7 +240,9 @@ fun BookshelfScreen( ) val folderGridState = rememberLazyGridState() val standaloneSearchGridState = rememberLazyGridState() - val groupGridStates = mutableMapOf() + // Pager may keep composing an old keyed page briefly after the visible group list changes. + // Retain states for removed groups until those pages are disposed to avoid a missing map key. + val groupGridStates = remember { mutableMapOf() } uiState.groups.forEach { group -> key(group.groupId) { groupGridStates[group.groupId] = rememberLazyGridState() @@ -507,6 +511,19 @@ fun BookshelfScreen( }, leadingIcon = { Icon(Icons.Outlined.ViewCarousel, null) } ) + RoundDropdownMenuItem( + text = stringResource(R.string.privacy_mode), + onClick = { + viewModel.setPrivacyMode(!uiState.privacyModeEnabled) + }, + leadingIcon = { Icon(Icons.Default.VisibilityOff, null) }, + trailingIcon = { + Switch( + checked = uiState.privacyModeEnabled, + onCheckedChange = viewModel::setPrivacyMode + ) + } + ) RoundDropdownMenuItem( text = stringResource(R.string.add_url), onClick = { @@ -578,6 +595,9 @@ fun BookshelfScreen( AppTabRow( tabTitles = tabTitles, + hiddenTabIndices = uiState.groups.mapIndexedNotNull { index, group -> + index.takeIf { group.isPrivate } + }.toSet(), selectedTabIndex = selectedTabIndex, onTabSelected = { index -> scope.launch { pagerState.animateScrollToPage(index) } @@ -608,6 +628,7 @@ fun BookshelfScreen( uiState.groups.forEachIndexed { index, group -> RoundDropdownMenuItem( text = group.groupName, + hidden = group.isPrivate, onClick = { if (uiState.isSearch) { viewModel.changeGroup(group.groupId) @@ -641,7 +662,9 @@ fun BookshelfScreen( it.groupId == BookGroup.IdAll } val hiddenGroups = uiState.allGroups.filter { - !it.show && it.groupId != BookGroup.IdAll + !it.show && + it.groupId != BookGroup.IdAll && + (!uiState.privacyModeEnabled || !it.isPrivate) } if (allGroup != null || hiddenGroups.isNotEmpty()) { @@ -656,6 +679,7 @@ fun BookshelfScreen( allGroup?.let { group -> RoundDropdownMenuItem( text = group.groupName, + hidden = group.isPrivate, onClick = { viewModel.changeGroup(group.groupId) dismiss() @@ -675,6 +699,7 @@ fun BookshelfScreen( hiddenGroups.forEach { group -> RoundDropdownMenuItem( text = group.groupName, + hidden = group.isPrivate, onClick = { viewModel.changeGroup(group.groupId) dismiss() @@ -999,6 +1024,7 @@ fun BookshelfScreen( uiState.groups.forEach { group -> RoundDropdownMenuItem( text = group.groupName, + hidden = group.isPrivate, onClick = { val targetIndex = uiState.groups.indexOfFirst { it.groupId == group.groupId } @@ -1140,7 +1166,10 @@ private fun BookshelfOverlays( GroupSelectSheet( show = activeOverlay == BookshelfOverlay.GroupSelectSheet, - groups = groups.filter { it.groupId > 0 }, + groups = groups.filter { + (it.groupId > 0 || it.groupId == Long.MIN_VALUE) && + (!uiState.privacyModeEnabled || !it.isPrivate) + }, currentGroupId = 0L, onDismissRequest = { viewModel.dismissOverlay() }, onConfirm = { groupId -> diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt index 06c6c9f0b..ea75cdb78 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt @@ -58,6 +58,7 @@ data class BookshelfUiState( val bookGroupStyle: Int = 0, val bookshelfSort: Int = 0, val bookshelfSortOrder: Int = 1, + val privacyModeEnabled: Boolean = true, val title: String = "", val subtitle: String? = null, val currentGroupName: String? = null, diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index 220e4c517..c57bb1247 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -10,6 +10,7 @@ import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup @@ -18,6 +19,7 @@ import io.legado.app.data.repository.BookGroupRepository import io.legado.app.data.repository.BookRepository import io.legado.app.data.repository.BookSourceRepository import io.legado.app.data.repository.BookshelfRepository +import io.legado.app.data.repository.SettingsRepository import io.legado.app.data.repository.UploadRepository import io.legado.app.domain.usecase.AddBookUseCase import io.legado.app.domain.usecase.BatchCacheDownloadUseCase @@ -81,6 +83,7 @@ class BookshelfViewModel( private val bookGroupRepository: BookGroupRepository, private val bookSourceRepository: BookSourceRepository, private val bookshelfRepository: BookshelfRepository, + private val settingsRepository: SettingsRepository, private val uploadRepository: UploadRepository, private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase, private val updateBooksGroupUseCase: UpdateBooksGroupUseCase, @@ -110,6 +113,13 @@ class BookshelfViewModel( val sortOrder: Int ) + private data class GroupBooksConfig( + val groups: List, + val sortConfig: BookshelfSortConfig, + val privacyModeEnabled: Boolean, + val privateGroupMask: Long + ) + private fun readSortConfig() = BookshelfSortConfig( sort = BookshelfConfig.bookshelfSort, sortOrder = BookshelfConfig.bookshelfSortOrder @@ -142,7 +152,40 @@ class BookshelfViewModel( protected val _eventChannel = Channel() val events = _eventChannel.receiveAsFlow() - val groupsFlow: SharedFlow> = bookGroupRepository.flowShow() + val allGroupsFlow: StateFlow> = bookGroupRepository.flowAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val privacyModeFlow: StateFlow = settingsRepository + .getBoolean(PreferKey.bookshelfPrivacyMode, true) + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + private val privateGroupMaskFlow: StateFlow = allGroupsFlow + .map { groups -> + groups.asSequence() + .filter { + (it.groupId > 0L || it.groupId == Long.MIN_VALUE) && it.isPrivate + } + .fold(0L) { mask, group -> mask or group.groupId } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L) + + private val allBooksIncludingPrivateFlow = bookRepository.flowBookShelfIncludingPrivate() + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + + private val displayedAllBookCountFlow = combine( + bookRepository.flowAllBookShelfCount(), + allBooksIncludingPrivateFlow, + privacyModeFlow + ) { publicCount, allBooks, privacyModeEnabled -> + if (privacyModeEnabled) publicCount else allBooks.size + } + + val groupsFlow: SharedFlow> = combine( + bookGroupRepository.flowShow(), + privacyModeFlow + ) { groups, privacyModeEnabled -> + if (privacyModeEnabled) groups.filterNot { it.isPrivate } else groups + } .onEach { if (it.isNotEmpty()) { isInitialLoadingFlow.value = false @@ -150,8 +193,21 @@ class BookshelfViewModel( } .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) - val allGroupsFlow: StateFlow> = bookGroupRepository.flowAll() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private fun displayedBooksFlow( + groupId: Long, + privacyModeEnabled: Boolean + ): Flow> { + return if ( + !privacyModeEnabled && + (groupId == BookGroup.IdAll || groupId > 0L || groupId == Long.MIN_VALUE) + ) { + allBooksIncludingPrivateFlow.map { books -> + if (groupId == BookGroup.IdAll) books else books.filter { (it.group and groupId) != 0L } + } + } else { + bookRepository.flowBookShelfByGroup(groupId) + } + } private data class GroupPreviewState( val previews: ImmutableMap>, @@ -180,38 +236,44 @@ class BookshelfViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState()) @OptIn(ExperimentalCoroutinesApi::class) - val booksFlow: Flow> = groupIdFlow - .flatMapLatest { groupId -> + val booksFlow: Flow> = combine( + groupIdFlow, + privacyModeFlow + ) { groupId, privacyModeEnabled -> groupId to privacyModeEnabled } + .flatMapLatest { (groupId, privacyModeEnabled) -> combine( - bookRepository.flowBookShelfByGroup(groupId), - groupsFlow, - sortConfigFlow - ) { list, groups, sortConfig -> + displayedBooksFlow(groupId, privacyModeEnabled), + allGroupsFlow, + sortConfigFlow, + privateGroupMaskFlow + ) { list, groups, sortConfig, privateGroupMask -> bookshelfRepository.sortBooks( list, groups.find { it.groupId == groupId }, sortConfig.sort, sortConfig.sortOrder - ).map { it.toUiItem() } + ).map { it.toUiItem(privateGroupMask) } } }.distinctUntilChanged().flowOn(Dispatchers.Default) @OptIn(ExperimentalCoroutinesApi::class) private val allGroupBooksImmutableFlow: Flow>> = - combine(groupsFlow, sortConfigFlow) { groups, sortConfig -> - groups to sortConfig - }.flatMapLatest { (groups, sortConfig) -> + combine(groupsFlow, sortConfigFlow, privacyModeFlow, privateGroupMaskFlow) { + groups, sortConfig, privacyModeEnabled, privateGroupMask -> + GroupBooksConfig(groups, sortConfig, privacyModeEnabled, privateGroupMask) + }.flatMapLatest { config -> + val groups = config.groups if (groups.isEmpty()) { flowOf(persistentMapOf()) } else { val flows = groups.map { group -> - bookRepository.flowBookShelfByGroup(group.groupId).map { books -> + displayedBooksFlow(group.groupId, config.privacyModeEnabled).map { books -> group.groupId to bookshelfRepository.sortBooks( books, group, - sortConfig.sort, - sortConfig.sortOrder - ).map { it.toUiItem() }.toImmutableList() + config.sortConfig.sort, + config.sortConfig.sortOrder + ).map { it.toUiItem(config.privateGroupMask) }.toImmutableList() } } combine(flows) { results -> @@ -256,7 +318,7 @@ class BookshelfViewModel( groupsFlow, bookGroupStyleFlow, bookRepository.flowSystemGroupCounts(), - bookRepository.flowAllBookShelfCount() + displayedAllBookCountFlow ) { groups, bookGroupStyle, systemCounts, totalCount -> DataForPreviews( groups, @@ -276,7 +338,9 @@ class BookshelfViewModel( flowOf(GroupPreviewState(persistentMapOf(), persistentMapOf(), allBookCount)) } else { val groupFlows = groups.map { group -> - val countFlow: Flow = if (group.groupId > 0) { + val countFlow: Flow = if ( + group.groupId > 0L || group.groupId == Long.MIN_VALUE + ) { bookRepository.flowUserGroupBookCount(group.groupId) } else { flowOf(systemCountsMap[group.groupId] ?: 0) @@ -372,7 +436,7 @@ class BookshelfViewModel( GroupPreviewState(persistentMapOf(), persistentMapOf(), 0) ) - private val dataStateFlow = combine( + private val dataCoreFlow = combine( booksFlow, groupsFlow, allGroupsFlow, @@ -380,14 +444,21 @@ class BookshelfViewModel( internalStateFlow ) { books, groups, allGroups, previews, internal -> BookshelfDataCore(books, groups, allGroups, previews, internal) - }.combine(allGroupBooksImmutableFlow) { core, allGroupBooks -> + } + + private val dataStateFlow = combine( + dataCoreFlow, + allGroupBooksImmutableFlow, + privacyModeFlow + ) { core, allGroupBooks, privacyModeEnabled -> BookshelfDataState( books = core.books, groups = core.groups.map { it.toBookGroupUi() }, allGroups = core.allGroups.map { it.toBookGroupUi() }, previews = core.previews, internal = core.internal, - allGroupBooks = allGroupBooks + allGroupBooks = allGroupBooks, + privacyModeEnabled = privacyModeEnabled ) } @@ -405,7 +476,8 @@ class BookshelfViewModel( val allGroups: List, val previews: GroupPreviewState, val internal: InternalState, - val allGroupBooks: ImmutableMap> + val allGroupBooks: ImmutableMap>, + val privacyModeEnabled: Boolean ) val uiState: StateFlow = combine( @@ -459,6 +531,7 @@ class BookshelfViewModel( bookGroupStyle = interaction.bookGroupStyle, bookshelfSort = internal.sortConfig.sort, bookshelfSortOrder = internal.sortConfig.sortOrder, + privacyModeEnabled = data.privacyModeEnabled, title = title, subtitle = when { interaction.isEditMode -> { @@ -506,6 +579,14 @@ class BookshelfViewModel( viewModelScope.launch { groupPreviewsFlow.collect { groupPreviewsStateFlow.value = it } } + viewModelScope.launch { + combine(privacyModeFlow, allGroupsFlow, groupIdFlow) { + privacyModeEnabled, groups, groupId -> + privacyModeEnabled && groups.any { it.groupId == groupId && it.isPrivate } + }.distinctUntilChanged().filter { it }.collect { + changeGroup(BookGroup.IdAll) + } + } viewModelScope.launch { combine(booksFlow, selectedGroupCanReorderFlow) { books, canReorderBooks -> books to canReorderBooks @@ -584,6 +665,12 @@ class BookshelfViewModel( clearSelection() } + fun setPrivacyMode(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.putBoolean(PreferKey.bookshelfPrivacyMode, enabled) + } + } + fun showOverlay(overlay: BookshelfOverlay) { activeOverlayFlow.value = overlay } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index 69cad7e1c..7fcfdc06a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -466,9 +466,11 @@ private fun CoverTextOverlay( val startX = viewWidth * 0.84f val fm = paint.fontMetrics val charHeight = fm.bottom - fm.top - var startY = viewHeight * 0.16f - (author.length * charHeight) - startY = startY.coerceAtLeast(viewHeight * 0.2f) - author.forEach { char -> + val visibleAuthor = author.takeLast( + ((viewHeight * 0.35f) / charHeight).toInt().coerceAtLeast(1) + ) + var startY = viewHeight * 0.92f - (visibleAuthor.length - 1) * charHeight + visibleAuthor.forEach { char -> nativeCanvas.drawText(char.toString(), startX, startY, paint) startY += charHeight } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt index 8dfa92d4b..7e56681cf 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.text.hiddenAttributeUnderline import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.basic.Check import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -50,6 +51,7 @@ fun RoundDropdownMenuItem( leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, enabled: Boolean = true, + hidden: Boolean = false, contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, interactionSource: MutableInteractionSource? = null, ) { @@ -88,7 +90,9 @@ fun RoundDropdownMenuItem( contentAlignment = Alignment.CenterStart ) { MiuixText( - modifier = Modifier.widthIn(max = 200.dp), + modifier = Modifier + .widthIn(max = 200.dp) + .hiddenAttributeUnderline(hidden, textColor), text = text, fontSize = MiuixTheme.textStyles.body1.fontSize, fontWeight = FontWeight.Medium, @@ -157,7 +161,9 @@ fun RoundDropdownMenuItem( contentAlignment = Alignment.CenterStart ) { Text( - modifier = Modifier.widthIn(max = 200.dp), + modifier = Modifier + .widthIn(max = 200.dp) + .hiddenAttributeUnderline(hidden, contentColor), text = text, style = LegadoTheme.typography.labelLargeEmphasized, color = contentColor diff --git a/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt b/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt index 8af61683b..4c0a82cf5 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.text.hiddenAttributeUnderline import top.yukonga.miuix.kmp.basic.TabRowDefaults import top.yukonga.miuix.kmp.basic.TabRowWithContour @@ -21,11 +22,12 @@ fun AppTabRow( selectedTabIndex: Int, onTabSelected: (Int) -> Unit, modifier: Modifier = Modifier, - isScrollable: Boolean = true + isScrollable: Boolean = true, + hiddenTabIndices: Set = emptySet() ) { val composeEngine = LegadoTheme.composeEngine - if (ThemeResolver.isMiuixEngine(composeEngine)) { + if (ThemeResolver.isMiuixEngine(composeEngine) && hiddenTabIndices.isEmpty()) { TabRowWithContour( tabs = tabTitles, selectedTabIndex = selectedTabIndex, @@ -50,7 +52,8 @@ fun AppTabRow( AppTab( selected = selectedTabIndex == index, onClick = { onTabSelected(index) }, - title = title + title = title, + hidden = index in hiddenTabIndices ) } } @@ -65,7 +68,8 @@ fun AppTabRow( AppTab( selected = selectedTabIndex == index, onClick = { onTabSelected(index) }, - title = title + title = title, + hidden = index in hiddenTabIndices ) } } @@ -77,7 +81,8 @@ fun AppTabRow( private fun AppTab( selected: Boolean, onClick: () -> Unit, - title: String + title: String, + hidden: Boolean ) { Tab( selected = selected, @@ -88,9 +93,15 @@ private fun AppTab( maxLines = 1, overflow = TextOverflow.Ellipsis, style = LegadoTheme.typography.labelLargeEmphasized, - modifier = Modifier.padding(horizontal = 8.dp), + modifier = Modifier + .padding(horizontal = 8.dp) + .hiddenAttributeUnderline( + hidden, + if (selected) LegadoTheme.colorScheme.primary + else LegadoTheme.colorScheme.onSurfaceVariant + ), color = if (selected) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant ) } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/text/HiddenAttributeUnderline.kt b/app/src/main/java/io/legado/app/ui/widget/components/text/HiddenAttributeUnderline.kt new file mode 100644 index 000000000..119f986c2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/text/HiddenAttributeUnderline.kt @@ -0,0 +1,25 @@ +package io.legado.app.ui.widget.components.text + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.unit.dp + +fun Modifier.hiddenAttributeUnderline( + hidden: Boolean, + color: Color +): Modifier = if (!hidden) { + this +} else { + drawBehind { + val strokeWidth = 1.dp.toPx() + drawLine( + color = color, + start = androidx.compose.ui.geometry.Offset(0f, size.height - strokeWidth), + end = androidx.compose.ui.geometry.Offset(size.width, size.height - strokeWidth), + strokeWidth = strokeWidth, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 3.dp.toPx())) + ) + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 86151c4f5..1ad4596ab 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,7 +1,7 @@ - 阅读 - 阅读·A + 且看 + 且看·A 阅读 · 搜索 阅读需要访问存储卡权限,请前往“设置”—“应用权限”,打开所需权限 @@ -134,7 +134,7 @@ 换源 全新设计的阅读 3.0 - 阅读 3.0 下载地址:\nhttps://github.com/gedoor/legado/releases + 且看 3.0 下载地址:\nhttps://gitea.yamby.cn/yusheng/QieKan-3.0/releases 版本 %s 后台校验书源 @@ -1295,9 +1295,10 @@ 管理私有文件夹的文件 创建文件夹 允许下拉刷新 - 设为隐私分组 - 关闭隐私分组 - 开启后,本分组内的书籍将从公开书架隐藏 + 设为隐藏分组 + 关闭隐藏分组 + 开启后,本分组及其中书籍将在隐私模式下隐藏 + 隐私模式 文字下划线 选中新增源 选中更新源 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index f35b50b23..379c90d2a 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1,7 +1,7 @@ - 閲讀 - 閲讀·A + 且看 + 且看·A 閲讀·搜尋 閲讀需要訪問存儲卡權限,請前往「設定」—「應用程式權限」—開啟所需要的權限 @@ -99,7 +99,7 @@ \u3000\u3000這是一款使用 Kotlin 全新開發的開源的閲讀應用程式,歡迎你的加入。 - 閲讀3.0下載地址:\nhttps://github.com/gedoor/legado/releases + 且看 3.0 下載地址:\nhttps://gitea.yamby.cn/yusheng/QieKan-3.0/releases Version %s 後臺校驗書源 @@ -1145,9 +1145,10 @@ 管理私有文件夹的文件 创建文件夹 允许下拉刷新 - 設為私隱分組 - 關閉私隱分組 - 開啟後,本分組內嘅書籍將從公開書架隱藏 + 設為隱藏分組 + 關閉隱藏分組 + 開啟後,本分組及其中書籍會在私隱模式下隱藏 + 私隱模式 文字下划线 选中新增源 选中更新源 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 20bc308bd..4df6844f5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,7 +1,7 @@ - 閱讀 - 閱讀·A + 且看 + 且看·A 閱讀·搜尋 閱讀需要存取記憶卡權限,請前往「設定」—「應用程式權限」—打開所需權限 @@ -101,7 +101,7 @@ \u3000\u3000這是一款使用Kotlin全新開發的開源的閱讀軟體,歡迎您的加入。 - 閱讀3.0下載網址:\nhttps://github.com/gedoor/legado/releases + 且看 3.0 下載網址:\nhttps://gitea.yamby.cn/yusheng/QieKan-3.0/releases Version %s 後臺校驗書源 @@ -1148,9 +1148,10 @@ 管理私有文件夹的文件 创建文件夹 允许下拉刷新 - 設為隱私分組 - 關閉隱私分組 - 開啟後,本分組內的書籍將從公開書架隱藏 + 設為隱藏分組 + 關閉隱藏分組 + 開啟後,本分組及其中書籍將在隱私模式下隱藏 + 隱私模式 文字下划线 选中新增源 选中更新源 diff --git a/app/src/main/res/values/non_translat.xml b/app/src/main/res/values/non_translat.xml index 7d32a5235..2bc25e9c6 100644 --- a/app/src/main/res/values/non_translat.xml +++ b/app/src/main/res/values/non_translat.xml @@ -5,14 +5,14 @@ gekunfei@live.com https://github.com/gedoor/legado/graphs/contributors - https://github.com/HapeLee/legado-with-MD3 + https://gitea.yamby.cn/yusheng/QieKan-3.0 https://gedoor.github.io/ http://%1$s:%2$d - GitHub + Gitea 【%s】 🔒%s 🔓%s - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9751e4ad2..0f870890a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,8 +1,8 @@ - Legado - Legado·A + 且看 + 且看·A Legado·search Legado needs storage access to find and read books. Please go "App Settings" to allow "Storage Permission". @@ -155,7 +155,7 @@ \u3000\u3000 This is an open source reading software newly developed by Kotlin, welcome to join us. - Legado (YueDu 3.0) download link:\n https://github.com/gedoor/legado/releases + QieKan 3.0 download link:\n https://gitea.yamby.cn/yusheng/QieKan-3.0/releases Version %s Background-verification @@ -1336,9 +1336,10 @@ Managing files in the application\'s private folder Create Folder Allow drop-down refresh - Set as private group - Disable private group - After enabling, books in this group are hidden from the public bookshelf + Set as hidden group + Disable hidden group + The group and its books are hidden while privacy mode is enabled + Privacy mode Text underline Select new source Select update source diff --git a/app/src/test/java/io/legado/app/data/repository/BookPrivacyStateTest.kt b/app/src/test/java/io/legado/app/data/repository/BookPrivacyStateTest.kt new file mode 100644 index 000000000..53563b273 --- /dev/null +++ b/app/src/test/java/io/legado/app/data/repository/BookPrivacyStateTest.kt @@ -0,0 +1,69 @@ +package io.legado.app.data.repository + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookGroup +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BookPrivacyStateTest { + + private val hiddenGroup = BookGroup(groupId = 32L, isPrivate = true) + private val visibleBook = Book( + bookUrl = "visible-url", + name = "MS攻略", + author = "公开作者", + group = 0L, + ) + private val hiddenBook = Book( + bookUrl = "hidden-url", + name = "秘密书", + author = "隐藏作者", + group = 32L, + ) + + @Test + fun `privacy mode only exposes records linked to visible books`() { + val state = BookPrivacyState.create( + books = listOf(visibleBook, hiddenBook), + groups = listOf(hiddenGroup), + privacyModeEnabled = true, + ) + + assertTrue(state.isReadRecordVisible("visible-url", "旧标题")) + assertTrue(state.isReadRecordVisible(null, "MS 攻略", "公开作者")) + assertTrue(state.isReadRecordVisible("stale-url", "MS 攻略", "公开作者")) + assertFalse(state.isReadRecordVisible("hidden-url", "任意标题")) + assertFalse(state.isReadRecordVisible(null, "秘密书")) + assertFalse(state.isReadRecordVisible("stale-url", "秘密书")) + assertFalse(state.isReadRecordVisible(null, "无法关联的历史别名")) + } + + @Test + fun `author disambiguates visible and hidden books with the same title`() { + val state = BookPrivacyState.create( + books = listOf( + visibleBook.copy(name = "同名书"), + hiddenBook.copy(name = "同名书"), + ), + groups = listOf(hiddenGroup), + privacyModeEnabled = true, + ) + + assertTrue(state.isReadRecordVisible("stale-url", "同名书", "公开作者")) + assertFalse(state.isReadRecordVisible("stale-url", "同名书", "隐藏作者")) + assertFalse(state.isReadRecordVisible("stale-url", "同名书")) + } + + @Test + fun `privacy mode off exposes all historical records`() { + val state = BookPrivacyState.create( + books = listOf(visibleBook, hiddenBook), + groups = listOf(hiddenGroup), + privacyModeEnabled = false, + ) + + assertTrue(state.isReadRecordVisible("hidden-url", "秘密书")) + assertTrue(state.isReadRecordVisible(null, "无法关联的历史别名")) + } +} diff --git a/app/src/test/java/io/legado/app/data/repository/SettingsRepositoryCompatibilityTest.kt b/app/src/test/java/io/legado/app/data/repository/SettingsRepositoryCompatibilityTest.kt new file mode 100644 index 000000000..781eb68e7 --- /dev/null +++ b/app/src/test/java/io/legado/app/data/repository/SettingsRepositoryCompatibilityTest.kt @@ -0,0 +1,35 @@ +package io.legado.app.data.repository + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import io.legado.app.constant.PreferKey +import org.junit.Assert.assertEquals +import org.junit.Test + +class SettingsRepositoryCompatibilityTest { + + @Test + fun normalizeLegacyBrightnessVisibilityBoolean() { + assertEquals("1", normalizeLegacyPreferenceValue(PreferKey.showBrightnessView, true)) + assertEquals("0", normalizeLegacyPreferenceValue(PreferKey.showBrightnessView, false)) + assertEquals("2", normalizeLegacyPreferenceValue(PreferKey.showBrightnessView, "2")) + } + + @Test + fun readLegacyBrightnessVisibilityBooleanAsString() { + val enabled = mutablePreferencesOf( + booleanPreferencesKey(PreferKey.showBrightnessView) to true + ) + val disabled = mutablePreferencesOf( + booleanPreferencesKey(PreferKey.showBrightnessView) to false + ) + val vertical = mutablePreferencesOf( + stringPreferencesKey(PreferKey.showBrightnessView) to "2" + ) + + assertEquals("1", enabled.getStringCompat(PreferKey.showBrightnessView, "0")) + assertEquals("0", disabled.getStringCompat(PreferKey.showBrightnessView, "1")) + assertEquals("2", vertical.getStringCompat(PreferKey.showBrightnessView, "1")) + } +} diff --git a/app/src/test/java/io/legado/app/ui/main/bookshelf/BookShelfItemTest.kt b/app/src/test/java/io/legado/app/ui/main/bookshelf/BookShelfItemTest.kt new file mode 100644 index 000000000..08488057b --- /dev/null +++ b/app/src/test/java/io/legado/app/ui/main/bookshelf/BookShelfItemTest.kt @@ -0,0 +1,33 @@ +package io.legado.app.ui.main.bookshelf + +import org.junit.Assert.assertTrue +import org.junit.Test + +class BookShelfItemTest { + + @Test + fun `marks book in highest-bit private group as hidden`() { + val book = BookShelfItem( + bookUrl = "book-url", + name = "book", + author = "author", + origin = "origin", + originName = "origin-name", + coverUrl = null, + customCoverUrl = null, + durChapterTitle = null, + durChapterTime = 0L, + durChapterPos = 0, + latestChapterTitle = null, + latestChapterTime = 0L, + lastCheckCount = 0, + totalChapterNum = 0, + durChapterIndex = 0, + type = 0, + group = Long.MIN_VALUE, + order = 0, + ) + + assertTrue(book.toUiItem(privateGroupMask = Long.MIN_VALUE).isHidden) + } +} diff --git a/app/version.properties b/app/version.properties index ec8ec3805..5167805d3 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,5 +1,5 @@ VERSION_MAJOR=3 VERSION_MINOR=26 -VERSION_PATCH=13 +VERSION_PATCH=14 VERSION_SUFFIX=1 -# 1 = Pre, 0 = Release \ No newline at end of file +# 1 = Pre, 0 = Release