From d6dc77a6853b37486a49a60049b3565f1d7defb0 Mon Sep 17 00:00:00 2001 From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:08:00 +0700 Subject: [PATCH 1/2] feat: implement scanlator filtering backport 1864 (upstream) --- .eslintrc.js | 2 +- App.tsx | 24 +- __mocks__/database.js | 2 + .../migration.sql | 1 + .../snapshot.json | 682 ++++++++++++++++++ drizzle/migrations.js | 12 +- package.json | 4 +- pnpm-lock.yaml | 120 ++- src/database/queries/ChapterQueries.ts | 235 ++++-- .../queries/__tests__/ChapterQueries.test.ts | 231 ++++++ src/database/queries/__tests__/testData.ts | 1 + src/database/queries/__tests__/testDb.ts | 7 +- src/database/schema/chapter.ts | 1 + src/database/types/index.ts | 1 + .../__tests__/bootstrapService.test.ts | 49 +- .../__tests__/novelStore.chapterState.test.ts | 1 + .../__tests__/useNovelSettings.test.ts | 5 +- .../useNovel/store-helper/bootstrapService.ts | 82 ++- .../useNovel/store-helper/persistence.ts | 7 + .../persisted/useNovel/store/createStore.ts | 11 +- .../useNovel/store/novelStore.actions.ts | 7 + .../store/novelStore.chapterActions.ts | 2 + .../useNovel/store/novelStore.chapterState.ts | 1 + .../useNovel/store/novelStore.types.ts | 1 + src/hooks/persisted/useNovel/types.ts | 2 + src/hooks/persisted/useNovelSettings.ts | 27 +- src/plugins/helpers/chapterPage.ts | 9 +- src/plugins/types/index.ts | 1 + src/screens/novel/components/ChapterItem.tsx | 24 +- .../novel/components/NovelBottomSheet.tsx | 225 +++++- .../reader/hooks/__tests__/useChapter.test.ts | 3 + src/screens/reader/hooks/useChapter.ts | 22 +- strings/languages/en/strings.json | 3 +- strings/languages/vi_VN/strings.json | 3 +- strings/types/index.ts | 1 + 35 files changed, 1613 insertions(+), 196 deletions(-) create mode 100644 drizzle/20260716072509_faithful_marvex/migration.sql create mode 100644 drizzle/20260716072509_faithful_marvex/snapshot.json diff --git a/.eslintrc.js b/.eslintrc.js index 7b26a45e2c..578a067560 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -88,7 +88,7 @@ module.exports = { 'spaced-comment': ['error', 'always', { markers: ['!'] }], 'no-inline-comments': 'warn', 'no-console': 'warn', - 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/exhaustive-deps': 'error', 'react-native/no-inline-styles': 'error', }, }, diff --git a/App.tsx b/App.tsx index e0f5406473..ebbd7d6dc1 100644 --- a/App.tsx +++ b/App.tsx @@ -8,7 +8,10 @@ import AppErrorBoundary, { } from '@components/AppErrorBoundary/AppErrorBoundary'; import { useInitDatabase } from '@database/db'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; -import { useLibrarySettings,useSecuritySettings } from '@hooks/persisted/useSettings'; +import { + useLibrarySettings, + useSecuritySettings, +} from '@hooks/persisted/useSettings'; import { ThemeProvider } from '@hooks/persisted/useTheme'; import { CloudflareSolverOverlay } from '@plugins/helpers/CloudflareSolverOverlay'; import { initLocalServer } from '@plugins/local/localServerManager'; @@ -48,7 +51,7 @@ const useScreenProtection = () => { useEffect(() => { try { - const {FlagSecure} = NativeModules; + const { FlagSecure } = NativeModules; if (!FlagSecure) { return; } @@ -84,11 +87,13 @@ const useCancelStuckBackupTasks = () => { ]; const hasStuckBackupTasks = taskList.some( - t => t?.task?.name && backupTasks.includes(t.task.name) + t => t?.task?.name && backupTasks.includes(t.task.name), ); if (hasStuckBackupTasks) { - backupTasks.forEach(name => ServiceManager.manager.removeTasksByName(name as any)); + backupTasks.forEach(name => + ServiceManager.manager.removeTasksByName(name as any), + ); showToast(getString('backupLogScreen.incompleteBackupCancelled')); } }, []); @@ -103,7 +108,7 @@ const AppContent = () => { useEffect(() => { const subscription = Notifications.addNotificationResponseReceivedListener( async response => { - const {data} = response.notification.request.content; + const { data } = response.notification.request.content; if (data?.action === 'open_update_error_log' && data?.filePath) { try { const cleanPath = (data.filePath as string).replace('file://', ''); @@ -147,6 +152,10 @@ const App = () => { } }, [state.success, state.error]); + if (!state.success && !state.error) { + return null; + } + return ( @@ -160,7 +169,10 @@ const App = () => { - + diff --git a/__mocks__/database.js b/__mocks__/database.js index 158a2e2724..425ce5dd2c 100644 --- a/__mocks__/database.js +++ b/__mocks__/database.js @@ -36,6 +36,8 @@ jest.mock('@database/queries/ChapterQueries', () => ({ getNovelChaptersSync: jest.fn(), getFirstUnreadChapter: jest.fn(), updateChapterProgress: jest.fn(), + getNovelScanlators: jest.fn(() => []), + getNovelScanlatorsSync: jest.fn(() => []), })); jest.mock('@database/queries/HistoryQueries', () => ({ diff --git a/drizzle/20260716072509_faithful_marvex/migration.sql b/drizzle/20260716072509_faithful_marvex/migration.sql new file mode 100644 index 0000000000..703bc844bd --- /dev/null +++ b/drizzle/20260716072509_faithful_marvex/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `Chapter` ADD `scanlator` text; diff --git a/drizzle/20260716072509_faithful_marvex/snapshot.json b/drizzle/20260716072509_faithful_marvex/snapshot.json new file mode 100644 index 0000000000..0fa2775d52 --- /dev/null +++ b/drizzle/20260716072509_faithful_marvex/snapshot.json @@ -0,0 +1,682 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "65dc845a-8bce-49d1-a4b6-f276371c1a38", + "prevIds": [ + "66b4f6d6-4519-4940-8f33-f24cb1b68f2f" + ], + "ddl": [ + { + "name": "Category", + "entityType": "tables" + }, + { + "name": "Chapter", + "entityType": "tables" + }, + { + "name": "LNReader_eXtended_Chapter_History", + "entityType": "tables" + }, + { + "name": "NovelCategory", + "entityType": "tables" + }, + { + "name": "Novel", + "entityType": "tables" + }, + { + "name": "Repository", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "Category" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "Category" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sort", + "entityType": "columns", + "table": "Category" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "novelId", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "releaseTime", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "bookmark", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "unread", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "readTime", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "isDownloaded", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedTime", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "chapterNumber", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'1'", + "generated": null, + "name": "page", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "position", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "progress", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scanlator", + "entityType": "columns", + "table": "Chapter" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "chapterId", + "entityType": "columns", + "table": "LNReader_eXtended_Chapter_History" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "readDuration", + "entityType": "columns", + "table": "LNReader_eXtended_Chapter_History" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "NovelCategory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "novelId", + "entityType": "columns", + "table": "NovelCategory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "categoryId", + "entityType": "columns", + "table": "NovelCategory" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pluginId", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cover", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "artist", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'Unknown'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "genres", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "inLibrary", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "isLocal", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "totalPages", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "chaptersDownloaded", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "chaptersUnread", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "totalChapters", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastReadAt", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastUpdatedAt", + "entityType": "columns", + "table": "Novel" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "Repository" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "Repository" + }, + { + "columns": [ + "chapterId" + ], + "tableTo": "Chapter", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_LNReader_eXtended_Chapter_History_chapterId_Chapter_id_fk", + "entityType": "fks", + "table": "LNReader_eXtended_Chapter_History" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "Category_pk", + "table": "Category", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "Chapter_pk", + "table": "Chapter", + "entityType": "pks" + }, + { + "columns": [ + "chapterId" + ], + "nameExplicit": false, + "name": "LNReader_eXtended_Chapter_History_pk", + "table": "LNReader_eXtended_Chapter_History", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "NovelCategory_pk", + "table": "NovelCategory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "Novel_pk", + "table": "Novel", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "Repository_pk", + "table": "Repository", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "name", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "category_name_unique", + "entityType": "indexes", + "table": "Category" + }, + { + "columns": [ + { + "value": "sort", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "category_sort_idx", + "entityType": "indexes", + "table": "Category" + }, + { + "columns": [ + { + "value": "novelId", + "isExpression": false + }, + { + "value": "path", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "chapter_novel_path_unique", + "entityType": "indexes", + "table": "Chapter" + }, + { + "columns": [ + { + "value": "novelId", + "isExpression": false + }, + { + "value": "position", + "isExpression": false + }, + { + "value": "page", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chapterNovelIdIndex", + "entityType": "indexes", + "table": "Chapter" + }, + { + "columns": [ + { + "value": "novelId", + "isExpression": false + }, + { + "value": "categoryId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "novel_category_unique", + "entityType": "indexes", + "table": "NovelCategory" + }, + { + "columns": [ + { + "value": "path", + "isExpression": false + }, + { + "value": "pluginId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "novel_path_plugin_unique", + "entityType": "indexes", + "table": "Novel" + }, + { + "columns": [ + { + "value": "pluginId", + "isExpression": false + }, + { + "value": "path", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + }, + { + "value": "inLibrary", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "NovelIndex", + "entityType": "indexes", + "table": "Novel" + }, + { + "columns": [ + { + "value": "url", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "repository_url_unique", + "entityType": "indexes", + "table": "Repository" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/drizzle/migrations.js b/drizzle/migrations.js index 6fde4e904a..45a5eb2436 100644 --- a/drizzle/migrations.js +++ b/drizzle/migrations.js @@ -1,11 +1,11 @@ -// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo - import m0000 from './20251222152612_past_mandrill/migration.sql'; import m0001 from './20260602175528_famous_serpent_society/migration.sql'; +import m0002 from './20260716072509_faithful_marvex/migration.sql'; export default { migrations: { - "20251222152612_past_mandrill": m0000, - "20260602175528_famous_serpent_society": m0001, - } -} \ No newline at end of file + '20251222152612_past_mandrill': m0000, + '20260602175528_famous_serpent_society': m0001, + '20260716072509_faithful_marvex': m0002, + }, +}; diff --git a/package.json b/package.json index 42fb8db48d..c64f7a73bf 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "cheerio": "1.0.0-rc.12", "color": "^5.0.3", "dayjs": "^1.11.20", - "drizzle-orm": "1.0.0-beta.20", + "drizzle-orm": "1.0.0-beta.22", "eslint-plugin-react-compiler": "19.1.0-rc.2", "eventemitter3": "^5.0.4", "expo": "^55.0.9", @@ -154,7 +154,7 @@ "babel-plugin-module-resolver": "^5.0.3", "babel-plugin-react-compiler": "^1.0.0", "better-sqlite3": "^12.9.0", - "drizzle-kit": "1.0.0-beta.20", + "drizzle-kit": "1.0.0-beta.22", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-eslint-comments": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3820b830b..64e209ff5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,8 +86,8 @@ importers: specifier: ^1.11.20 version: 1.11.20 drizzle-orm: - specifier: 1.0.0-beta.20 - version: 1.0.0-beta.20(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.9.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@3.25.76) + specifier: 1.0.0-beta.22 + version: 1.0.0-beta.22(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.9.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@3.25.76) eslint-plugin-react-compiler: specifier: 19.1.0-rc.2 version: 19.1.0-rc.2(eslint@8.57.1) @@ -351,8 +351,8 @@ importers: specifier: ^12.9.0 version: 12.9.0 drizzle-kit: - specifier: 1.0.0-beta.20 - version: 1.0.0-beta.20 + specifier: 1.0.0-beta.22 + version: 1.0.0-beta.22 eslint: specifier: ^8.57.1 version: 8.57.1 @@ -3033,12 +3033,12 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} - drizzle-kit@1.0.0-beta.20: - resolution: {integrity: sha512-qMUBnrOQIU+H32aF80BSn7lT1IQuKmofCypmkrKMuOMqvM0bhz5hjCHim1bLcXUzXYRtTSr6U2pe0MSV79WbAg==} + drizzle-kit@1.0.0-beta.22: + resolution: {integrity: sha512-9HTZuQRljQKTgCx4UhiGn8KYYfHGk4+B/bRR1714W67kz0qgJvdrG527i8rQD8uUyET9UTGR1u8syySJD4znGw==} hasBin: true - drizzle-orm@1.0.0-beta.20: - resolution: {integrity: sha512-7qiuw+Z6yGr+ywt3PS5dP6UCfdymIuFT/ni6GnPGzLhkBIolNBTo4ByMBWTxJ7dW/Ya6d73GtkeuKfcVcriVHA==} + drizzle-orm@1.0.0-beta.22: + resolution: {integrity: sha512-F+DZyVIvH0oVKa/w08Cle1xfoH+pc+htIXHG/frnMLG72aby9NYYr9oc+9XvghnoO4umxFItduz0OMmQJMnenw==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=4' @@ -3115,6 +3115,8 @@ packages: optional: true '@types/better-sqlite3': optional: true + '@types/mssql': + optional: true '@types/pg': optional: true '@types/sql.js': @@ -3135,6 +3137,8 @@ packages: optional: true gel: optional: true + mssql: + optional: true mysql2: optional: true pg: @@ -6643,10 +6647,12 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/abort-controller@2.1.2': dependencies: tslib: 2.8.1 + optional: true '@azure/core-auth@1.10.1': dependencies: @@ -6655,6 +6661,7 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/core-client@1.10.1': dependencies: @@ -6667,12 +6674,14 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/core-http-compat@2.3.2(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)': dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-client': 1.10.1 '@azure/core-rest-pipeline': 1.23.0 + optional: true '@azure/core-lro@2.7.2': dependencies: @@ -6682,10 +6691,12 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/core-paging@1.6.2': dependencies: tslib: 2.8.1 + optional: true '@azure/core-rest-pipeline@1.23.0': dependencies: @@ -6698,10 +6709,12 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/core-tracing@1.3.1': dependencies: tslib: 2.8.1 + optional: true '@azure/core-util@1.13.1': dependencies: @@ -6710,6 +6723,7 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/identity@4.13.1': dependencies: @@ -6726,6 +6740,7 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/keyvault-common@2.0.0': dependencies: @@ -6739,6 +6754,7 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1)': dependencies: @@ -6757,6 +6773,7 @@ snapshots: transitivePeerDependencies: - '@azure/core-client' - supports-color + optional: true '@azure/logger@1.3.0': dependencies: @@ -6764,18 +6781,22 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@azure/msal-browser@5.6.2': dependencies: '@azure/msal-common': 16.4.0 + optional: true - '@azure/msal-common@16.4.0': {} + '@azure/msal-common@16.4.0': + optional: true '@azure/msal-node@5.1.1': dependencies: '@azure/msal-common': 16.4.0 jsonwebtoken: 9.0.3 uuid: 8.3.2 + optional: true '@babel/code-frame@7.29.0': dependencies: @@ -8377,7 +8398,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@js-joda/core@5.7.0': {} + '@js-joda/core@5.7.0': + optional: true '@js-temporal/polyfill@0.5.1': dependencies: @@ -8947,7 +8969,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@tediousjs/connection-string@0.5.0': {} + '@tediousjs/connection-string@0.5.0': + optional: true '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.5.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: @@ -9039,6 +9062,7 @@ snapshots: transitivePeerDependencies: - '@azure/core-client' - supports-color + optional: true '@types/node@25.5.0': dependencies: @@ -9051,6 +9075,7 @@ snapshots: '@types/readable-stream@4.0.23': dependencies: '@types/node': 25.5.0 + optional: true '@types/retry@0.12.0': {} @@ -9166,6 +9191,7 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color + optional: true '@ungap/structured-clone@1.3.0': {} @@ -9554,6 +9580,7 @@ snapshots: buffer: 6.0.3 inherits: 2.0.4 readable-stream: 4.7.0 + optional: true body-parser@2.2.2: dependencies: @@ -9629,6 +9656,7 @@ snapshots: bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 + optional: true bytes@3.1.2: {} @@ -9821,7 +9849,8 @@ snapshots: command-exists@1.2.9: optional: true - commander@11.1.0: {} + commander@11.1.0: + optional: true commander@12.1.0: {} @@ -9986,12 +10015,14 @@ snapshots: deepmerge@4.3.1: {} - default-browser-id@5.0.1: {} + default-browser-id@5.0.1: + optional: true default-browser@5.5.0: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 + optional: true defaults@1.0.4: dependencies: @@ -10005,7 +10036,8 @@ snapshots: define-lazy-prop@2.0.0: {} - define-lazy-prop@3.0.0: {} + define-lazy-prop@3.0.0: + optional: true define-properties@1.2.1: dependencies: @@ -10075,7 +10107,7 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 - drizzle-kit@1.0.0-beta.20: + drizzle-kit@1.0.0-beta.22: dependencies: '@drizzle-team/brocli': 0.11.0 '@js-temporal/polyfill': 0.5.1 @@ -10083,16 +10115,15 @@ snapshots: get-tsconfig: 4.13.7 jiti: 2.6.1 - drizzle-orm@1.0.0-beta.20(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.9.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@3.25.76): - dependencies: - '@types/mssql': 9.1.9(@azure/core-client@1.10.1) - mssql: 11.0.1(@azure/core-client@1.10.1) + drizzle-orm@1.0.0-beta.22(@op-engineering/op-sqlite@15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@sinclair/typebox@0.34.49)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.9(@azure/core-client@1.10.1))(better-sqlite3@12.9.0)(expo-sqlite@16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(mssql@11.0.1(@azure/core-client@1.10.1))(zod@3.25.76): optionalDependencies: '@op-engineering/op-sqlite': 15.2.9(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) '@sinclair/typebox': 0.34.49 '@types/better-sqlite3': 7.6.13 + '@types/mssql': 9.1.9(@azure/core-client@1.10.1) better-sqlite3: 12.9.0 expo-sqlite: 16.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + mssql: 11.0.1(@azure/core-client@1.10.1) zod: 3.25.76 dunder-proto@1.0.1: @@ -11120,6 +11151,7 @@ snapshots: debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@5.0.1: dependencies: @@ -11247,7 +11279,8 @@ snapshots: is-docker@2.2.1: {} - is-docker@3.0.0: {} + is-docker@3.0.0: + optional: true is-extglob@2.1.1: {} @@ -11279,6 +11312,7 @@ snapshots: is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 + optional: true is-interactive@1.0.0: {} @@ -11353,6 +11387,7 @@ snapshots: is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 + optional: true isarray@2.0.5: {} @@ -11802,7 +11837,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 - js-md4@0.3.2: {} + js-md4@0.3.2: + optional: true js-tokens@4.0.0: {} @@ -11885,6 +11921,7 @@ snapshots: lodash.once: 4.1.1 ms: 2.1.3 semver: 7.7.4 + optional: true jsx-ast-utils@3.3.5: dependencies: @@ -12035,21 +12072,28 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.includes@4.3.0: {} + lodash.includes@4.3.0: + optional: true - lodash.isboolean@3.0.3: {} + lodash.isboolean@3.0.3: + optional: true - lodash.isinteger@4.0.4: {} + lodash.isinteger@4.0.4: + optional: true - lodash.isnumber@3.0.3: {} + lodash.isnumber@3.0.3: + optional: true - lodash.isplainobject@4.0.6: {} + lodash.isplainobject@4.0.6: + optional: true - lodash.isstring@4.0.1: {} + lodash.isstring@4.0.1: + optional: true lodash.merge@4.6.2: {} - lodash.once@4.1.1: {} + lodash.once@4.1.1: + optional: true lodash.throttle@4.1.1: {} @@ -12545,6 +12589,7 @@ snapshots: transitivePeerDependencies: - '@azure/core-client' - supports-color + optional: true multitars@0.2.4: {} @@ -12554,7 +12599,8 @@ snapshots: napi-build-utils@2.0.0: {} - native-duplexpair@1.0.0: {} + native-duplexpair@1.0.0: + optional: true natural-compare@1.4.0: {} @@ -12687,6 +12733,7 @@ snapshots: define-lazy-prop: 3.0.0 is-inside-container: 1.0.0 wsl-utils: 0.1.0 + optional: true open@6.4.0: dependencies: @@ -13454,7 +13501,8 @@ snapshots: rtl-detect@1.1.2: {} - run-applescript@7.1.0: {} + run-applescript@7.1.0: + optional: true run-parallel@1.2.0: dependencies: @@ -13682,7 +13730,8 @@ snapshots: sprintf-js@1.0.3: {} - sprintf-js@1.1.3: {} + sprintf-js@1.1.3: + optional: true stack-generator@2.0.10: dependencies: @@ -13872,7 +13921,8 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tarn@3.0.2: {} + tarn@3.0.2: + optional: true tedious@18.6.2(@azure/core-client@1.10.1): dependencies: @@ -13889,6 +13939,7 @@ snapshots: transitivePeerDependencies: - '@azure/core-client' - supports-color + optional: true tedious@19.2.1(@azure/core-client@1.10.1): dependencies: @@ -13905,6 +13956,7 @@ snapshots: transitivePeerDependencies: - '@azure/core-client' - supports-color + optional: true terminal-link@2.1.1: dependencies: @@ -14099,7 +14151,8 @@ snapshots: uuid@7.0.3: {} - uuid@8.3.2: {} + uuid@8.3.2: + optional: true v8-to-istanbul@9.3.0: dependencies: @@ -14234,6 +14287,7 @@ snapshots: wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 + optional: true x-path@0.0.2: dependencies: diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index 3111c06f34..09b37c358a 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -21,9 +21,12 @@ import { gt, inArray, isNotNull, + isNull, like, lt, lte, + notInArray, + or, sql, } from 'drizzle-orm'; @@ -49,15 +52,26 @@ export const insertChapters = async ( const nowSql = sql`datetime('now','localtime')`; - const rows = chapters.map((c, index) => ({ - path: c.path, - name: c.name || `Chapter ${index + 1}`, - releaseTime: c.releaseTime ?? (options?.preferNullReleaseTime ? null : ''), - novelId, - chapterNumber: c.chapterNumber ?? index + 1, - page: options?.page ?? c.page ?? '1', - position: index, - })); + const rows = chapters.map((c, index) => { + let scanlatorStr: string | null = null; + if (c.scanlator) { + scanlatorStr = Array.isArray(c.scanlator) + ? c.scanlator.filter(Boolean).join(', ') + : c.scanlator; + } + + return { + path: c.path, + name: c.name || `Chapter ${index + 1}`, + releaseTime: + c.releaseTime ?? (options?.preferNullReleaseTime ? null : ''), + novelId, + chapterNumber: c.chapterNumber ?? index + 1, + page: options?.page ?? c.page ?? '1', + position: index, + scanlator: scanlatorStr, + }; + }); await dbManager.batch(rows, (tx, ph) => tx .insert(chapterSchema) @@ -69,6 +83,7 @@ export const insertChapters = async ( chapterNumber: ph('chapterNumber'), page: ph('page'), position: ph('position'), + scanlator: ph('scanlator'), ...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}), }) .onConflictDoUpdate({ @@ -79,6 +94,7 @@ export const insertChapters = async ( name: sql`excluded.name`, releaseTime: sql`excluded.releaseTime`, chapterNumber: sql`excluded.chapterNumber`, + scanlator: sql`excluded.scanlator`, ...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}), }, where: sql`NOT ( @@ -87,6 +103,7 @@ export const insertChapters = async ( AND ${chapterSchema.name} IS excluded.name AND ${chapterSchema.releaseTime} IS excluded.releaseTime AND ${chapterSchema.chapterNumber} IS excluded.chapterNumber + AND ${chapterSchema.scanlator} IS excluded.scanlator )`, }) .prepare(), @@ -384,23 +401,36 @@ export const getCustomPages = (novelId: number) => { ); }; +const scanlatorFilterToSQL = (excludedScanlators?: string[]) => { + if (!excludedScanlators || excludedScanlators.length === 0) { + return sql.raw('true'); + } + return or( + isNull(chapterSchema.scanlator), + eq(chapterSchema.scanlator, ''), + notInArray(chapterSchema.scanlator, excludedScanlators), + ); +}; + export const getNovelChapters = async ( novelId: number, sort?: ChapterOrderKey, filter?: ChapterFilterKey[], page?: string, limit: number = -1, + excludedScanlators?: string[], ): Promise => { + const conditions = [ + eq(chapterSchema.novelId, novelId), + !page ? sql.raw('true') : eq(chapterSchema.page, page), + chapterFilterToSQL(filter), + scanlatorFilterToSQL(excludedScanlators), + ]; + const query = dbManager .select() .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - !page ? sql.raw('true') : eq(chapterSchema.page, page), - chapterFilterToSQL(filter), - ), - ) + .where(and(...conditions)) .orderBy(chapterOrderToSQL(sort)); if (limit > 0) { query.limit(limit); @@ -414,17 +444,19 @@ export const getNovelChaptersSync = ( filter?: ChapterFilterKey[], page?: string, limit: number = 300, + excludedScanlators?: string[], ): ChapterInfo[] => { + const conditions = [ + eq(chapterSchema.novelId, novelId), + !page ? sql.raw('true') : eq(chapterSchema.page, page), + chapterFilterToSQL(filter), + scanlatorFilterToSQL(excludedScanlators), + ]; + const query = dbManager .select() .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - !page ? sql.raw('true') : eq(chapterSchema.page, page), - chapterFilterToSQL(filter), - ), - ) + .where(and(...conditions)) .orderBy(chapterOrderToSQL(sort)); if (limit > 0) { query.limit(limit); // Adding a limit to prevent potential performance issues with large datasets @@ -494,17 +526,18 @@ export const getPageChapters = async ( page?: string, offset?: number, limit?: number, + excludedScanlators?: string[], ): Promise => { + const conditions = [ + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page || '1'), + chapterFilterToSQL(filter), + scanlatorFilterToSQL(excludedScanlators), + ]; const query = dbManager .select() .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page || '1'), - chapterFilterToSQL(filter), - ), - ) + .where(and(...conditions)) .$dynamic(); if (sort) { @@ -524,33 +557,37 @@ export const getChapterCount = async ( novelId: number, page: string = '1', filter?: ChapterFilterKey[], -) => - await dbManager.$count( - chapterSchema, - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page), - chapterFilterToSQL(filter), - ), - ); + excludedScanlators?: string[], +) => { + const conditions = [ + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page), + chapterFilterToSQL(filter), + scanlatorFilterToSQL(excludedScanlators), + ]; + + return await dbManager.$count(chapterSchema, and(...conditions)); +}; export const getChapterCountSync = ( novelId: number, page: string = '1', filter?: ChapterFilterKey[], + excludedScanlators?: string[], ): number => { + const conditions = [ + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page), + chapterFilterToSQL(filter), + scanlatorFilterToSQL(excludedScanlators), + ]; + // Using count(*) as name because the current drizzle version generates wrong type const result = dbManager.getSync( dbManager .select({ 'count(*)': count() }) .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page), - chapterFilterToSQL(filter), - ), - ), + .where(and(...conditions)), ); return result?.['count(*)'] ?? 0; @@ -562,19 +599,21 @@ export const getPageChaptersBatched = async ( filter?: ChapterFilterKey[], page?: string, batch: number = 0, + excludedScanlators?: string[], ) => { + const conditions = [ + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page || '1'), + chapterFilterToSQL(filter), + scanlatorFilterToSQL(excludedScanlators), + ]; + const limit = 300; const offset = 300 * batch; const query = dbManager .select() .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page || '1'), - chapterFilterToSQL(filter), - ), - ) + .where(and(...conditions)) .limit(limit) .offset(offset) .$dynamic(); @@ -605,22 +644,25 @@ export const getFirstUnreadChapter = ( novelId: number, filter?: ChapterFilterKey[], page?: string, -) => - dbManager.getSync( + excludedScanlators?: string[], +) => { + const conditions = [ + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page || '1'), + eq(chapterSchema.unread, true), + chapterFilterToSQL(filter), + scanlatorFilterToSQL(excludedScanlators), + ]; + + return dbManager.getSync( dbManager .select() .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page || '1'), - eq(chapterSchema.unread, true), - chapterFilterToSQL(filter), - ), - ) + .where(and(...conditions)) .orderBy(asc(chapterSchema.position)) .limit(1), ); +}; export const getNovelChaptersByName = async ( novelId: number, @@ -642,18 +684,20 @@ export const getPrevChapter = async ( novelId: number, chapterPosition: number, page: string, + excludedScanlators?: string[], ): Promise => { // First try: same page, lower position + const conditions = [ + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page), + lt(chapterSchema.position, chapterPosition), + scanlatorFilterToSQL(excludedScanlators), + ]; + const samePage = await dbManager .select() .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page), - lt(chapterSchema.position, chapterPosition), - ), - ) + .where(and(...conditions)) .orderBy(desc(chapterSchema.position)) .limit(1) .get(); @@ -695,6 +739,7 @@ export const getPrevChapter = async ( and( eq(chapterSchema.novelId, novelId), eq(chapterSchema.page, prevPageRow.page!), + scanlatorFilterToSQL(excludedScanlators), ), ) .orderBy(desc(chapterSchema.position)) @@ -706,18 +751,20 @@ export const getNextChapter = async ( novelId: number, chapterPosition: number, page: string, + excludedScanlators?: string[], ): Promise => { // First try: same page, higher position + const conditions = [ + eq(chapterSchema.novelId, novelId), + eq(chapterSchema.page, page), + gt(chapterSchema.position, chapterPosition), + scanlatorFilterToSQL(excludedScanlators), + ]; + const samePage = await dbManager .select() .from(chapterSchema) - .where( - and( - eq(chapterSchema.novelId, novelId), - eq(chapterSchema.page, page), - gt(chapterSchema.position, chapterPosition), - ), - ) + .where(and(...conditions)) .orderBy(asc(chapterSchema.position)) .limit(1) .get(); @@ -759,6 +806,7 @@ export const getNextChapter = async ( and( eq(chapterSchema.novelId, novelId), eq(chapterSchema.page, nextPageRow.page!), + scanlatorFilterToSQL(excludedScanlators), ), ) .orderBy(asc(chapterSchema.position)) @@ -903,3 +951,36 @@ export const isChapterDownloaded = (chapterId: number): boolean => { return !!result; }; + +export const getNovelScanlators = async ( + novelId: number, +): Promise => { + const result = await dbManager + .selectDistinct({ scanlator: chapterSchema.scanlator }) + .from(chapterSchema) + .where( + and( + eq(chapterSchema.novelId, novelId), + isNotNull(chapterSchema.scanlator), + sql`${chapterSchema.scanlator} != ''`, + ), + ) + .all(); + return result.map(r => r.scanlator).filter(Boolean) as string[]; +}; + +export const getNovelScanlatorsSync = (novelId: number): string[] => { + const result = dbManager.allSync( + dbManager + .selectDistinct({ scanlator: chapterSchema.scanlator }) + .from(chapterSchema) + .where( + and( + eq(chapterSchema.novelId, novelId), + isNotNull(chapterSchema.scanlator), + sql`${chapterSchema.scanlator} != ''`, + ), + ), + ); + return result.map(r => r.scanlator).filter(Boolean) as string[]; +}; diff --git a/src/database/queries/__tests__/ChapterQueries.test.ts b/src/database/queries/__tests__/ChapterQueries.test.ts index a05309ca2f..6217f8ef64 100644 --- a/src/database/queries/__tests__/ChapterQueries.test.ts +++ b/src/database/queries/__tests__/ChapterQueries.test.ts @@ -19,12 +19,16 @@ import { getAllUndownloadedChapters, getChapter, getChapterCount, + getChapterCountSync, getCustomPages, getDetailedUpdatesFromDb, getDownloadedChapters, + getFirstUnreadChapter, getNextChapter, getNovelChapters, getNovelDownloadedChapters, + getNovelScanlators, + getNovelScanlatorsSync, getPageChapters, getPageChaptersBatched, getPrevChapter, @@ -257,6 +261,29 @@ describe('ChapterQueries', () => { expect(chapters[0].position).toBe(0); }); + it('should insert scanlator string and array correctly', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + + await insertChapters(novelId, [ + { + path: '/chapter/1', + name: 'Chapter 1', + scanlator: 'Translator Team A', + }, + { + path: '/chapter/2', + name: 'Chapter 2', + scanlator: ['Group X', 'Group Y', ''], + }, + ]); + + const chapters = await getNovelChapters(novelId); + expect(chapters).toHaveLength(2); + expect(chapters[0].scanlator).toBe('Translator Team A'); + expect(chapters[1].scanlator).toBe('Group X, Group Y'); + }); + it('should insert multiple chapters', async () => { const testDb = getTestDb(); const novelId = await insertTestNovel(testDb, { inLibrary: true }); @@ -951,6 +978,41 @@ describe('ChapterQueries', () => { expect(result?.id).toBe(chapterId2); }); + + it('should exclude chapters with scanlators in excludedScanlators list', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + const chapterId1 = await insertTestChapter(testDb, novelId, { + page: '1', + position: 0, + scanlator: 'Scan A', + }); + const chapterId2 = await insertTestChapter(testDb, novelId, { + page: '1', + position: 1, + scanlator: 'Scan B', + }); + await insertTestChapter(testDb, novelId, { + page: '1', + position: 2, + scanlator: 'Scan C', + }); + + // From chapter 3, previous should be chapter 2 (Scan B) if nothing excluded + const resultNormal = await getPrevChapter(novelId, 2, '1'); + expect(resultNormal?.id).toBe(chapterId2); + + // From chapter 3, previous should be chapter 1 (Scan A) if Scan B is excluded + const resultExcludeB = await getPrevChapter(novelId, 2, '1', ['Scan B']); + expect(resultExcludeB?.id).toBe(chapterId1); + + // From chapter 3, previous should be undefined if both Scan B and Scan A are excluded + const resultExcludeBoth = await getPrevChapter(novelId, 2, '1', [ + 'Scan B', + 'Scan A', + ]); + expect(resultExcludeBoth).toBeUndefined(); + }); }); describe('getNextChapter', () => { @@ -1028,6 +1090,65 @@ describe('ChapterQueries', () => { expect(result?.id).toBe(chapterId2); }); + + it('should exclude chapters with scanlators in excludedScanlators list', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + await insertTestChapter(testDb, novelId, { + page: '1', + position: 0, + scanlator: 'Scan A', + }); + const chapterId2 = await insertTestChapter(testDb, novelId, { + page: '1', + position: 1, + scanlator: 'Scan B', + }); + const chapterId3 = await insertTestChapter(testDb, novelId, { + page: '1', + position: 2, + scanlator: 'Scan C', + }); + + // From chapter 1, next should be chapter 2 (Scan B) if nothing excluded + const resultNormal = await getNextChapter(novelId, 0, '1'); + expect(resultNormal?.id).toBe(chapterId2); + + // From chapter 1, next should be chapter 3 (Scan C) if Scan B is excluded + const resultExcludeB = await getNextChapter(novelId, 0, '1', ['Scan B']); + expect(resultExcludeB?.id).toBe(chapterId3); + + // From chapter 1, next should be undefined if both Scan B and Scan C are excluded + const resultExcludeBoth = await getNextChapter(novelId, 0, '1', [ + 'Scan B', + 'Scan C', + ]); + expect(resultExcludeBoth).toBeUndefined(); + }); + + it('should cross page boundaries when the first chapter of the next page is excluded', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + await insertTestChapter(testDb, novelId, { + page: '1', + position: 0, + }); + // Page 2, position 0 is excluded scanlator + await insertTestChapter(testDb, novelId, { + page: '2', + position: 0, + scanlator: 'Scan A', + }); + // Page 2, position 1 is not excluded + const nextPageChapter1 = await insertTestChapter(testDb, novelId, { + page: '2', + position: 1, + scanlator: 'Scan B', + }); + + const result = await getNextChapter(novelId, 0, '1', ['Scan A']); + expect(result?.id).toBe(nextPageChapter1); + }); }); describe('getDownloadedChapters', () => { @@ -1249,6 +1370,116 @@ describe('ChapterQueries', () => { }); }); + describe('getFirstUnreadChapter', () => { + it('should return first unread chapter', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + await insertTestChapter(testDb, novelId, { unread: false, position: 0 }); + const chapterId = await insertTestChapter(testDb, novelId, { + unread: true, + position: 1, + }); + await insertTestChapter(testDb, novelId, { unread: true, position: 2 }); + + const result = getFirstUnreadChapter(novelId); + expect(result).toBeDefined(); + expect(result?.id).toBe(chapterId); + }); + + it('should filter out excluded scanlators', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + await insertTestChapter(testDb, novelId, { + unread: true, + position: 0, + scanlator: 'Scan A', + }); + const chapterId = await insertTestChapter(testDb, novelId, { + unread: true, + position: 1, + scanlator: 'Scan B', + }); + + const result = getFirstUnreadChapter(novelId, undefined, undefined, [ + 'Scan A', + ]); + expect(result).toBeDefined(); + expect(result?.id).toBe(chapterId); + }); + }); + + describe('getChapterCount and getChapterCountSync with excluded scanlators', () => { + it('should exclude chapters with scanlators in excludedScanlators list', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + await insertTestChapter(testDb, novelId, { + scanlator: 'Scan A', + position: 0, + }); + await insertTestChapter(testDb, novelId, { + scanlator: 'Scan B', + position: 1, + }); + await insertTestChapter(testDb, novelId, { + scanlator: null, + position: 2, + }); + await insertTestChapter(testDb, novelId, { scanlator: '', position: 3 }); + + // Count without exclusions should be 4 + const countAll = await getChapterCount(novelId); + expect(countAll).toBe(4); + const countAllSync = getChapterCountSync(novelId); + expect(countAllSync).toBe(4); + + // Exclude 'Scan A' -> count should be 3 (Scan B, null, '') + const countExcludeA = await getChapterCount(novelId, '1', undefined, [ + 'Scan A', + ]); + expect(countExcludeA).toBe(3); + const countExcludeASync = getChapterCountSync(novelId, '1', undefined, [ + 'Scan A', + ]); + expect(countExcludeASync).toBe(3); + + // Exclude 'Scan A' and 'Scan B' -> count should be 2 (null, '') + const countExcludeBoth = await getChapterCount(novelId, '1', undefined, [ + 'Scan A', + 'Scan B', + ]); + expect(countExcludeBoth).toBe(2); + const countExcludeBothSync = getChapterCountSync( + novelId, + '1', + undefined, + ['Scan A', 'Scan B'], + ); + expect(countExcludeBothSync).toBe(2); + }); + }); + + describe('getNovelScanlators', () => { + it('should return unique scanlators', async () => { + const testDb = getTestDb(); + const novelId = await insertTestNovel(testDb, { inLibrary: true }); + await insertTestChapter(testDb, novelId, { scanlator: 'Scan A' }); + await insertTestChapter(testDb, novelId, { scanlator: 'Scan B' }); + await insertTestChapter(testDb, novelId, { scanlator: 'Scan A' }); // Duplicate + await insertTestChapter(testDb, novelId, { scanlator: '' }); // Empty + await insertTestChapter(testDb, novelId, { scanlator: null }); // Null + + const scanlators = await getNovelScanlators(novelId); + expect(scanlators).toHaveLength(2); + expect(scanlators).toContain('Scan A'); + expect(scanlators).toContain('Scan B'); + + const scanlatorsSync = getNovelScanlatorsSync(novelId); + expect(scanlatorsSync).toHaveLength(2); + expect(scanlatorsSync).toContain('Scan A'); + expect(scanlatorsSync).toContain('Scan B'); + }); + }); + describe('deleteAllReadingTime', () => { it('should delete all reading time records', async () => { const testDb = getTestDb(); diff --git a/src/database/queries/__tests__/testData.ts b/src/database/queries/__tests__/testData.ts index e308611d37..653d065548 100644 --- a/src/database/queries/__tests__/testData.ts +++ b/src/database/queries/__tests__/testData.ts @@ -92,6 +92,7 @@ export async function insertTestChapter( page: '1', position: 0, progress: null, + scanlator: null, ...data, novelId, }; diff --git a/src/database/queries/__tests__/testDb.ts b/src/database/queries/__tests__/testDb.ts index 4702b08998..ef772b672f 100644 --- a/src/database/queries/__tests__/testDb.ts +++ b/src/database/queries/__tests__/testDb.ts @@ -19,8 +19,8 @@ import { open } from '@op-engineering/op-sqlite'; import { drizzle } from 'drizzle-orm/op-sqlite'; import { platform } from 'os'; -// SQL migration from drizzle/0000_past_mandrill.sql -// SQLite uses double quotes or no quotes for identifiers, not backticks +// Initial SQL migration +// // SQLite uses double quotes or no quotes for identifiers, not backticks const MIGRATION_STATEMENTS = [ `CREATE TABLE IF NOT EXISTS Category ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -43,7 +43,8 @@ const MIGRATION_STATEMENTS = [ chapterNumber real, page text DEFAULT '1', position integer DEFAULT 0, - progress integer + progress integer, + scanlator text )`, `CREATE UNIQUE INDEX IF NOT EXISTS chapter_novel_path_unique ON Chapter (novelId, path)`, `CREATE INDEX IF NOT EXISTS chapterNovelIdIndex ON Chapter (novelId, position, page, id)`, diff --git a/src/database/schema/chapter.ts b/src/database/schema/chapter.ts index 239bac4902..26fdf2ea96 100644 --- a/src/database/schema/chapter.ts +++ b/src/database/schema/chapter.ts @@ -24,6 +24,7 @@ export const chapter = sqliteTable( page: text('page').default('1'), position: integer('position').default(0), progress: integer('progress'), + scanlator: text('scanlator'), }, table => [ uniqueIndex('chapter_novel_path_unique').on(table.novelId, table.path), diff --git a/src/database/types/index.ts b/src/database/types/index.ts index 5eb4624c84..8e4059b3d6 100644 --- a/src/database/types/index.ts +++ b/src/database/types/index.ts @@ -39,6 +39,7 @@ export interface ChapterInfo { page: string | null; progress: number | null; position?: number | null; + scanlator?: string | null; } export interface ExtendedChapterHistory { diff --git a/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts b/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts index 38ece71d9a..a9d863dd4a 100644 --- a/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts +++ b/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts @@ -55,6 +55,7 @@ const makeChapter = (id: number, overrides: Partial = {}) => ({ progress: 0, page: '1', position: id, + scanlator: null, ...overrides, releaseTime: overrides.releaseTime || '2024-01-01', }); @@ -145,6 +146,7 @@ describe('bootstrapService', () => { mockNovel.id, '1', settingsFilter, + undefined, ); }); @@ -178,6 +180,9 @@ describe('bootstrapService', () => { settingsSort, settingsFilter, '1', + undefined, + undefined, + undefined, ); expect(result.batchInformation.totalChapters).toBe(mockChapters.length); }); @@ -280,12 +285,15 @@ describe('bootstrapService', () => { mockNovel.id, '3', settingsFilter, + undefined, ); expect(mockGetPageChaptersBatched).toHaveBeenCalledWith( mockNovel.id, settingsSort, settingsFilter, '3', + 0, + undefined, ); }); @@ -308,6 +316,7 @@ describe('bootstrapService', () => { settingsFilter, '1', 1, + undefined, ); expect(result).toEqual({ batch: 1, @@ -382,9 +391,12 @@ describe('bootstrapService', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.batchInformation.totalChapters).toBe(2); - expect(mockGetChapterCountSync).toHaveBeenCalledWith(mockNovel.id, '1', [ - 'not-read', - ]); + expect(mockGetChapterCountSync).toHaveBeenCalledWith( + mockNovel.id, + '1', + ['not-read'], + undefined, + ); }); it('bootstrapNovelSync returns missing-chapters only when unfiltered count is zero', () => { @@ -412,4 +424,35 @@ describe('bootstrapService', () => { }); expect(filtered.ok).toBe(true); }); + + it('bootstrapNovelSync passes excludedScanlators down to queries', () => { + setupDbFirstSuccess(); + const service = createBootstrapService(); + + const result = service.bootstrapNovelSync({ + novel: mockNovel, + novelPath: NOVEL_PATH, + pluginId: PLUGIN_ID, + pageIndex: 0, + settingsSort, + settingsFilter: [], + excludedScanlators: ['Scan A'], + }); + + expect(result.ok).toBe(true); + expect(mockGetChapterCountSync).toHaveBeenCalledWith( + mockNovel.id, + '1', + [], + ['Scan A'], + ); + expect(mockGetNovelChaptersSync).toHaveBeenCalledWith( + mockNovel.id, + settingsSort, + [], + '1', + 300, + ['Scan A'], + ); + }); }); diff --git a/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts index b202c00525..39b1fe454b 100644 --- a/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts +++ b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts @@ -6,6 +6,7 @@ describe('novelStore.chapterState', () => { chapters: [], firstUnreadChapter: undefined, chapterTextCache: {}, + scanlators: [], batchInformation: { batch: 0, total: 0, diff --git a/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts b/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts index ac3d94eaa1..87ca3028b0 100644 --- a/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts +++ b/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts @@ -62,11 +62,12 @@ describe('useNovelSettings', () => { showChapterTitles: true, sort: 'nameDesc', filter: ['read'], + excludedScanlators: [], }); expect(storeSetNovelSettings).toHaveBeenCalledTimes(1); }); - it('falls back to app default sort and persists it when changing filter', async () => { + it('persists changes when changing filter', async () => { const storeSetNovelSettings = jest.fn(); const storeNovelSettings = { filter: ['read'], @@ -93,8 +94,8 @@ describe('useNovelSettings', () => { expect(storeSetNovelSettings).toHaveBeenCalledWith({ showChapterTitles: true, - sort: 'positionAsc', filter: ['downloaded'], + excludedScanlators: [], }); }); diff --git a/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts b/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts index b8b78fba7d..1c4e91591e 100644 --- a/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts +++ b/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts @@ -5,6 +5,7 @@ import { getCustomPages as defaultGetCustomPages, getFirstUnreadChapter as defaultGetFirstUnreadChapter, getNovelChaptersSync as defaultGetNovelChaptersSync, + getNovelScanlatorsSync as defaultGetNovelScanlatorsSync, getPageChapters as defaultGetPageChapters, getPageChaptersBatched as defaultGetPageChaptersBatched, insertChapters as defaultInsertChapters, @@ -33,6 +34,7 @@ export interface BootstrapSuccessResult extends ChapterLoadResult { ok: true; novel: NovelInfo; pages: string[]; + scanlators: string[]; } export interface BootstrapFailureResult { @@ -63,6 +65,7 @@ const defaultBootstrapServiceDependencies = { insertChapters: defaultInsertChapters, getPageChapters: defaultGetPageChapters, getFirstUnreadChapter: defaultGetFirstUnreadChapter, + getNovelScanlatorsSync: defaultGetNovelScanlatorsSync, getString: defaultGetString, } as const; export type BootstrapServiceDependencies = @@ -123,6 +126,7 @@ export const createBootstrapService = ( pageIndex, settingsSort, settingsFilter, + excludedScanlators, }: { novel: NovelInfo; novelPath: string; @@ -131,19 +135,28 @@ export const createBootstrapService = ( pageIndex: number; settingsSort: ChapterOrderKey; settingsFilter: ChapterFilterKey[]; + excludedScanlators?: string[]; }): Promise => { const page = pages[pageIndex]; let newChapters: ChapterInfo[] = []; - const config = [novel.id, settingsSort, settingsFilter, page] as const; let chapterCount = await deps.getChapterCount( novel.id, page, settingsFilter, + excludedScanlators, ); if (chapterCount) { try { - newChapters = (await deps.getPageChaptersBatched(...config)) || []; + newChapters = + (await deps.getPageChaptersBatched( + novel.id, + settingsSort, + settingsFilter, + page, + 0, + excludedScanlators, + )) || []; } catch { newChapters = []; } @@ -156,8 +169,21 @@ export const createBootstrapService = ( }; }); await deps.insertChapters(novel.id, sourceChapters); - newChapters = await deps.getPageChapters(...config); - chapterCount = await deps.getChapterCount(novel.id, page, settingsFilter); + newChapters = await deps.getPageChapters( + novel.id, + settingsSort, + settingsFilter, + page, + undefined, + undefined, + excludedScanlators, + ); + chapterCount = await deps.getChapterCount( + novel.id, + page, + settingsFilter, + excludedScanlators, + ); } const batchInformation: BatchInfo = { @@ -165,7 +191,12 @@ export const createBootstrapService = ( total: Math.max(0, Math.ceil(chapterCount / 300) - 1), totalChapters: chapterCount, }; - const unread = deps.getFirstUnreadChapter(novel.id, settingsFilter, page); + const unread = deps.getFirstUnreadChapter( + novel.id, + settingsFilter, + page, + excludedScanlators, + ); return { chapters: newChapters, batchInformation, @@ -180,6 +211,7 @@ export const createBootstrapService = ( settingsSort, settingsFilter, batchInformation, + excludedScanlators, }: { novel: NovelInfo | undefined; pages: string[]; @@ -187,6 +219,7 @@ export const createBootstrapService = ( settingsSort: ChapterOrderKey; settingsFilter: ChapterFilterKey[]; batchInformation: BatchInfo; + excludedScanlators?: string[]; }) => { const page = pages[pageIndex]; const nextBatch = batchInformation.batch + 1; @@ -203,6 +236,7 @@ export const createBootstrapService = ( settingsFilter, page, nextBatch, + excludedScanlators, )) || []; } catch { newChapters = []; @@ -223,6 +257,7 @@ export const createBootstrapService = ( settingsFilter, batchInformation, onBatchLoaded, + excludedScanlators, }: { targetBatch: number; novel: NovelInfo | undefined; @@ -232,6 +267,7 @@ export const createBootstrapService = ( settingsFilter: ChapterFilterKey[]; batchInformation: BatchInfo; onBatchLoaded: (batch: number, chapters: ChapterInfo[]) => void; + excludedScanlators?: string[]; }) => { const page = pages[pageIndex] ?? '1'; if (!novel || !page || targetBatch <= batchInformation.batch) { @@ -254,6 +290,7 @@ export const createBootstrapService = ( settingsFilter, page, batch, + excludedScanlators, )) || []; } catch { newChapters = []; @@ -270,6 +307,7 @@ export const createBootstrapService = ( pageIndex, settingsSort, settingsFilter, + excludedScanlators, }: { novel: NovelInfo | undefined; novelPath: string; @@ -277,6 +315,7 @@ export const createBootstrapService = ( pageIndex: number; settingsSort: ChapterOrderKey; settingsFilter: ChapterFilterKey[]; + excludedScanlators?: string[]; }): Promise => { const key = getBootstrapKey(pluginId, novelPath); const existing = inflightBootstraps.get(key); @@ -305,12 +344,16 @@ export const createBootstrapService = ( pageIndex, settingsSort, settingsFilter, + excludedScanlators, }); + const scanlators = deps.getNovelScanlatorsSync(resolvedNovel.id); + return { ok: true, novel: resolvedNovel, pages, + scanlators, ...chapterState, } satisfies BootstrapSuccessResult; } catch (error) { @@ -335,6 +378,7 @@ export const createBootstrapService = ( pageIndex, settingsSort, settingsFilter, + excludedScanlators, }: { novel: NovelInfo | undefined; novelPath: string; @@ -342,6 +386,7 @@ export const createBootstrapService = ( pageIndex: number; settingsSort: ChapterOrderKey; settingsFilter: ChapterFilterKey[]; + excludedScanlators?: string[]; }): BootstrapResult => { try { const novel = !_novel?.id @@ -357,9 +402,16 @@ export const createBootstrapService = ( const pages = calculatePages(novel); const page = pages[pageIndex] ?? '1'; const chapterCount = - settingsFilter.length === 0 && pages.length === 1 + settingsFilter.length === 0 && + pages.length === 1 && + (!excludedScanlators || excludedScanlators.length === 0) ? novel.totalChapters ?? 0 - : deps.getChapterCountSync(novel.id, page, settingsFilter); + : deps.getChapterCountSync( + novel.id, + page, + settingsFilter, + excludedScanlators, + ); if (chapterCount === 0 && settingsFilter.length === 0) { return { ok: false, @@ -367,22 +419,27 @@ export const createBootstrapService = ( } satisfies BootstrapFailureResult; } - const config = [ + const newChapters = deps.getNovelChaptersSync( novel.id, settingsSort, settingsFilter, page, 300, - ] as const; - - const newChapters = deps.getNovelChaptersSync(...config); + excludedScanlators, + ); const batchInformation: BatchInfo = { batch: 0, total: Math.max(0, Math.ceil(chapterCount / 300) - 1), totalChapters: chapterCount, }; - const unread = deps.getFirstUnreadChapter(novel.id, settingsFilter, page); + const unread = deps.getFirstUnreadChapter( + novel.id, + settingsFilter, + page, + excludedScanlators, + ); + const scanlators = deps.getNovelScanlatorsSync(novel.id); return { ok: true, @@ -391,6 +448,7 @@ export const createBootstrapService = ( chapters: newChapters, batchInformation, firstUnreadChapter: unread ?? undefined, + scanlators, } satisfies BootstrapSuccessResult; } catch (error) { const dbNovel = !_novel?.id diff --git a/src/hooks/persisted/useNovel/store-helper/persistence.ts b/src/hooks/persisted/useNovel/store-helper/persistence.ts index b9dbea7bf6..fe7426523b 100644 --- a/src/hooks/persisted/useNovel/store-helper/persistence.ts +++ b/src/hooks/persisted/useNovel/store-helper/persistence.ts @@ -51,6 +51,13 @@ const isValidNovelSettings = (value: unknown): value is NovelSettings => { ) { return false; } + if ( + value.excludedScanlators !== undefined && + (!Array.isArray(value.excludedScanlators) || + !value.excludedScanlators.every(s => typeof s === 'string')) + ) { + return false; + } return true; }; diff --git a/src/hooks/persisted/useNovel/store/createStore.ts b/src/hooks/persisted/useNovel/store/createStore.ts index e895ece612..632a53ddab 100644 --- a/src/hooks/persisted/useNovel/store/createStore.ts +++ b/src/hooks/persisted/useNovel/store/createStore.ts @@ -42,10 +42,18 @@ export function createStore({ }; const bootstrapService = createBootstrapService(); + let storeRef: { getState: () => NovelStoreState } | null = null; const deps: NovelStoreDependencies = { bootstrapService, chapterActionsDependencies: defaultChapterActionsDependencies, - transformChapters: c => c, + transformChapters: c => { + const excluded = + storeRef?.getState().novelSettings.excludedScanlators || []; + if (excluded.length === 0) { + return c; + } + return c.filter(ch => !ch.scanlator || !excluded.includes(ch.scanlator)); + }, persistPageIndex: value => novelPersistence.writePageIndex(persistenceInput, value), persistNovelSettings: value => { @@ -91,6 +99,7 @@ export function createStore({ actions, }; }); + storeRef = store; const success = store.getState().actions.bootstrapNovelSync(); if (!success) { diff --git a/src/hooks/persisted/useNovel/store/novelStore.actions.ts b/src/hooks/persisted/useNovel/store/novelStore.actions.ts index 8eece4bb6e..85fd3915bd 100644 --- a/src/hooks/persisted/useNovel/store/novelStore.actions.ts +++ b/src/hooks/persisted/useNovel/store/novelStore.actions.ts @@ -46,6 +46,7 @@ export const createNovelStoreActions = ({ pageIndex: state.pageIndex, settingsSort: getSettingsSort(state.novelSettings), settingsFilter: getSettingsFilter(state.novelSettings), + excludedScanlators: state.novelSettings.excludedScanlators, }); if (!result.ok) { @@ -65,6 +66,7 @@ export const createNovelStoreActions = ({ chapters: deps.transformChapters(result.chapters), batchInformation: result.batchInformation, firstUnreadChapter: result.firstUnreadChapter, + scanlators: result.scanlators, }); return true; @@ -83,6 +85,7 @@ export const createNovelStoreActions = ({ pageIndex: state.pageIndex, settingsSort: getSettingsSort(state.novelSettings), settingsFilter: getSettingsFilter(state.novelSettings), + excludedScanlators: state.novelSettings.excludedScanlators, }); if (!result.ok) { @@ -100,6 +103,7 @@ export const createNovelStoreActions = ({ chapters: deps.transformChapters(result.chapters), batchInformation: result.batchInformation, firstUnreadChapter: result.firstUnreadChapter, + scanlators: result.scanlators, }); return true; @@ -121,6 +125,7 @@ export const createNovelStoreActions = ({ pageIndex: state.pageIndex, settingsSort: getSettingsSort(state.novelSettings), settingsFilter: getSettingsFilter(state.novelSettings), + excludedScanlators: state.novelSettings.excludedScanlators, }); set({ @@ -144,6 +149,7 @@ export const createNovelStoreActions = ({ pageIndex: state.pageIndex, settingsSort: getSettingsSort(state.novelSettings), settingsFilter: getSettingsFilter(state.novelSettings), + excludedScanlators: state.novelSettings.excludedScanlators, }); if (!refreshed.ok) { @@ -156,6 +162,7 @@ export const createNovelStoreActions = ({ chapters: deps.transformChapters(refreshed.chapters), batchInformation: refreshed.batchInformation, firstUnreadChapter: refreshed.firstUnreadChapter, + scanlators: refreshed.scanlators, }); } finally { set({ loading: false, fetching: false }); diff --git a/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts b/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts index c93902a944..463a44547e 100644 --- a/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts +++ b/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts @@ -135,6 +135,7 @@ export const createNovelStoreChapterActions = ({ onBatchLoaded: (batch, chapters) => { appendBatch(batch, transformChapters(chapters)); }, + excludedScanlators: state.novelSettings.excludedScanlators, }); } })().finally(() => { @@ -161,6 +162,7 @@ export const createNovelStoreChapterActions = ({ settingsSort: getSettingsSort(state.novelSettings), settingsFilter: getSettingsFilter(state.novelSettings), batchInformation: state.batchInformation, + excludedScanlators: state.novelSettings.excludedScanlators, }); if (!result) { diff --git a/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts b/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts index c87764d55f..04f3f1be79 100644 --- a/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts +++ b/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts @@ -8,4 +8,5 @@ export const createInitialChapterSlice = (): ChapterSliceState => ({ batch: 0, total: 0, }, + scanlators: [], }); diff --git a/src/hooks/persisted/useNovel/store/novelStore.types.ts b/src/hooks/persisted/useNovel/store/novelStore.types.ts index bcd0b1428f..395aa33563 100644 --- a/src/hooks/persisted/useNovel/store/novelStore.types.ts +++ b/src/hooks/persisted/useNovel/store/novelStore.types.ts @@ -18,6 +18,7 @@ export interface ChapterSliceState { firstUnreadChapter: ChapterInfo | undefined; batchInformation: BatchInfo; chapterTextCache: Record; + scanlators: string[]; } export interface NovelStoreData extends ChapterSliceState { diff --git a/src/hooks/persisted/useNovel/types.ts b/src/hooks/persisted/useNovel/types.ts index 48815857db..6a5800e43f 100644 --- a/src/hooks/persisted/useNovel/types.ts +++ b/src/hooks/persisted/useNovel/types.ts @@ -7,6 +7,7 @@ export const LAST_READ_PREFIX = 'LAST_READ_PREFIX'; export const defaultNovelSettings: NovelSettingsWithoutSort = { showChapterTitles: true, filter: [], + excludedScanlators: [], }; export const defaultPageIndex = 0; @@ -15,6 +16,7 @@ export interface NovelSettingsWithoutSort { filter: ChapterFilterKey[]; showChapterTitles: boolean; sort?: ChapterOrderKey; + excludedScanlators?: string[]; } export interface NovelSettings extends NovelSettingsWithoutSort { sort: ChapterOrderKey; diff --git a/src/hooks/persisted/useNovelSettings.ts b/src/hooks/persisted/useNovelSettings.ts index b78184aa76..d0f5c4cab9 100644 --- a/src/hooks/persisted/useNovelSettings.ts +++ b/src/hooks/persisted/useNovelSettings.ts @@ -12,12 +12,10 @@ import { NOVEL_PAGE_INDEX_PREFIX, NOVEL_SETTINGS_PREFIX, } from './useNovel/types'; -import { useAppSettings } from './useSettings'; export { NOVEL_PAGE_INDEX_PREFIX, NOVEL_SETTINGS_PREFIX }; export const useNovelSettings = () => { - const { defaultChapterSort } = useAppSettings(); const novel = useNovelValue('novel'); const domainNovelSettings = useNovelValue('novelSettings'); const writeNovelSettings = useNovelAction('setNovelSettings'); @@ -27,7 +25,6 @@ export const useNovelSettings = () => { [domainNovelSettings], ); - const _sort: ChapterOrderKey = novelSettings.sort ?? defaultChapterSort; const _filter: ChapterFilterKey[] = novelSettings.filter; // #endregion @@ -37,25 +34,23 @@ export const useNovelSettings = () => { async (sort: ChapterOrderKey) => { if (novel) { writeNovelSettings({ - showChapterTitles: novelSettings?.showChapterTitles, + ...novelSettings, sort, - filter: _filter, }); } }, - [novel, writeNovelSettings, novelSettings?.showChapterTitles, _filter], + [novel, writeNovelSettings, novelSettings], ); const setChapterFilter = useCallback( async (filter?: ChapterFilterKey[]) => { if (novel) { writeNovelSettings({ - showChapterTitles: novelSettings?.showChapterTitles, - sort: _sort, + ...novelSettings, filter: filter ?? [], }); } }, - [novel, writeNovelSettings, novelSettings?.showChapterTitles, _sort], + [novel, writeNovelSettings, novelSettings], ); const filterManager = useMemo( @@ -96,6 +91,18 @@ export const useNovelSettings = () => { [novelSettings, writeNovelSettings], ); + const setExcludedScanlators = useCallback( + (excludedScanlators: string[]) => { + if (novel) { + writeNovelSettings({ + ...novelSettings, + excludedScanlators, + }); + } + }, + [novel, writeNovelSettings, novelSettings], + ); + // #endregion return useMemo( @@ -108,6 +115,7 @@ export const useNovelSettings = () => { getChapterFilter, setChapterSort, setShowChapterTitles, + setExcludedScanlators, }), [ cycleChapterFilter, @@ -118,6 +126,7 @@ export const useNovelSettings = () => { setChapterFilterValue, setChapterSort, setShowChapterTitles, + setExcludedScanlators, ], ); }; diff --git a/src/plugins/helpers/chapterPage.ts b/src/plugins/helpers/chapterPage.ts index 46ef317332..b25396ce40 100644 --- a/src/plugins/helpers/chapterPage.ts +++ b/src/plugins/helpers/chapterPage.ts @@ -50,8 +50,15 @@ export const normalizePluginChapters = ( } return chapters.map((chapter, index) => { + const scanlator = Array.isArray(chapter.scanlator) + ? chapter.scanlator.join(', ') + : chapter.scanlator; try { - return { ...chapter, page: normalizeChapterPage(chapter.page) }; + return { + ...chapter, + page: normalizeChapterPage(chapter.page), + scanlator, + }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); const chapterIdentity = chapter?.path || chapter?.name || `#${index + 1}`; diff --git a/src/plugins/types/index.ts b/src/plugins/types/index.ts index 413ec958ad..c79d62e43a 100644 --- a/src/plugins/types/index.ts +++ b/src/plugins/types/index.ts @@ -59,6 +59,7 @@ export interface ChapterItem { chapterNumber?: number; releaseTime?: string; page?: string; + scanlator?: string | string[]; } export enum NovelStatus { diff --git a/src/screens/novel/components/ChapterItem.tsx b/src/screens/novel/components/ChapterItem.tsx index 8d030a5319..75d1f20b69 100644 --- a/src/screens/novel/components/ChapterItem.tsx +++ b/src/screens/novel/components/ChapterItem.tsx @@ -406,7 +406,7 @@ const ChapterItem: React.FC = ({ const releaseTimeStyle = { color: theme.outline, - marginStart: chapter.releaseTime ? 5 : 0, + marginStart: chapter.releaseTime || chapter.scanlator ? 5 : 0, } as const; function parseTime(time?: string | Date | null) { @@ -475,12 +475,26 @@ const ChapterItem: React.FC = ({ {parsedTime} ) : null} + {chapter.scanlator && !isUpdateCard ? ( + + {parsedTime ? '• ' : null} + {chapter.scanlator} + + ) : null} {!isUpdateCard && progress && progress > 0 && chapter.unread ? ( - {chapter.releaseTime ? '• ' : null} + {chapter.releaseTime || chapter.scanlator ? '• ' : null} {getString('novelScreen.progress', { progress })} ) : null} @@ -581,4 +595,10 @@ const styles = StyleSheet.create({ mt4: { marginTop: 4, }, + marginStart5: { + marginStart: 5, + }, + marginStart0: { + marginStart: 0, + }, }); diff --git a/src/screens/novel/components/NovelBottomSheet.tsx b/src/screens/novel/components/NovelBottomSheet.tsx index 87d5cd13a7..ad2579a1b1 100644 --- a/src/screens/novel/components/NovelBottomSheet.tsx +++ b/src/screens/novel/components/NovelBottomSheet.tsx @@ -1,13 +1,24 @@ +import { Button } from '@components'; import BottomSheet from '@components/BottomSheet/BottomSheet'; import { Checkbox, SortItem } from '@components/Checkbox/Checkbox'; -import { BottomSheetView } from '@gorhom/bottom-sheet'; +import { BottomSheetScrollView, BottomSheetView } from '@gorhom/bottom-sheet'; import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; import { useNovelSettings } from '@hooks/persisted/useNovelSettings'; +import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; +import { useNovelValue } from '@screens/novel/NovelContext'; import { getString } from '@strings/translations'; import { ThemeColors } from '@theme/types'; import color from 'color'; -import React, { useCallback, useState } from 'react'; -import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + Modal as RNModal, + Pressable, + ScrollView, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; import { overlay } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SceneMap, TabBar, TabView, TabViewProps } from 'react-native-tab-view'; @@ -29,8 +40,29 @@ const ChaptersSettingsSheet = ({ setShowChapterTitles, sort, showChapterTitles, + excludedScanlators = [], + setExcludedScanlators, } = useNovelSettings(); + const rawScanlators = useNovelValue('scanlators'); + const scanlators = useMemo( + () => [...(rawScanlators || [])].sort((a, b) => a.localeCompare(b)), + [rawScanlators], + ); + + const [scanlatorsModalVisible, setScanlatorsModalVisible] = useState(false); + const [tempExcludedScanlators, setTempExcludedScanlators] = useState< + string[] + >([]); + + const toggleTempScanlator = useCallback((scanlator: string) => { + setTempExcludedScanlators(prev => + prev.includes(scanlator) + ? prev.filter(s => s !== scanlator) + : [...prev, scanlator], + ); + }, []); + const { left, right } = useSafeAreaInsets(); const readStatus = getChapterFilterState('read'); const unreadStatus = @@ -42,7 +74,7 @@ const ChaptersSettingsSheet = ({ const FirstRoute = useCallback( () => ( - + - + {scanlators.length > 0 && ( + + { + setTempExcludedScanlators(excludedScanlators); + setScanlatorsModalVisible(true); + }} + > + + {getString('novelScreen.bottomSheet.filters.ignoreScanlators')} + + + + + )} + ), [ cycleChapterFilter, @@ -85,6 +144,8 @@ const ChaptersSettingsSheet = ({ setChapterFilterValue, theme, unreadStatus, + scanlators, + excludedScanlators, ], ); @@ -187,34 +248,85 @@ const ChaptersSettingsSheet = ({ [], ); return ( - - + - - - + + + + + {scanlators.length > 0 && ( + setScanlatorsModalVisible(false)} + > + + + + {getString('novelScreen.bottomSheet.filters.ignoreScanlators')} + + + {scanlators.map(scanlator => ( + toggleTempScanlator(scanlator)} + /> + ))} + + +