Use novel refactor (#1838)
* init * feat: add zustand dependency and persistence key contract * refactor: extract bootstrap data loading into reusable service * refactor: move chapter mutations into store-ready action helpers * feat: add zustand novel store with cache and core actions * refactor: bridge novel persistence contracts for migration safety * refactor: migrate NovelScreen domain flows to zustand selectors * refactor: migrate NovelScreenList to selector-based store access * refactor: move reader chapter flows onto store boundaries * refactor: decouple useNovelSettings from broad context domain state * refactor: align migrateNovel with stable persistence contracts * refactor: cut novel-reader consumers to store-only context boundary * refactor: retire legacy useNovel and route cache cleanup export * test: update suites for store-only context boundary cutover * test: modernize store-era mocks and add contract coverage * test: finalize Task-15 sweep—remove dead useNovelData and lint clear mocksContract Final validation confirms mock-contract test suite clean and target file deletion verified with zero stale references in src/ scope. * remove imports from NovelScreen * reworked ai output * improvements * implemented synchronus novel and chapter fetch * refactor tests * fix db tests * Update remaining tests. * Harden chapter actions and bootstrap flows * Only count filtered chapters * improved chapter insert speed by optimizing triggers * Improved the batching function * Added drizzle support to dbManager.batch * removed better-sqlite3 for testing * fix tests * Update updateNovelChapters fucntion * reverse read filter * fix snackbar * fixed page bottomsheet * resolved paged novels showing wrong chapter number on opening * use openPage instead of setPageIndex in chapterDrawer * fix lint & tests * fix type issues * fix various smaller issues * updated novel restore * Delete tsconfig.tsbuildinfo
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
jest.mock('@database/queries/NovelQueries', () => ({
|
||||
getNovelById: jest.fn(),
|
||||
getNovelByPath: jest.fn(),
|
||||
deleteCachedNovels: jest.fn(),
|
||||
getCachedNovels: jest.fn(),
|
||||
@@ -30,7 +31,9 @@ jest.mock('@database/queries/ChapterQueries', () => ({
|
||||
insertChapters: jest.fn(),
|
||||
getCustomPages: jest.fn(),
|
||||
getChapterCount: jest.fn(),
|
||||
getChapterCountSync: jest.fn(),
|
||||
getPageChaptersBatched: jest.fn(),
|
||||
getNovelChaptersSync: jest.fn(),
|
||||
getFirstUnreadChapter: jest.fn(),
|
||||
updateChapterProgress: jest.fn(),
|
||||
}));
|
||||
|
||||
+3
-2
@@ -118,7 +118,8 @@
|
||||
"react-native-worklets": "^0.8.1",
|
||||
"react-native-zip-archive": "^7.0.2",
|
||||
"sanitize-html": "^2.17.2",
|
||||
"urlencode": "^2.0.0"
|
||||
"urlencode": "^2.0.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
@@ -145,7 +146,7 @@
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"babel-plugin-module-resolver": "^5.0.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"drizzle-kit": "1.0.0-beta.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
|
||||
Generated
+35
-8
@@ -79,7 +79,7 @@ importers:
|
||||
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.8.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@4.3.6)
|
||||
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@4.3.6)
|
||||
expo:
|
||||
specifier: ^55.0.9
|
||||
version: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-native-webview@13.16.1(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))(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)(typescript@5.9.3)
|
||||
@@ -218,6 +218,9 @@ importers:
|
||||
urlencode:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
zustand:
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
specifier: ^7.29.0
|
||||
@@ -292,8 +295,8 @@ importers:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
better-sqlite3:
|
||||
specifier: ^12.8.0
|
||||
version: 12.8.0
|
||||
specifier: ^12.9.0
|
||||
version: 12.9.0
|
||||
drizzle-kit:
|
||||
specifier: 1.0.0-beta.20
|
||||
version: 1.0.0-beta.20
|
||||
@@ -2410,8 +2413,8 @@ packages:
|
||||
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
better-sqlite3@12.8.0:
|
||||
resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==}
|
||||
better-sqlite3@12.9.0:
|
||||
resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
|
||||
|
||||
big-integer@1.6.52:
|
||||
@@ -6320,6 +6323,24 @@ packages:
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zustand@5.0.12:
|
||||
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=18.0.0'
|
||||
use-sync-external-store: '>=1.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@azure-rest/core-client@2.5.1':
|
||||
@@ -9189,7 +9210,7 @@ snapshots:
|
||||
dependencies:
|
||||
open: 8.4.2
|
||||
|
||||
better-sqlite3@12.8.0:
|
||||
better-sqlite3@12.9.0:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
prebuild-install: 7.1.3
|
||||
@@ -9736,7 +9757,7 @@ 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.8.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@4.3.6):
|
||||
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@4.3.6):
|
||||
dependencies:
|
||||
'@types/mssql': 9.1.9(@azure/core-client@1.10.1)
|
||||
mssql: 11.0.1(@azure/core-client@1.10.1)
|
||||
@@ -9744,7 +9765,7 @@ snapshots:
|
||||
'@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
|
||||
better-sqlite3: 12.8.0
|
||||
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)
|
||||
zod: 4.3.6
|
||||
|
||||
@@ -13786,3 +13807,9 @@ snapshots:
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { open, type DB } from '@op-engineering/op-sqlite';
|
||||
import { drizzle } from 'drizzle-orm/op-sqlite';
|
||||
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
|
||||
import migrations from '../../../drizzle/migrations';
|
||||
import { schema } from '@database/schema';
|
||||
|
||||
jest.mock('@op-engineering/op-sqlite', () => ({
|
||||
__esModule: true,
|
||||
open: jest.fn(() => ({
|
||||
execute: jest.fn().mockResolvedValue({ rows: [] }),
|
||||
executeAsync: jest.fn().mockResolvedValue({ rows: [] }),
|
||||
executeSync: jest.fn().mockReturnValue({ rows: [] }),
|
||||
executeRawAsync: jest.fn().mockResolvedValue([]),
|
||||
executeBatch: jest.fn().mockResolvedValue(undefined),
|
||||
flushPendingReactiveQueries: jest.fn(),
|
||||
reactiveExecute: jest.fn(() => () => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { runDatabaseBootstrap } from '@database/db';
|
||||
|
||||
const MIGRATION_STATEMENTS = [
|
||||
@@ -80,84 +67,26 @@ const MIGRATION_STATEMENTS = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS repository_url_unique ON Repository (url)`,
|
||||
];
|
||||
|
||||
const createExecutor = (sqlite: Database.Database) => ({
|
||||
const createExecutor = (sqlite: DB) => ({
|
||||
executeSync: (sql: string, params?: unknown[]) => {
|
||||
if (params && params.length) {
|
||||
const stmt = sqlite.prepare(sql);
|
||||
stmt.run(params as any[]);
|
||||
return;
|
||||
}
|
||||
sqlite.exec(sql);
|
||||
sqlite.executeSync(sql, params as any[]);
|
||||
},
|
||||
});
|
||||
|
||||
const createOpSqliteAdapter = (sqlite: Database.Database) => {
|
||||
return {
|
||||
execute: async (sql: string, params?: unknown[]) => {
|
||||
const stmt = sqlite.prepare(sql);
|
||||
const rows =
|
||||
params && params.length ? stmt.all(params as any[]) : stmt.all();
|
||||
return {
|
||||
rows: {
|
||||
_array: rows.map(row =>
|
||||
Object.values(row as Record<string, unknown>),
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
executeSync: (sql: string, params?: unknown[]) => {
|
||||
const stmt = sqlite.prepare(sql);
|
||||
const result =
|
||||
params && params.length ? stmt.run(params as any[]) : stmt.run();
|
||||
return { rows: [], rowsAffected: result.changes ?? 0 };
|
||||
},
|
||||
executeAsync: async (sql: string, params?: unknown[]) => {
|
||||
const stmt = sqlite.prepare(sql);
|
||||
const result =
|
||||
params && params.length ? stmt.run(params as any[]) : stmt.run();
|
||||
return { rows: [], rowsAffected: result.changes ?? 0 };
|
||||
},
|
||||
executeRawAsync: async (sql: string, params?: unknown[]) => {
|
||||
const stmt = sqlite.prepare(sql).raw();
|
||||
const rows =
|
||||
params && params.length ? stmt.all(params as any[]) : stmt.all();
|
||||
return rows as unknown[][];
|
||||
},
|
||||
executeBatch: async (
|
||||
commands: Array<[string, unknown[] | unknown[][]]>,
|
||||
) => {
|
||||
const transaction = sqlite.transaction((cmds: typeof commands) => {
|
||||
for (const cmd of cmds) {
|
||||
const stmt = sqlite.prepare(cmd[0]);
|
||||
if (Array.isArray(cmd[1])) {
|
||||
for (const arg of cmd[1]) {
|
||||
stmt.run(arg as any[]);
|
||||
}
|
||||
} else {
|
||||
stmt.run(cmd[1] as any[]);
|
||||
}
|
||||
}
|
||||
});
|
||||
transaction(commands);
|
||||
},
|
||||
flushPendingReactiveQueries: () => undefined,
|
||||
reactiveExecute: () => () => undefined,
|
||||
};
|
||||
};
|
||||
|
||||
describe('new database initialization', () => {
|
||||
it('creates schema, triggers, and default data', async () => {
|
||||
const sqlite = new Database(':memory:');
|
||||
const sqlite = open({ name: ':memory:' });
|
||||
(sqlite as any).executeAsync ??= sqlite.execute;
|
||||
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
|
||||
try {
|
||||
const adapter = createOpSqliteAdapter(sqlite);
|
||||
const drizzleDb = drizzle(adapter, { schema });
|
||||
const drizzleDb = drizzle(sqlite, { schema });
|
||||
|
||||
await migrate(drizzleDb, migrations);
|
||||
runDatabaseBootstrap(createExecutor(sqlite));
|
||||
|
||||
const tables = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as Array<{ name: string }>;
|
||||
const tables = sqlite.executeSync(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||
).rows as Array<{ name: string }>;
|
||||
const tableNames = tables.map(table => table.name);
|
||||
expect(tableNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -169,9 +98,9 @@ describe('new database initialization', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const triggers = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.all() as Array<{ name: string }>;
|
||||
const triggers = sqlite.executeSync(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger'",
|
||||
).rows as Array<{ name: string }>;
|
||||
const triggerNames = triggers.map(trigger => trigger.name);
|
||||
expect(triggerNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -182,9 +111,9 @@ describe('new database initialization', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const categories = sqlite
|
||||
.prepare('SELECT id, name FROM Category ORDER BY id')
|
||||
.all() as Array<{ id: number; name: string }>;
|
||||
const categories = sqlite.executeSync(
|
||||
'SELECT id, name FROM Category ORDER BY id',
|
||||
).rows as Array<{ id: number; name: string }>;
|
||||
expect(categories.map(category => category.id)).toEqual([1, 2]);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
@@ -194,20 +123,23 @@ describe('new database initialization', () => {
|
||||
|
||||
describe('runDatabaseBootstrap', () => {
|
||||
it('applies pragmas, triggers, and default categories', () => {
|
||||
const sqlite = new Database(':memory:');
|
||||
const sqlite = open({ name: ':memory:' });
|
||||
(sqlite as any).executeAsync ??= sqlite.execute;
|
||||
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
|
||||
try {
|
||||
for (const statement of MIGRATION_STATEMENTS) {
|
||||
sqlite.exec(statement.trim());
|
||||
sqlite.executeSync(statement.trim());
|
||||
}
|
||||
|
||||
sqlite.executeSync('PRAGMA journal_mode = WAL');
|
||||
runDatabaseBootstrap(createExecutor(sqlite));
|
||||
|
||||
const journalMode = sqlite.pragma('journal_mode', { simple: true });
|
||||
const journalMode = sqlite.executeRawSync('PRAGMA journal_mode')[0]?.[0];
|
||||
expect(['wal', 'memory']).toContain(String(journalMode).toLowerCase());
|
||||
|
||||
const triggers = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.all() as Array<{ name: string }>;
|
||||
const triggers = sqlite.executeSync(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger'",
|
||||
).rows as Array<{ name: string }>;
|
||||
const triggerNames = triggers.map(trigger => trigger.name);
|
||||
expect(triggerNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -218,9 +150,9 @@ describe('runDatabaseBootstrap', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const categories = sqlite
|
||||
.prepare('SELECT id, name FROM Category ORDER BY id')
|
||||
.all() as Array<{ id: number; name: string }>;
|
||||
const categories = sqlite.executeSync(
|
||||
'SELECT id, name FROM Category ORDER BY id',
|
||||
).rows as Array<{ id: number; name: string }>;
|
||||
expect(categories.map(category => category.id)).toEqual([1, 2]);
|
||||
expect(categories.map(category => category.name)).toEqual([
|
||||
'categories.default',
|
||||
@@ -234,19 +166,20 @@ describe('runDatabaseBootstrap', () => {
|
||||
|
||||
describe('production migrations', () => {
|
||||
it('can run after test schema exists', async () => {
|
||||
const sqlite = new Database(':memory:');
|
||||
const sqlite = open({ name: ':memory:' });
|
||||
(sqlite as any).executeAsync ??= sqlite.execute;
|
||||
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
|
||||
try {
|
||||
for (const statement of MIGRATION_STATEMENTS) {
|
||||
sqlite.exec(statement.trim());
|
||||
sqlite.executeSync(statement.trim());
|
||||
}
|
||||
|
||||
const adapter = createOpSqliteAdapter(sqlite);
|
||||
const drizzleDb = drizzle(adapter, { schema });
|
||||
const drizzleDb = drizzle(sqlite, { schema });
|
||||
await migrate(drizzleDb, migrations);
|
||||
|
||||
const tables = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as Array<{ name: string }>;
|
||||
const tables = sqlite.executeSync(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||
).rows as Array<{ name: string }>;
|
||||
const tableNames = tables.map(table => table.name);
|
||||
expect(tableNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
||||
@@ -69,6 +69,10 @@ const populateDatabase = (executor: SqlExecutor) => {
|
||||
|
||||
const createDbTriggers = (executor: SqlExecutor) => {
|
||||
console.log('Creating database triggers');
|
||||
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats');
|
||||
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_update');
|
||||
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_delete');
|
||||
executor.executeSync('DROP TRIGGER IF EXISTS add_category');
|
||||
executor.executeSync(createCategoryTriggerQuery);
|
||||
executor.executeSync(createNovelTriggerQueryDelete);
|
||||
executor.executeSync(createNovelTriggerQueryInsert);
|
||||
|
||||
Vendored
+18
-1
@@ -1,5 +1,10 @@
|
||||
// db-manager.types.ts
|
||||
import type { SQLiteTransaction, TablesRelationalConfig } from 'drizzle-orm';
|
||||
import type {
|
||||
SQLiteTransaction,
|
||||
TablesRelationalConfig,
|
||||
Placeholder,
|
||||
} from 'drizzle-orm';
|
||||
import { SQLitePreparedQuery } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// Define the TransactionParameter type based on your DrizzleDb
|
||||
export type TransactionParameter = SQLiteTransaction<
|
||||
@@ -14,6 +19,18 @@ export type TransactionParameter = SQLiteTransaction<
|
||||
* This contract ensures consistent documentation and type safety across the application.
|
||||
*/
|
||||
export interface IDbManager {
|
||||
/**
|
||||
* Efficiently executes a Drizzle query for multiple data rows using
|
||||
* op-sqlite executeBatch under the hood.
|
||||
*/
|
||||
batch<T extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
fn: (
|
||||
tx: TransactionParameter,
|
||||
ph: (arg: Extract<keyof T, string>) => Placeholder,
|
||||
) => SQLitePreparedQuery<any>,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Creates a subquery that defines a temporary named result set as a CTE.
|
||||
*
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { db, drizzleDb } from '@database/db';
|
||||
import type { SQLBatchTuple, Scalar } from '@op-engineering/op-sqlite';
|
||||
import { IDbManager } from './manager.d';
|
||||
import { DbTaskQueue } from './queue';
|
||||
import { Schema } from '../schema';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { GetSelectTableName } from 'drizzle-orm/query-builders/select.types';
|
||||
import { AnyColumn, Placeholder, Query, sql } from 'drizzle-orm';
|
||||
import {
|
||||
AnyColumn,
|
||||
fillPlaceholders,
|
||||
Placeholder,
|
||||
Query,
|
||||
sql,
|
||||
} from 'drizzle-orm';
|
||||
import { SQLitePreparedQuery } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
type DrizzleDb = typeof drizzleDb;
|
||||
@@ -18,7 +25,11 @@ interface ExecutableSelect<TResult = any> {
|
||||
get(): Promise<TResult | undefined>;
|
||||
}
|
||||
|
||||
let _dbManager: DbManager;
|
||||
let _dbManager: DbManager | undefined;
|
||||
|
||||
export const __resetDbManagerForTests = () => {
|
||||
_dbManager = undefined;
|
||||
};
|
||||
|
||||
export function castInt(value: number | string | AnyColumn) {
|
||||
return sql`CAST(${value} AS INTEGER)`;
|
||||
@@ -66,34 +77,18 @@ class DbManager implements IDbManager {
|
||||
query: T,
|
||||
): Awaited<ReturnType<T['get']>> {
|
||||
const { sql: sqlString, params } = query.toSQL();
|
||||
return db.executeSync(sqlString, params as any[]).rows[0] as Awaited<
|
||||
ReturnType<T['get']>
|
||||
>;
|
||||
return this.db.$client.executeSync(sqlString, params as any[])
|
||||
.rows[0] as Awaited<ReturnType<T['get']>>;
|
||||
}
|
||||
|
||||
public async allSync<T extends ExecutableSelect>(
|
||||
public allSync<T extends ExecutableSelect>(
|
||||
query: T,
|
||||
): Promise<Awaited<ReturnType<T['all']>>> {
|
||||
): Awaited<ReturnType<T['all']>> {
|
||||
const { sql: sqlString, params } = query.toSQL();
|
||||
return db.executeSync(sqlString, params as any[]).rows as Awaited<
|
||||
ReturnType<T['all']>
|
||||
>;
|
||||
return this.db.$client.executeSync(sqlString, params as any[])
|
||||
.rows as Awaited<ReturnType<T['all']>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently executes a Drizzle query for multiple data rows using a single
|
||||
* prepared statement within a write transaction.
|
||||
*
|
||||
* @param data - Array of objects containing the parameters for each execution.
|
||||
* @param fn - Callback to build the query. Use `ph("key")` to bind to properties in your data.
|
||||
* Must return a prepared query via `.prepare()`.
|
||||
*
|
||||
* @example
|
||||
* await dbManager.batch(
|
||||
* [{ id: 1, val: 'a' }, { id: 2, val: 'b' }],
|
||||
* (tx, ph) => tx.insert(table).values({ id: ph('id'), val: ph('val') }).prepare()
|
||||
* );
|
||||
*/
|
||||
public async batch<T extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
fn: (
|
||||
@@ -101,12 +96,27 @@ class DbManager implements IDbManager {
|
||||
ph: (arg: Extract<keyof T, string>) => Placeholder,
|
||||
) => SQLitePreparedQuery<any>,
|
||||
) {
|
||||
if (!data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ph = (arg: Extract<keyof T, string>) => sql.placeholder(arg);
|
||||
await this.write(async tx => {
|
||||
const prep = fn(tx, ph);
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
prep.run(data[index]);
|
||||
}
|
||||
const prepared = fn(this.db as unknown as TransactionParameter, ph);
|
||||
const query = prepared.getQuery();
|
||||
const params = data.map(item => {
|
||||
const values = fillPlaceholders(query.params, item);
|
||||
return values.map(value =>
|
||||
value === undefined ? null : (value as Scalar),
|
||||
) as Scalar[];
|
||||
});
|
||||
const commands: SQLBatchTuple[] = [[query.sql, params]];
|
||||
|
||||
await this.queue.enqueue({
|
||||
id: 'write',
|
||||
run: async () => {
|
||||
await this.db.$client.executeBatch(commands);
|
||||
this.db.$client?.flushPendingReactiveQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,7 +128,7 @@ class DbManager implements IDbManager {
|
||||
run: async () =>
|
||||
await this.db.transaction(async tx => {
|
||||
const result = await fn(tx);
|
||||
db?.flushPendingReactiveQueries();
|
||||
this.db.$client?.flushPendingReactiveQueries();
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -135,16 +145,20 @@ type FireOn = Array<{ table: TableNames; ids?: number[] }>;
|
||||
export function useLiveQuery<T extends ExecutableSelect>(
|
||||
query: T,
|
||||
fireOn: FireOn,
|
||||
callback?: (data: Awaited<ReturnType<T['all']>>) => void,
|
||||
) {
|
||||
type ReturnValue = Awaited<ReturnType<T['all']>>;
|
||||
|
||||
const { sql: sqlString, params } = query.toSQL();
|
||||
const paramsKey = JSON.stringify(params);
|
||||
const fireOnKey = JSON.stringify(fireOn);
|
||||
const cb = useRef(callback ?? (() => {}));
|
||||
|
||||
const [data, setData] = useState<ReturnValue>(
|
||||
() => db.executeSync(sqlString, params as any[]).rows as ReturnValue,
|
||||
);
|
||||
const [data, setData] = useState<ReturnValue>(() => {
|
||||
const r = db.executeSync(sqlString, params as any[]).rows as ReturnValue;
|
||||
cb.current(r);
|
||||
return r;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = db.reactiveExecute({
|
||||
@@ -153,6 +167,7 @@ export function useLiveQuery<T extends ExecutableSelect>(
|
||||
fireOn,
|
||||
callback: (result: { rows: ReturnValue }) => {
|
||||
setData(result.rows);
|
||||
cb.current(result.rows);
|
||||
},
|
||||
});
|
||||
return unsub;
|
||||
|
||||
@@ -35,42 +35,59 @@ import { castInt } from '@database/manager/manager';
|
||||
export const insertChapters = async (
|
||||
novelId: number,
|
||||
chapters?: ChapterItem[],
|
||||
options?: {
|
||||
page?: string;
|
||||
touchUpdatedTime?: boolean;
|
||||
preferNullReleaseTime?: boolean;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (!chapters?.length) {
|
||||
return;
|
||||
}
|
||||
await dbManager.batch(
|
||||
chapters.map((c, i) => ({
|
||||
path: c.path,
|
||||
name: c.name || 'Chapter ' + (i + 1),
|
||||
releaseTime: c.releaseTime || '',
|
||||
chapterNumber: c.chapterNumber ?? null,
|
||||
page: c.page || '1',
|
||||
position: i,
|
||||
})),
|
||||
(tx, ph) =>
|
||||
tx
|
||||
.insert(chapterSchema)
|
||||
.values({
|
||||
path: ph('path'),
|
||||
name: ph('name'),
|
||||
releaseTime: ph('releaseTime'),
|
||||
novelId,
|
||||
chapterNumber: ph('chapterNumber'),
|
||||
page: ph('page'),
|
||||
position: ph('position'),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [chapterSchema.novelId, chapterSchema.path],
|
||||
set: {
|
||||
page: ph('page'),
|
||||
position: ph('position'),
|
||||
name: ph('name'),
|
||||
releaseTime: ph('releaseTime'),
|
||||
chapterNumber: ph('chapterNumber'),
|
||||
},
|
||||
})
|
||||
.prepare(),
|
||||
|
||||
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,
|
||||
}));
|
||||
await dbManager.batch(rows, (tx, ph) =>
|
||||
tx
|
||||
.insert(chapterSchema)
|
||||
.values({
|
||||
path: ph('path'),
|
||||
name: ph('name'),
|
||||
releaseTime: ph('releaseTime'),
|
||||
novelId: ph('novelId'),
|
||||
chapterNumber: ph('chapterNumber'),
|
||||
page: ph('page'),
|
||||
position: ph('position'),
|
||||
...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [chapterSchema.novelId, chapterSchema.path],
|
||||
set: {
|
||||
page: sql`excluded.page`,
|
||||
position: sql`excluded.position`,
|
||||
name: sql`excluded.name`,
|
||||
releaseTime: sql`excluded.releaseTime`,
|
||||
chapterNumber: sql`excluded.chapterNumber`,
|
||||
...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}),
|
||||
},
|
||||
where: sql`NOT (
|
||||
${chapterSchema.page} IS excluded.page
|
||||
AND ${chapterSchema.position} IS excluded.position
|
||||
AND ${chapterSchema.name} IS excluded.name
|
||||
AND ${chapterSchema.releaseTime} IS excluded.releaseTime
|
||||
AND ${chapterSchema.chapterNumber} IS excluded.chapterNumber
|
||||
)`,
|
||||
})
|
||||
.prepare(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -300,23 +317,61 @@ export const clearUpdates = async (): Promise<void> => {
|
||||
// #endregion
|
||||
// #region Selectors
|
||||
|
||||
export const getCustomPages = async (novelId: number) => {
|
||||
return await dbManager
|
||||
.selectDistinct({ page: chapterSchema.page })
|
||||
.from(chapterSchema)
|
||||
.where(eq(chapterSchema.novelId, novelId))
|
||||
.orderBy(asc(castInt(chapterSchema.page)))
|
||||
.all();
|
||||
export const getCustomPages = (novelId: number) => {
|
||||
return dbManager.allSync(
|
||||
dbManager
|
||||
.selectDistinct({ page: chapterSchema.page })
|
||||
.from(chapterSchema)
|
||||
.where(eq(chapterSchema.novelId, novelId))
|
||||
.orderBy(asc(castInt(chapterSchema.page))),
|
||||
);
|
||||
};
|
||||
|
||||
export const getNovelChapters = async (
|
||||
novelId: number,
|
||||
sort?: ChapterOrderKey,
|
||||
filter?: ChapterFilterKey[],
|
||||
page?: string,
|
||||
limit: number = 1000,
|
||||
): Promise<ChapterInfo[]> =>
|
||||
dbManager
|
||||
.select()
|
||||
.from(chapterSchema)
|
||||
.where(eq(chapterSchema.novelId, novelId));
|
||||
.where(
|
||||
and(
|
||||
eq(chapterSchema.novelId, novelId),
|
||||
!page ? sql.raw('true') : eq(chapterSchema.page, page),
|
||||
chapterFilterToSQL(filter),
|
||||
),
|
||||
)
|
||||
.orderBy(chapterOrderToSQL(sort))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
export const getNovelChaptersSync = (
|
||||
novelId: number,
|
||||
sort?: ChapterOrderKey,
|
||||
filter?: ChapterFilterKey[],
|
||||
page?: string,
|
||||
limit: number = 1000,
|
||||
): ChapterInfo[] =>
|
||||
dbManager.allSync(
|
||||
dbManager
|
||||
.select()
|
||||
.from(chapterSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(chapterSchema.novelId, novelId),
|
||||
!page ? sql.raw('true') : eq(chapterSchema.page, page),
|
||||
chapterFilterToSQL(filter),
|
||||
),
|
||||
)
|
||||
.orderBy(chapterOrderToSQL(sort))
|
||||
.limit(limit), // Adding a limit to prevent potential performance issues with large datasets
|
||||
);
|
||||
/**
|
||||
* @deprecated, use getNovelChapters with whereConditions instead
|
||||
*/
|
||||
export const getUnreadNovelChapters = async (
|
||||
novelId: number,
|
||||
): Promise<ChapterInfo[]> =>
|
||||
@@ -326,7 +381,9 @@ export const getUnreadNovelChapters = async (
|
||||
.where(
|
||||
and(eq(chapterSchema.novelId, novelId), eq(chapterSchema.unread, true)),
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated, use getNovelChapters with whereConditions instead
|
||||
*/
|
||||
export const getAllUndownloadedChapters = async (
|
||||
novelId: number,
|
||||
): Promise<ChapterInfo[]> =>
|
||||
@@ -339,7 +396,9 @@ export const getAllUndownloadedChapters = async (
|
||||
eq(chapterSchema.isDownloaded, false),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated, use getNovelChapters with whereConditions instead
|
||||
*/
|
||||
export const getAllUndownloadedAndUnreadChapters = async (
|
||||
novelId: number,
|
||||
): Promise<ChapterInfo[]> =>
|
||||
@@ -409,6 +468,28 @@ export const getChapterCount = async (
|
||||
),
|
||||
);
|
||||
|
||||
export const getChapterCountSync = (
|
||||
novelId: number,
|
||||
page: string = '1',
|
||||
filter?: ChapterFilterKey[],
|
||||
): number => {
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result?.['count(*)'] ?? 0;
|
||||
};
|
||||
|
||||
export const getPageChaptersBatched = async (
|
||||
novelId: number,
|
||||
sort?: ChapterOrderKey,
|
||||
@@ -416,8 +497,8 @@ export const getPageChaptersBatched = async (
|
||||
page?: string,
|
||||
batch: number = 0,
|
||||
) => {
|
||||
const limit = 300;
|
||||
const offset = 300 * batch;
|
||||
const limit = 1000;
|
||||
const offset = 1000 * batch;
|
||||
const query = dbManager
|
||||
.select()
|
||||
.from(chapterSchema)
|
||||
@@ -459,20 +540,21 @@ export const getFirstUnreadChapter = (
|
||||
filter?: ChapterFilterKey[],
|
||||
page?: string,
|
||||
) =>
|
||||
dbManager
|
||||
.select()
|
||||
.from(chapterSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(chapterSchema.novelId, novelId),
|
||||
eq(chapterSchema.page, page || '1'),
|
||||
eq(chapterSchema.unread, true),
|
||||
chapterFilterToSQL(filter),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chapterSchema.position))
|
||||
.limit(1)
|
||||
.get();
|
||||
dbManager.getSync(
|
||||
dbManager
|
||||
.select()
|
||||
.from(chapterSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(chapterSchema.novelId, novelId),
|
||||
eq(chapterSchema.page, page || '1'),
|
||||
eq(chapterSchema.unread, true),
|
||||
chapterFilterToSQL(filter),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chapterSchema.position))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
export const getNovelChaptersByName = async (
|
||||
novelId: number,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { insertChapters } from './ChapterQueries';
|
||||
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
import { BackupNovel, NovelInfo } from '../types';
|
||||
import { BackupNovel, DBNovelInfo, NovelInfo } from '../types';
|
||||
import { SourceNovel } from '@plugins/types';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import { downloadFile } from '@plugins/helpers/fetch';
|
||||
@@ -82,21 +82,16 @@ export const getAllNovels = async (): Promise<NovelInfo[]> => {
|
||||
return dbManager.select().from(novelSchema).all();
|
||||
};
|
||||
|
||||
export const getNovelById = async (
|
||||
novelId: number,
|
||||
): Promise<NovelInfo | undefined> => {
|
||||
const res = dbManager
|
||||
.select()
|
||||
.from(novelSchema)
|
||||
.where(eq(novelSchema.id, novelId))
|
||||
.get();
|
||||
return res;
|
||||
export const getNovelById = (novelId: number): DBNovelInfo | undefined => {
|
||||
return dbManager.getSync(
|
||||
dbManager.select().from(novelSchema).where(eq(novelSchema.id, novelId)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getNovelByPath = (
|
||||
novelPath: string,
|
||||
pluginId: string,
|
||||
): NovelInfo | undefined => {
|
||||
): DBNovelInfo | undefined => {
|
||||
const res = dbManager.getSync(
|
||||
dbManager
|
||||
.select()
|
||||
@@ -430,7 +425,15 @@ export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
|
||||
tx.delete(chapterSchema).where(eq(chapterSchema.novelId, novel.id)).run();
|
||||
|
||||
// Restore novel
|
||||
tx.insert(novelSchema).values(novel).run();
|
||||
tx
|
||||
.insert(novelSchema)
|
||||
.values({
|
||||
...novel,
|
||||
totalChapters: 0,
|
||||
chaptersDownloaded: 0,
|
||||
chaptersUnread: 0,
|
||||
})
|
||||
.run();
|
||||
|
||||
// Restore chapters in batches
|
||||
if (chapters.length > 0) {
|
||||
|
||||
@@ -324,6 +324,89 @@ describe('ChapterQueries', () => {
|
||||
const chapters = await getNovelChapters(novelId);
|
||||
expect(chapters[0].name).toBe('Chapter 1');
|
||||
});
|
||||
|
||||
it('should force page override option when inserting chapters', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertChapters(
|
||||
novelId,
|
||||
[
|
||||
{
|
||||
path: '/chapter/1',
|
||||
name: 'Chapter 1',
|
||||
page: '99',
|
||||
},
|
||||
],
|
||||
{ page: '2' },
|
||||
);
|
||||
|
||||
const chapters = await getNovelChapters(novelId);
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].page).toBe('2');
|
||||
});
|
||||
|
||||
it('should set updatedTime when touchUpdatedTime is enabled on insert', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertChapters(
|
||||
novelId,
|
||||
[
|
||||
{
|
||||
path: '/chapter/1',
|
||||
name: 'Chapter 1',
|
||||
},
|
||||
],
|
||||
{ touchUpdatedTime: true },
|
||||
);
|
||||
|
||||
const chapters = await getNovelChapters(novelId);
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].updatedTime).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should set releaseTime to null when preferNullReleaseTime is enabled', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertChapters(
|
||||
novelId,
|
||||
[
|
||||
{
|
||||
path: '/chapter/1',
|
||||
name: 'Chapter 1',
|
||||
},
|
||||
],
|
||||
{ preferNullReleaseTime: true },
|
||||
);
|
||||
|
||||
const chapters = await getNovelChapters(novelId);
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].releaseTime).toBeNull();
|
||||
});
|
||||
|
||||
it('should backfill chapterNumber with index fallback over existing null', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
|
||||
await insertTestChapter(testDb, novelId, {
|
||||
path: '/chapter/1',
|
||||
chapterNumber: null,
|
||||
});
|
||||
|
||||
await insertChapters(novelId, [
|
||||
{
|
||||
path: '/chapter/1',
|
||||
name: 'Chapter 1',
|
||||
},
|
||||
]);
|
||||
|
||||
const chapters = await getNovelChapters(novelId);
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].chapterNumber).toBe(1);
|
||||
expect(chapters[0].chapterNumber).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteChapter', () => {
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
insertTestNovel,
|
||||
insertTestChapter,
|
||||
insertTestNovelCategory,
|
||||
insertTestCategory,
|
||||
} from './testData';
|
||||
import {
|
||||
getLibraryNovelsFromDb,
|
||||
getLibraryWithCategory,
|
||||
} from '../LibraryQueries';
|
||||
import { TestDb } from './testDb';
|
||||
import { categorySchema } from '@database/schema';
|
||||
import { setupTestDatabase, teardownTestDatabase } from './setup';
|
||||
|
||||
describe('LibraryQueries', () => {
|
||||
@@ -140,22 +140,65 @@ describe('LibraryQueries', () => {
|
||||
|
||||
it('should combine all filters (sort, search, downloaded only, exclude local)', async () => {
|
||||
// Setup: Insert multiple novels with varying properties
|
||||
const novel1Id = await insertTestNovel(testDb, {
|
||||
const novelId1 = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'The Great Local Novel',
|
||||
author: 'Author One',
|
||||
isLocal: true,
|
||||
});
|
||||
await insertTestChapter(testDb, novel1Id, { isDownloaded: true });
|
||||
const novelId2 = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'Novel 2',
|
||||
isLocal: false,
|
||||
});
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
|
||||
const novel2Id = await insertTestNovel(testDb, {
|
||||
const novels = await getLibraryWithCategory(categoryId1, undefined, true);
|
||||
expect(novels).toHaveLength(1);
|
||||
expect(novels[0].name).toBe('Novel 2');
|
||||
});
|
||||
|
||||
it('should not filter by excludeLocalNovels = false', async () => {
|
||||
const novelId1 = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'Novel 1',
|
||||
isLocal: true,
|
||||
});
|
||||
const novelId2 = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'Novel 2',
|
||||
isLocal: false,
|
||||
});
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
|
||||
const novels = await getLibraryWithCategory(
|
||||
categoryId1,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
expect(novels).toHaveLength(2);
|
||||
expect(novels.map(n => n.name).sort()).toEqual(['Novel 1', 'Novel 2']);
|
||||
});
|
||||
|
||||
it('should handle novels belonging to multiple categories', async () => {
|
||||
// Test Case 1: Filter by remote novels with downloaded chapters
|
||||
const novel1Id = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'A Good Remote Story',
|
||||
author: 'Author Two',
|
||||
isLocal: false,
|
||||
});
|
||||
await insertTestChapter(testDb, novel2Id, { isDownloaded: true });
|
||||
await insertTestChapter(testDb, novel1Id, { isDownloaded: true });
|
||||
|
||||
// Novel 2: Remote but not downloaded
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'Another Remote Novel',
|
||||
@@ -163,14 +206,14 @@ describe('LibraryQueries', () => {
|
||||
isLocal: false,
|
||||
});
|
||||
|
||||
// Novel 3: Downloaded but not matching author filter
|
||||
await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'Downloaded Local Book',
|
||||
name: 'Downloaded Book',
|
||||
author: 'Author One',
|
||||
isLocal: false,
|
||||
}); // No chapters, so chaptersDownloaded is 0
|
||||
|
||||
// Test Case 1: All filters combined, expecting specific result
|
||||
const novels1 = await getLibraryNovelsFromDb(
|
||||
'name ASC', // sortOrder
|
||||
"author = 'Author Two'", // filter
|
||||
@@ -181,14 +224,21 @@ describe('LibraryQueries', () => {
|
||||
expect(novels1).toHaveLength(1);
|
||||
expect(novels1[0].name).toBe('A Good Remote Story');
|
||||
|
||||
// Test Case 2: Different combination, expecting a different result
|
||||
// Looking for local, downloaded novels by Author One, sorted by name DESC
|
||||
const novel3Id = await insertTestNovel(testDb, {
|
||||
// Test Case 2: Filter by local novels with downloaded chapters by Author One
|
||||
const novel2Id = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'An Old Local Story',
|
||||
author: 'Author One',
|
||||
isLocal: true,
|
||||
});
|
||||
await insertTestChapter(testDb, novel2Id, { isDownloaded: true });
|
||||
|
||||
const novel3Id = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
name: 'The Great Local Novel',
|
||||
author: 'Author One',
|
||||
isLocal: true,
|
||||
});
|
||||
await insertTestChapter(testDb, novel3Id, { isDownloaded: true });
|
||||
|
||||
const novels2 = await getLibraryNovelsFromDb(
|
||||
@@ -199,16 +249,8 @@ describe('LibraryQueries', () => {
|
||||
false, // Include local novels
|
||||
);
|
||||
|
||||
// We expect 'The Great Local Novel' and 'An Old Local Story'
|
||||
// Both are local, by Author One, and have downloaded chapters.
|
||||
// Sorted DESC, so 'The Great Local Novel' comes first.
|
||||
expect(
|
||||
novels2.map(n => ({
|
||||
name: n.name,
|
||||
isLocal: n.isLocal,
|
||||
chaptersDownloaded: n.chaptersDownloaded,
|
||||
})),
|
||||
).toHaveLength(2);
|
||||
expect(novels2.map(n => n.name)).toEqual([
|
||||
'The Great Local Novel',
|
||||
'An Old Local Story',
|
||||
@@ -258,16 +300,12 @@ describe('LibraryQueries', () => {
|
||||
name: 'Novel 3',
|
||||
});
|
||||
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId2 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category B' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
const categoryId2 = await insertTestCategory(testDb, {
|
||||
name: 'Category B',
|
||||
});
|
||||
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
@@ -288,11 +326,9 @@ describe('LibraryQueries', () => {
|
||||
name: 'Novel 2',
|
||||
});
|
||||
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
@@ -307,16 +343,12 @@ describe('LibraryQueries', () => {
|
||||
inLibrary: true,
|
||||
name: 'Novel 1',
|
||||
});
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId2 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category B' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
const categoryId2 = await insertTestCategory(testDb, {
|
||||
name: 'Category B',
|
||||
});
|
||||
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
|
||||
@@ -329,11 +361,9 @@ describe('LibraryQueries', () => {
|
||||
await insertTestNovel(testDb, { inLibrary: true, name: 'Novel 1' });
|
||||
await insertTestNovel(testDb, { inLibrary: true, name: 'Novel 2' });
|
||||
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
|
||||
// No novel-category associations made for categoryId1
|
||||
|
||||
@@ -358,11 +388,9 @@ describe('LibraryQueries', () => {
|
||||
name: 'Novel 2',
|
||||
status: 'Completed',
|
||||
});
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
|
||||
@@ -382,11 +410,9 @@ describe('LibraryQueries', () => {
|
||||
name: 'Novel 2',
|
||||
status: 'Completed',
|
||||
});
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
|
||||
@@ -406,11 +432,9 @@ describe('LibraryQueries', () => {
|
||||
name: 'Novel 2',
|
||||
isLocal: false,
|
||||
});
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
|
||||
@@ -430,11 +454,9 @@ describe('LibraryQueries', () => {
|
||||
name: 'Novel 2',
|
||||
isLocal: false,
|
||||
});
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
|
||||
@@ -457,16 +479,12 @@ describe('LibraryQueries', () => {
|
||||
name: 'Novel 2',
|
||||
});
|
||||
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId2 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category B' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
const categoryId2 = await insertTestCategory(testDb, {
|
||||
name: 'Category B',
|
||||
});
|
||||
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId2); // Novel 1 in two categories
|
||||
@@ -495,11 +513,9 @@ describe('LibraryQueries', () => {
|
||||
});
|
||||
await insertTestNovel(testDb, { inLibrary: false, name: 'Novel 3' });
|
||||
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
@@ -520,11 +536,9 @@ describe('LibraryQueries', () => {
|
||||
});
|
||||
await insertTestNovel(testDb, { inLibrary: false, name: 'Novel 3' });
|
||||
|
||||
const categoryId1 = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Category A' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId1 = await insertTestCategory(testDb, {
|
||||
name: 'Category A',
|
||||
});
|
||||
|
||||
await insertTestNovelCategory(testDb, novelId1, categoryId1);
|
||||
await insertTestNovelCategory(testDb, novelId2, categoryId1);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { setupTestDatabase, getTestDb, teardownTestDatabase } from './setup';
|
||||
import {
|
||||
insertTestNovel,
|
||||
insertTestNovelCategory,
|
||||
insertTestCategory,
|
||||
clearAllTables,
|
||||
} from './testData';
|
||||
import { categorySchema, novelCategorySchema } from '@database/schema';
|
||||
@@ -147,9 +148,9 @@ describe('NovelQueries', () => {
|
||||
'test-plugin',
|
||||
);
|
||||
|
||||
expect(result?.inLibrary).toBe(true);
|
||||
const novel = await getNovelById(novelId);
|
||||
expect(novel?.inLibrary).toBe(true);
|
||||
expect(Boolean(result?.inLibrary)).toBe(true);
|
||||
const novel = getNovelById(novelId);
|
||||
expect(Boolean(novel?.inLibrary)).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove novel from library', async () => {
|
||||
@@ -165,9 +166,9 @@ describe('NovelQueries', () => {
|
||||
'test-plugin',
|
||||
);
|
||||
|
||||
expect(result?.inLibrary).toBe(false);
|
||||
const novel = await getNovelById(novelId);
|
||||
expect(novel?.inLibrary).toBe(false);
|
||||
expect(Boolean(result?.inLibrary)).toBe(false);
|
||||
const novel = getNovelById(novelId);
|
||||
expect(Boolean(novel?.inLibrary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should assign default category when adding to library', async () => {
|
||||
@@ -211,8 +212,8 @@ describe('NovelQueries', () => {
|
||||
|
||||
const novel1 = await getNovelById(novelId1);
|
||||
const novel2 = await getNovelById(novelId2);
|
||||
expect(novel1?.inLibrary).toBe(false);
|
||||
expect(novel2?.inLibrary).toBe(false);
|
||||
expect(Boolean(novel1?.inLibrary)).toBe(false);
|
||||
expect(Boolean(novel2?.inLibrary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty array', async () => {
|
||||
@@ -222,11 +223,9 @@ describe('NovelQueries', () => {
|
||||
it('should clean up categories when removing from library', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const categoryId = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Test Category' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId = await insertTestCategory(testDb, {
|
||||
name: 'Test Category',
|
||||
});
|
||||
await insertTestNovelCategory(testDb, novelId, categoryId);
|
||||
|
||||
await removeNovelsFromLibrary([novelId]);
|
||||
@@ -379,11 +378,9 @@ describe('NovelQueries', () => {
|
||||
it('should add categories to a novel', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const categoryId = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Test Category' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId = await insertTestCategory(testDb, {
|
||||
name: 'Test Category',
|
||||
});
|
||||
|
||||
await updateNovelCategoryById(novelId, [categoryId]);
|
||||
|
||||
@@ -402,11 +399,9 @@ describe('NovelQueries', () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId1 = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const novelId2 = await insertTestNovel(testDb, { inLibrary: true });
|
||||
const categoryId = await testDb.drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values({ name: 'Test Category' })
|
||||
.returning()
|
||||
.get().id;
|
||||
const categoryId = await insertTestCategory(testDb, {
|
||||
name: 'Test Category',
|
||||
});
|
||||
|
||||
await updateNovelCategories([novelId1, novelId2], [categoryId]);
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
// @ts-ignore
|
||||
global.__DEV__ ??= false;
|
||||
|
||||
import { createTestDb, cleanupTestDb, type TestDb } from './testDb';
|
||||
import type { TestDb } from './testDb';
|
||||
|
||||
const getTestDbModule = () => require('./testDb') as typeof import('./testDb');
|
||||
|
||||
// Module-level variable to hold the test database
|
||||
// Using 'mock' prefix so Jest allows it in jest.mock() factory
|
||||
@@ -19,6 +21,7 @@ let mockTestDbInstance: TestDb | null = null;
|
||||
* This should be called in beforeEach of test files
|
||||
*/
|
||||
export function setupTestDatabase(): TestDb {
|
||||
const { createTestDb, cleanupTestDb } = getTestDbModule();
|
||||
if (mockTestDbInstance) {
|
||||
cleanupTestDb(mockTestDbInstance);
|
||||
}
|
||||
@@ -42,6 +45,7 @@ export function getTestDb(): TestDb {
|
||||
* Cleans up the test database
|
||||
*/
|
||||
export function teardownTestDatabase() {
|
||||
const { cleanupTestDb } = getTestDbModule();
|
||||
if (mockTestDbInstance) {
|
||||
cleanupTestDb(mockTestDbInstance);
|
||||
mockTestDbInstance = null;
|
||||
@@ -114,43 +118,6 @@ jest.mock('expo-document-picker', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock database utilities
|
||||
jest.mock('@database/utils/parser', () => {
|
||||
const { sql } = require('drizzle-orm');
|
||||
return {
|
||||
chapterFilterToSQL: jest.fn().mockImplementation(filter => {
|
||||
if (!filter || !filter.length) return undefined;
|
||||
const map: Record<string, string> = {
|
||||
'read': '`unread`=0',
|
||||
'not-read': '`unread`=1',
|
||||
'downloaded': 'isDownloaded=1',
|
||||
'not-downloaded': 'isDownloaded=0',
|
||||
'bookmarked': 'bookmark=1',
|
||||
'not-bookmarked': 'bookmark=0',
|
||||
};
|
||||
const parts = filter.map((f: string) => map[f]).filter(Boolean);
|
||||
if (!parts.length) return undefined;
|
||||
return sql.raw(parts.join(' AND '));
|
||||
}),
|
||||
chapterOrderToSQL: jest.fn().mockReturnValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock database constants
|
||||
jest.mock('@database/constants', () => ({
|
||||
ChapterFilterKey: {
|
||||
UNREAD: 'unread',
|
||||
DOWNLOADED: 'downloaded',
|
||||
BOOKMARKED: 'bookmarked',
|
||||
},
|
||||
ChapterOrderKey: {
|
||||
BY_SOURCE: 'bySource',
|
||||
BY_SOURCE_DESC: 'bySourceDesc',
|
||||
BY_CHAPTER_NUMBER: 'byChapterNumber',
|
||||
BY_CHAPTER_NUMBER_DESC: 'byChapterNumberDesc',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock lodash-es to avoid ES module issues
|
||||
jest.mock('lodash-es', () => {
|
||||
const lodash = jest.requireActual('lodash');
|
||||
|
||||
@@ -21,13 +21,11 @@ import {
|
||||
*/
|
||||
export function clearAllTables(testDb: TestDb) {
|
||||
const { sqlite } = testDb;
|
||||
sqlite.exec(`
|
||||
DELETE FROM NovelCategory;
|
||||
DELETE FROM Chapter;
|
||||
DELETE FROM Novel;
|
||||
DELETE FROM Repository;
|
||||
DELETE FROM Category WHERE id > 2;
|
||||
`);
|
||||
sqlite.executeSync('DELETE FROM NovelCategory');
|
||||
sqlite.executeSync('DELETE FROM Chapter');
|
||||
sqlite.executeSync('DELETE FROM Novel');
|
||||
sqlite.executeSync('DELETE FROM Repository');
|
||||
sqlite.executeSync('DELETE FROM Category WHERE id > 2');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +58,7 @@ export async function insertTestNovel(
|
||||
...data,
|
||||
};
|
||||
|
||||
const result = drizzleDb
|
||||
const result = await drizzleDb
|
||||
.insert(novelSchema)
|
||||
.values(novelData)
|
||||
.returning()
|
||||
@@ -96,7 +94,7 @@ export async function insertTestChapter(
|
||||
novelId,
|
||||
};
|
||||
|
||||
const result = drizzleDb
|
||||
const result = await drizzleDb
|
||||
.insert(chapterSchema)
|
||||
.values(chapterData)
|
||||
.returning()
|
||||
@@ -118,7 +116,7 @@ export async function insertTestCategory(
|
||||
sort: data.sort ?? null,
|
||||
};
|
||||
|
||||
const result = drizzleDb
|
||||
const result = await drizzleDb
|
||||
.insert(categorySchema)
|
||||
.values(categoryData)
|
||||
.returning()
|
||||
@@ -139,7 +137,7 @@ export async function insertTestRepository(
|
||||
url: data.url ?? `https://test-repo-${Date.now()}.example.com`,
|
||||
};
|
||||
|
||||
const result = drizzleDb
|
||||
const result = await drizzleDb
|
||||
.insert(repositorySchema)
|
||||
.values(repoData)
|
||||
.returning()
|
||||
@@ -162,7 +160,7 @@ export async function insertTestNovelCategory(
|
||||
categoryId,
|
||||
};
|
||||
|
||||
const result = drizzleDb
|
||||
const result = await drizzleDb
|
||||
.insert(novelCategorySchema)
|
||||
.values(data)
|
||||
.returning()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Test database factory for creating in-memory SQLite databases
|
||||
* Uses better-sqlite3 for Node.js testing environment
|
||||
* Uses op-sqlite Node runtime for Jest testing environment
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { open } from '@op-engineering/op-sqlite';
|
||||
import { drizzle } from 'drizzle-orm/op-sqlite';
|
||||
import { schema } from '@database/schema';
|
||||
import { createTestDbManager } from './testDbManager';
|
||||
import {
|
||||
__resetDbManagerForTests,
|
||||
createDbManager,
|
||||
} from '@database/manager/manager';
|
||||
import {
|
||||
createCategoryTriggerQuery,
|
||||
createNovelTriggerQueryDelete,
|
||||
@@ -84,20 +87,23 @@ const MIGRATION_STATEMENTS = [
|
||||
*/
|
||||
export function createTestDb() {
|
||||
// Create in-memory database
|
||||
const sqlite = new Database(':memory:');
|
||||
const sqlite = open({ name: ':memory:' });
|
||||
// drizzle-orm/op-sqlite expects executeAsync on the client
|
||||
(sqlite as any).executeAsync ??= sqlite.execute;
|
||||
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
|
||||
|
||||
// Set pragmas for better performance and behavior
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('synchronous = NORMAL');
|
||||
sqlite.pragma('temp_store = MEMORY');
|
||||
sqlite.pragma('busy_timeout = 5000');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.executeSync('PRAGMA journal_mode = WAL');
|
||||
sqlite.executeSync('PRAGMA synchronous = NORMAL');
|
||||
sqlite.executeSync('PRAGMA temp_store = MEMORY');
|
||||
sqlite.executeSync('PRAGMA busy_timeout = 5000');
|
||||
sqlite.executeSync('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Run migration SQL to create tables
|
||||
// Execute each statement separately
|
||||
for (const statement of MIGRATION_STATEMENTS) {
|
||||
try {
|
||||
sqlite.exec(statement.trim());
|
||||
sqlite.executeSync(statement.trim());
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
console.error('Failed statement:', statement);
|
||||
@@ -106,9 +112,9 @@ export function createTestDb() {
|
||||
}
|
||||
|
||||
// Verify tables were created (for debugging)
|
||||
const tables = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all();
|
||||
const tables = sqlite.executeSync(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||
).rows;
|
||||
const tableNames = tables
|
||||
.map((t: any) => t.name)
|
||||
.filter((n: string) => n !== 'sqlite_sequence');
|
||||
@@ -123,23 +129,26 @@ export function createTestDb() {
|
||||
}
|
||||
|
||||
// Create Drizzle instance
|
||||
const drizzleDb = drizzle({ client: sqlite, schema });
|
||||
const drizzleDb = drizzle(sqlite, { schema });
|
||||
|
||||
// Ensure singleton manager is bound to this test DB instance
|
||||
__resetDbManagerForTests();
|
||||
|
||||
// Create triggers (same as production)
|
||||
sqlite.exec(createNovelTriggerQueryInsert);
|
||||
sqlite.exec(createNovelTriggerQueryUpdate);
|
||||
sqlite.exec(createNovelTriggerQueryDelete);
|
||||
sqlite.exec(createCategoryTriggerQuery);
|
||||
sqlite.executeSync(createNovelTriggerQueryInsert);
|
||||
sqlite.executeSync(createNovelTriggerQueryUpdate);
|
||||
sqlite.executeSync(createNovelTriggerQueryDelete);
|
||||
sqlite.executeSync(createCategoryTriggerQuery);
|
||||
|
||||
// Populate default categories
|
||||
sqlite.exec(`
|
||||
sqlite.executeSync(`
|
||||
INSERT OR IGNORE INTO Category (id, name, sort) VALUES
|
||||
(1, 'Default', 1),
|
||||
(2, 'Local', 2)
|
||||
`);
|
||||
|
||||
// Create test-compatible dbManager
|
||||
const dbManager = createTestDbManager(drizzleDb, sqlite);
|
||||
const dbManager = createDbManager(drizzleDb);
|
||||
|
||||
return {
|
||||
sqlite,
|
||||
@@ -153,6 +162,7 @@ export function createTestDb() {
|
||||
*/
|
||||
export function cleanupTestDb(testDb: ReturnType<typeof createTestDb>) {
|
||||
testDb.sqlite.close();
|
||||
__resetDbManagerForTests();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Test-specific dbManager implementation for better-sqlite3
|
||||
* Provides compatibility with op-sqlite specific methods
|
||||
*/
|
||||
|
||||
import type { IDbManager } from '@database/manager/manager.d';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import { Placeholder, sql as drizzleSql } from 'drizzle-orm';
|
||||
import { SQLitePreparedQuery } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
interface ExecutableSelect<TResult = any> {
|
||||
toSQL(): { sql: string; params: unknown[] };
|
||||
get(): Promise<TResult | undefined>;
|
||||
all(): Promise<TResult[]>;
|
||||
}
|
||||
|
||||
type DrizzleDb = BetterSQLite3Database<any>;
|
||||
type TransactionParameter = Parameters<
|
||||
Parameters<DrizzleDb['transaction']>[0]
|
||||
>[0];
|
||||
|
||||
const isBuilderLike = (value: unknown): value is Record<string, unknown> => {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as { get?: unknown }).get === 'function' &&
|
||||
typeof (value as { all?: unknown }).all === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
const wrapBuilder = <T extends object>(builder: T): T => {
|
||||
return new Proxy(builder, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
if (prop === 'get' && typeof value === 'function') {
|
||||
return (...args: unknown[]) =>
|
||||
Promise.resolve(value.apply(target, args as never[]));
|
||||
}
|
||||
if (prop === 'all' && typeof value === 'function') {
|
||||
return (...args: unknown[]) =>
|
||||
Promise.resolve(value.apply(target, args as never[]));
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
return (...args: unknown[]) => {
|
||||
const result = value.apply(target, args as never[]);
|
||||
return isBuilderLike(result) ? wrapBuilder(result as object) : result;
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a test-compatible dbManager that works with better-sqlite3
|
||||
*/
|
||||
export function createTestDbManager(
|
||||
drizzleDb: DrizzleDb,
|
||||
sqlite: Database.Database,
|
||||
): IDbManager {
|
||||
// Create a wrapper that implements the IDbManager interface
|
||||
const dbManager = {
|
||||
// Drizzle methods - delegate to drizzleDb
|
||||
select: (...args: Parameters<DrizzleDb['select']>) =>
|
||||
wrapBuilder(drizzleDb.select(...args)),
|
||||
selectDistinct: (...args: Parameters<DrizzleDb['selectDistinct']>) =>
|
||||
wrapBuilder(drizzleDb.selectDistinct(...args)),
|
||||
$count: drizzleDb.$count.bind(drizzleDb),
|
||||
query: drizzleDb.query,
|
||||
run: drizzleDb.run.bind(drizzleDb),
|
||||
with: (...args: Parameters<DrizzleDb['with']>) =>
|
||||
wrapBuilder(drizzleDb.with(...args)),
|
||||
$with: (...args: Parameters<DrizzleDb['$with']>) =>
|
||||
wrapBuilder(drizzleDb.$with(...args)),
|
||||
all: (...args: Parameters<DrizzleDb['all']>) =>
|
||||
Promise.resolve(drizzleDb.all(...args)),
|
||||
get: (...args: Parameters<DrizzleDb['get']>) =>
|
||||
Promise.resolve(drizzleDb.get(...args)),
|
||||
values: (...args: Parameters<DrizzleDb['values']>) =>
|
||||
Promise.resolve(drizzleDb.values(...args)),
|
||||
|
||||
// Test-compatible implementations of op-sqlite specific methods
|
||||
getSync<T extends ExecutableSelect>(
|
||||
query: T,
|
||||
): Awaited<ReturnType<T['get']>> {
|
||||
const { sql, params } = query.toSQL();
|
||||
const stmt = sqlite.prepare(sql);
|
||||
const result = stmt.get(params as any[]) as Awaited<ReturnType<T['get']>>;
|
||||
return result;
|
||||
},
|
||||
|
||||
async allSync<T extends ExecutableSelect>(
|
||||
query: T,
|
||||
): Promise<Awaited<ReturnType<T['all']>>> {
|
||||
const { sql, params } = query.toSQL();
|
||||
const stmt = sqlite.prepare(sql);
|
||||
const results = stmt.all(params as any[]) as Awaited<
|
||||
ReturnType<T['all']>
|
||||
>;
|
||||
return results;
|
||||
},
|
||||
|
||||
async batch<T extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
fn: (
|
||||
tx: TransactionParameter,
|
||||
ph: (arg: Extract<keyof T, string>) => Placeholder,
|
||||
) => SQLitePreparedQuery<any>,
|
||||
) {
|
||||
const ph = (arg: Extract<keyof T, string>) => drizzleSql.placeholder(arg);
|
||||
await this.write(async tx => {
|
||||
const prep = fn(tx, ph);
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
prep.run(data[index]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// better-sqlite3 can't handle an async transaction function
|
||||
async write<T>(fn: (tx: TransactionParameter) => Promise<T>): Promise<T> {
|
||||
const result = await fn(drizzleDb as any);
|
||||
|
||||
return result;
|
||||
},
|
||||
async transaction<T>(
|
||||
fn: (tx: TransactionParameter) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await this.write(fn);
|
||||
},
|
||||
};
|
||||
|
||||
return dbManager;
|
||||
}
|
||||
@@ -3,16 +3,21 @@ AFTER INSERT ON Chapter
|
||||
BEGIN
|
||||
UPDATE Novel
|
||||
SET
|
||||
totalChapters = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id),
|
||||
chaptersDownloaded = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id AND Chapter.isDownloaded = 1),
|
||||
chaptersUnread = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id AND Chapter.unread = 1),
|
||||
lastUpdatedAt = (SELECT MAX(updatedTime) FROM Chapter WHERE Chapter.novelId = Novel.id)
|
||||
totalChapters = totalChapters + 1,
|
||||
chaptersDownloaded = chaptersDownloaded + CASE WHEN NEW.isDownloaded = 1 THEN 1 ELSE 0 END,
|
||||
chaptersUnread = chaptersUnread + CASE WHEN NEW.unread = 1 THEN 1 ELSE 0 END,
|
||||
lastUpdatedAt = CASE
|
||||
WHEN NEW.updatedTime IS NOT NULL
|
||||
AND (lastUpdatedAt IS NULL OR NEW.updatedTime > lastUpdatedAt)
|
||||
THEN NEW.updatedTime
|
||||
ELSE lastUpdatedAt
|
||||
END
|
||||
WHERE id = NEW.novelId;
|
||||
END;
|
||||
|
||||
`;
|
||||
export const createNovelTriggerQueryUpdate = `CREATE TRIGGER IF NOT EXISTS update_novel_stats_on_update
|
||||
AFTER UPDATE ON Chapter
|
||||
AFTER UPDATE OF isDownloaded, unread, readTime, updatedTime ON Chapter
|
||||
BEGIN
|
||||
UPDATE Novel
|
||||
SET
|
||||
@@ -42,4 +47,4 @@ export const createCategoryTriggerQuery = `
|
||||
BEGIN
|
||||
UPDATE Category SET sort = (SELECT IFNULL(sort, new.id)) WHERE id = new.id;
|
||||
END;
|
||||
`;
|
||||
`;
|
||||
|
||||
@@ -10,6 +10,12 @@ const FILTER_STATES = {
|
||||
} as const;
|
||||
export type FilterStates = typeof FILTER_STATES;
|
||||
|
||||
export type FilterObject = {
|
||||
unread?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
bookmark?: boolean;
|
||||
};
|
||||
|
||||
export class ChapterFilterObject {
|
||||
private filter: Map<
|
||||
ChapterFilterPositiveKey,
|
||||
@@ -51,6 +57,26 @@ export class ChapterFilterObject {
|
||||
return res as ChapterFilterKey[];
|
||||
}
|
||||
|
||||
toFilterObject(): FilterObject {
|
||||
const result: FilterObject = {};
|
||||
for (const [key, value] of this.filter.entries()) {
|
||||
if (value === FILTER_STATES.OFF) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'read':
|
||||
result.unread = value !== FILTER_STATES.ON;
|
||||
break;
|
||||
case 'downloaded':
|
||||
result.isDownloaded = value === FILTER_STATES.ON;
|
||||
break;
|
||||
case 'bookmarked':
|
||||
result.bookmark = value === FILTER_STATES.ON;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
set(key: ChapterFilterPositiveKey, value: keyof typeof FILTER_STATES) {
|
||||
this.filter.set(key, FILTER_STATES[value]);
|
||||
this.setState([...this.toArray()]);
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from '@database/constants';
|
||||
import { SQL, sql } from 'drizzle-orm';
|
||||
|
||||
export function chapterOrderToSQL(order: ChapterOrderKey) {
|
||||
export function chapterOrderToSQL(order?: ChapterOrderKey) {
|
||||
if (!order) return sql.raw(CHAPTER_ORDER.positionAsc);
|
||||
const o = CHAPTER_ORDER[order] ?? CHAPTER_ORDER.positionAsc;
|
||||
return sql.raw(o);
|
||||
}
|
||||
|
||||
@@ -29,3 +29,123 @@ jest.mock('@hooks/persisted/useTrackedNovel');
|
||||
jest.mock('@hooks/persisted/useUpdates');
|
||||
jest.mock('@services/plugin/fetch');
|
||||
jest.mock('@components/Context/LibraryContext');
|
||||
|
||||
const createMockChapterTextCache = () => {
|
||||
const cache = new Map<number, string | Promise<string>>();
|
||||
|
||||
return {
|
||||
read: jest.fn((chapterId: number) => cache.get(chapterId)),
|
||||
write: jest.fn((chapterId: number, value: string | Promise<string>) => {
|
||||
cache.set(chapterId, value);
|
||||
}),
|
||||
remove: jest.fn((chapterId: number) => cache.delete(chapterId)),
|
||||
clear: jest.fn(() => cache.clear()),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockNovelStoreState = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
) => ({
|
||||
loading: false,
|
||||
fetching: false,
|
||||
pageIndex: 0,
|
||||
pages: ['1'],
|
||||
novel: undefined,
|
||||
chapters: [],
|
||||
firstUnreadChapter: undefined,
|
||||
batchInformation: {
|
||||
batch: 0,
|
||||
total: 0,
|
||||
},
|
||||
novelSettings: {
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
},
|
||||
chapterTextCache: createMockChapterTextCache(),
|
||||
lastRead: undefined,
|
||||
|
||||
bootstrapNovel: jest.fn().mockResolvedValue(true),
|
||||
getChapters: jest.fn().mockResolvedValue(undefined),
|
||||
getNextChapterBatch: jest.fn().mockResolvedValue(undefined),
|
||||
loadUpToBatch: jest.fn().mockResolvedValue(undefined),
|
||||
refreshNovel: jest.fn().mockResolvedValue(undefined),
|
||||
|
||||
setNovel: jest.fn(),
|
||||
setPages: jest.fn(),
|
||||
setPageIndex: jest.fn(),
|
||||
openPage: jest.fn().mockResolvedValue(undefined),
|
||||
setNovelSettings: jest.fn(),
|
||||
setLastRead: jest.fn(),
|
||||
followNovel: jest.fn(),
|
||||
|
||||
updateChapter: jest.fn(),
|
||||
setChapters: jest.fn(),
|
||||
extendChapters: jest.fn(),
|
||||
|
||||
bookmarkChapters: jest.fn(),
|
||||
markPreviouschaptersRead: jest.fn(),
|
||||
markChapterRead: jest.fn(),
|
||||
markChaptersRead: jest.fn(),
|
||||
markPreviousChaptersUnread: jest.fn(),
|
||||
markChaptersUnread: jest.fn(),
|
||||
updateChapterProgress: jest.fn(),
|
||||
deleteChapter: jest.fn(),
|
||||
deleteChapters: jest.fn(),
|
||||
refreshChapters: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockNovelStore = (
|
||||
stateOverrides: Record<string, unknown> = {},
|
||||
) => {
|
||||
let state = createMockNovelStoreState(stateOverrides);
|
||||
|
||||
return {
|
||||
getState: jest.fn(() => state),
|
||||
setState: jest.fn(nextState => {
|
||||
const partial =
|
||||
typeof nextState === 'function' ? nextState(state) : nextState;
|
||||
state = {
|
||||
...state,
|
||||
...partial,
|
||||
};
|
||||
}),
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
};
|
||||
};
|
||||
|
||||
const defaultMockNovelContext = {
|
||||
novelStore: createMockNovelStore(),
|
||||
navigationBarHeight: 0,
|
||||
statusBarHeight: 0,
|
||||
};
|
||||
|
||||
export const mockUseNovelContext = jest.fn(() => defaultMockNovelContext);
|
||||
export const mockUseNovelValue = jest.fn((key: string) => {
|
||||
const state = mockUseNovelContext()?.novelStore?.getState?.() ?? {};
|
||||
return state[key as keyof typeof state];
|
||||
});
|
||||
export const mockUseNovelState = jest.fn((selector: (state: any) => unknown) => {
|
||||
const state = mockUseNovelContext()?.novelStore?.getState?.() ?? {};
|
||||
return selector(state);
|
||||
});
|
||||
export const mockUseNovelActions = jest.fn(() => {
|
||||
const state = mockUseNovelContext()?.novelStore?.getState?.() ?? {};
|
||||
const stateWithOptionalActions = state as Record<string, unknown> & {
|
||||
actions?: Record<string, unknown>;
|
||||
};
|
||||
return stateWithOptionalActions.actions ?? stateWithOptionalActions;
|
||||
});
|
||||
export const mockUseNovelAction = jest.fn((key: string) => {
|
||||
const actions = mockUseNovelActions();
|
||||
return actions?.[key];
|
||||
});
|
||||
|
||||
jest.mock('@screens/novel/NovelContext', () => ({
|
||||
useNovelContext: () => mockUseNovelContext(),
|
||||
useNovelValue: (key: string) => mockUseNovelValue(key),
|
||||
useNovelState: (selector: (state: any) => unknown) =>
|
||||
mockUseNovelState(selector),
|
||||
useNovelActions: () => mockUseNovelActions(),
|
||||
useNovelAction: (key: string) => mockUseNovelAction(key),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
createMockNovelStore,
|
||||
createMockNovelStoreState,
|
||||
mockUseNovelContext,
|
||||
} from './mocks';
|
||||
import {
|
||||
LAST_READ_PREFIX,
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
defaultNovelSettings as novelDefaultNovelSettings,
|
||||
defaultPageIndex,
|
||||
deleteCachedNovels,
|
||||
useNovel,
|
||||
} from '@hooks/persisted/useNovel';
|
||||
import { useNovelSettings } from '@hooks/persisted/useNovelSettings';
|
||||
|
||||
jest.mock('@hooks/persisted/useNovel');
|
||||
|
||||
describe('mock contracts (zustand novel architecture)', () => {
|
||||
it('useNovel mock exports persistence constants and compatibility helpers', () => {
|
||||
expect(NOVEL_PAGE_INDEX_PREFIX).toBe('NOVEL_PAGE_INDEX_PREFIX');
|
||||
expect(NOVEL_SETTINGS_PREFIX).toBe('NOVEL_SETTINGS');
|
||||
expect(LAST_READ_PREFIX).toBe('LAST_READ_PREFIX');
|
||||
expect(defaultPageIndex).toBe(0);
|
||||
expect(novelDefaultNovelSettings).toEqual({
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
});
|
||||
|
||||
expect(typeof useNovel).toBe('function');
|
||||
expect(jest.isMockFunction(useNovel)).toBe(true);
|
||||
expect(jest.isMockFunction(deleteCachedNovels)).toBe(true);
|
||||
});
|
||||
|
||||
it('useNovel mock state includes store-era action and cache surface', () => {
|
||||
const state = useNovel() as unknown as {
|
||||
chapterTextCache: {
|
||||
read: unknown;
|
||||
write: unknown;
|
||||
remove: unknown;
|
||||
clear: unknown;
|
||||
};
|
||||
} & Record<string, unknown>;
|
||||
|
||||
const requiredMembers = [
|
||||
'loading',
|
||||
'fetching',
|
||||
'pageIndex',
|
||||
'pages',
|
||||
'novel',
|
||||
'chapters',
|
||||
'firstUnreadChapter',
|
||||
'batchInformation',
|
||||
'novelSettings',
|
||||
'lastRead',
|
||||
'bootstrapNovel',
|
||||
'getChapters',
|
||||
'getNextChapterBatch',
|
||||
'loadUpToBatch',
|
||||
'refreshNovel',
|
||||
'setNovel',
|
||||
'setPages',
|
||||
'setPageIndex',
|
||||
'openPage',
|
||||
'setNovelSettings',
|
||||
'setLastRead',
|
||||
'followNovel',
|
||||
'updateChapter',
|
||||
'setChapters',
|
||||
'extendChapters',
|
||||
'bookmarkChapters',
|
||||
'markPreviouschaptersRead',
|
||||
'markChapterRead',
|
||||
'markChaptersRead',
|
||||
'markPreviousChaptersUnread',
|
||||
'markChaptersUnread',
|
||||
'updateChapterProgress',
|
||||
'deleteChapter',
|
||||
'deleteChapters',
|
||||
'refreshChapters',
|
||||
'chapterTextCache',
|
||||
] as const;
|
||||
|
||||
requiredMembers.forEach(member => {
|
||||
expect(state).toHaveProperty(member);
|
||||
});
|
||||
|
||||
expect(state.chapterTextCache).toEqual(
|
||||
expect.objectContaining({
|
||||
read: expect.any(Function),
|
||||
write: expect.any(Function),
|
||||
remove: expect.any(Function),
|
||||
clear: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('useNovelSettings mock keeps settings API contract available', () => {
|
||||
const result = useNovelSettings() as unknown as Record<string, unknown>;
|
||||
|
||||
expect({
|
||||
sort: result.sort,
|
||||
filter: result.filter,
|
||||
showChapterTitles: result.showChapterTitles,
|
||||
}).toEqual({
|
||||
sort: undefined,
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
});
|
||||
|
||||
[
|
||||
'sort',
|
||||
'filter',
|
||||
'showChapterTitles',
|
||||
'cycleChapterFilter',
|
||||
'setChapterFilter',
|
||||
'setChapterFilterValue',
|
||||
'getChapterFilterState',
|
||||
'getChapterFilter',
|
||||
'setChapterSort',
|
||||
'setShowChapterTitles',
|
||||
].forEach(member => {
|
||||
expect(result).toHaveProperty(member);
|
||||
});
|
||||
});
|
||||
|
||||
it('test harness mock context exposes subscribable novelStore boundary', () => {
|
||||
const state = createMockNovelStoreState();
|
||||
const store = createMockNovelStore();
|
||||
const context = mockUseNovelContext();
|
||||
|
||||
[
|
||||
'bootstrapNovel',
|
||||
'getChapters',
|
||||
'getNextChapterBatch',
|
||||
'setPageIndex',
|
||||
'openPage',
|
||||
'setNovelSettings',
|
||||
'setLastRead',
|
||||
'updateChapter',
|
||||
'refreshChapters',
|
||||
'chapterTextCache',
|
||||
].forEach(member => {
|
||||
expect(state).toHaveProperty(member);
|
||||
});
|
||||
|
||||
expect(store).toEqual(
|
||||
expect.objectContaining({
|
||||
getState: expect.any(Function),
|
||||
setState: expect.any(Function),
|
||||
subscribe: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(context).toEqual(
|
||||
expect.objectContaining({
|
||||
novelStore: expect.objectContaining({
|
||||
getState: expect.any(Function),
|
||||
subscribe: expect.any(Function),
|
||||
}),
|
||||
navigationBarHeight: expect.any(Number),
|
||||
statusBarHeight: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,494 +1,98 @@
|
||||
import './mocks';
|
||||
import { renderHook, act, waitFor } from '@test-utils';
|
||||
import { useNovel, deleteCachedNovels } from '@hooks/persisted/useNovel';
|
||||
import { deleteCachedNovels, useNovel } from '@hooks/persisted/useNovel';
|
||||
import {
|
||||
getNovelByPath,
|
||||
insertNovelAndChapters,
|
||||
getCachedNovels as _getCachedNovels,
|
||||
deleteCachedNovels as _deleteCachedNovels,
|
||||
} from '@database/queries/NovelQueries';
|
||||
import {
|
||||
getChapterCount,
|
||||
getCustomPages,
|
||||
getPageChaptersBatched,
|
||||
insertChapters,
|
||||
getFirstUnreadChapter as _getFirstUnreadChapter,
|
||||
getPageChapters as _getPageChapters,
|
||||
markChapterRead as _markChapterRead,
|
||||
markChaptersRead as _markChaptersRead,
|
||||
markChaptersUnread as _markChaptersUnread,
|
||||
markPreviousChaptersUnread as _markPreviousChaptersUnread,
|
||||
markPreviuschaptersRead as _markPreviuschaptersRead,
|
||||
deleteChapter as _deleteChapter,
|
||||
deleteChapters as _deleteChapters,
|
||||
bookmarkChapter as _bookmarkChapter,
|
||||
updateChapterProgress as _updateChapterProgress,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import { fetchNovel, fetchPage } from '@services/plugin/fetch';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { TRACKED_NOVEL_PREFIX } from '@hooks/persisted/useTrackedNovel';
|
||||
import {
|
||||
keyContract,
|
||||
novelPersistence,
|
||||
} from '@hooks/persisted/useNovel/store-helper/contracts';
|
||||
|
||||
// --- fixtures ---
|
||||
|
||||
const PLUGIN_ID = 'test-plugin';
|
||||
|
||||
const mockNovel: NovelInfo = {
|
||||
id: 1,
|
||||
path: '/novel/test',
|
||||
pluginId: PLUGIN_ID,
|
||||
name: 'Test Novel',
|
||||
inLibrary: false,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
const makeChapter = (id: number, overrides = {}): ChapterInfo => ({
|
||||
id,
|
||||
novelId: mockNovel.id,
|
||||
name: `Chapter ${id}`,
|
||||
path: `/chapter/${id}`,
|
||||
releaseTime: '2024-01-01',
|
||||
updatedTime: '2024-01-02',
|
||||
readTime: '2024-01-03',
|
||||
chapterNumber: id,
|
||||
unread: true,
|
||||
isDownloaded: false,
|
||||
bookmark: false,
|
||||
progress: 0,
|
||||
page: '1',
|
||||
...overrides,
|
||||
describe('useNovel (legacy retirement)', () => {
|
||||
it('throws with guidance to use store selectors', () => {
|
||||
expect(() => useNovel()).toThrow(
|
||||
'useNovel has been retired. Access novel domain state/actions via useNovelContext().novelStore selectors.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const mockChapters = [makeChapter(1), makeChapter(2), makeChapter(3)];
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
async function renderUseNovel(novelOrPath: string | NovelInfo = mockNovel) {
|
||||
const utils = renderHook(() => useNovel(novelOrPath, PLUGIN_ID));
|
||||
await waitFor(() => {
|
||||
expect(utils.result.current.fetching).toBe(false);
|
||||
});
|
||||
return utils;
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
describe('useNovel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
MMKVStorage.clearAll();
|
||||
// Default happy-path mocks
|
||||
(getNovelByPath as jest.Mock).mockReturnValue(mockNovel);
|
||||
(getChapterCount as jest.Mock).mockResolvedValue(mockChapters.length);
|
||||
(getPageChaptersBatched as jest.Mock).mockResolvedValue(mockChapters);
|
||||
(_getFirstUnreadChapter as jest.Mock).mockResolvedValue(mockChapters[0]);
|
||||
(getCustomPages as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
// #region initialization
|
||||
|
||||
describe('initialization', () => {
|
||||
it('uses the passed NovelInfo object directly without fetching from DB', async () => {
|
||||
await renderUseNovel(mockNovel);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNovelByPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches novel from DB when a path string is passed', async () => {
|
||||
await renderUseNovel(mockNovel.path);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNovelByPath).toHaveBeenCalledWith(mockNovel.path, PLUGIN_ID);
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches from source and inserts when novel is not in DB', async () => {
|
||||
const sourceNovel = { ...mockNovel, chapters: mockChapters };
|
||||
(getNovelByPath as jest.Mock)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(mockNovel);
|
||||
(fetchNovel as jest.Mock).mockResolvedValue(sourceNovel);
|
||||
|
||||
await renderUseNovel(mockNovel.path);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchNovel).toHaveBeenCalledWith(PLUGIN_ID, mockNovel.path);
|
||||
expect(insertNovelAndChapters).toHaveBeenCalledWith(
|
||||
PLUGIN_ID,
|
||||
sourceNovel,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when source fetch fails and novel is not in DB', async () => {
|
||||
(getNovelByPath as jest.Mock).mockReturnValue(null);
|
||||
(fetchNovel as jest.Mock).mockRejectedValue(new Error('network error'));
|
||||
|
||||
const { result } = renderHook(() => useNovel(mockNovel.path, PLUGIN_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.novel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets loading to false after novel resolves', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets fetching to false after chapters load', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.fetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('populates chapters after load', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.chapters).toHaveLength(mockChapters.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets firstUnreadChapter', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.firstUnreadChapter?.id).toBe(mockChapters[0].id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region pages
|
||||
|
||||
describe('pages', () => {
|
||||
it('builds numeric pages from totalPages when > 0', async () => {
|
||||
const pagedNovel = { ...mockNovel, totalPages: 3 };
|
||||
(getNovelByPath as jest.Mock).mockReturnValue(pagedNovel);
|
||||
|
||||
const { result } = await renderUseNovel(pagedNovel.path);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pages).toEqual(['1', '2', '3']);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to ["1"] when novel has no pages', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pages).toEqual(['1']);
|
||||
});
|
||||
});
|
||||
|
||||
it('reads custom pages from DB when totalPages is 0', async () => {
|
||||
(getCustomPages as jest.Mock).mockResolvedValue([
|
||||
{ page: 'vol1' },
|
||||
{ page: 'vol2' },
|
||||
]);
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pages).toEqual(['vol1', 'vol2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('openPage updates pageIndex', async () => {
|
||||
const pagedNovel = { ...mockNovel, totalPages: 3 };
|
||||
(getNovelByPath as jest.Mock).mockReturnValue(pagedNovel);
|
||||
|
||||
const { result } = await renderUseNovel(pagedNovel.path);
|
||||
|
||||
act(() => result.current.openPage(2));
|
||||
await waitFor(() => expect(result.current.fetching).toBe(false));
|
||||
|
||||
expect(result.current.pageIndex).toBe(2);
|
||||
|
||||
act(() => result.current.openPage(0));
|
||||
await waitFor(() => expect(result.current.fetching).toBe(false));
|
||||
|
||||
expect(result.current.pageIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region chapter batching
|
||||
|
||||
describe('chapter batching', () => {
|
||||
it('sets batchInformation.total based on chapter count', async () => {
|
||||
(getChapterCount as jest.Mock).mockResolvedValue(900);
|
||||
(getPageChaptersBatched as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
await waitFor(() => {
|
||||
// Math.floor(900 / 300) = 3
|
||||
expect(result.current.batchInformation.total).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('getNextChapterBatch appends chapters and increments batch', async () => {
|
||||
const batch1 = Array.from({ length: 2 }, (_, i) => makeChapter(i + 4));
|
||||
(getChapterCount as jest.Mock).mockResolvedValue(600);
|
||||
(getPageChaptersBatched as jest.Mock)
|
||||
.mockResolvedValueOnce(mockChapters) // initial load
|
||||
.mockResolvedValueOnce(batch1); // next batch
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
expect(result.current.chapters).toHaveLength(mockChapters.length);
|
||||
|
||||
await act(() => result.current.getNextChapterBatch());
|
||||
|
||||
expect(result.current.chapters).toHaveLength(
|
||||
mockChapters.length + batch1.length,
|
||||
);
|
||||
expect(result.current.batchInformation.batch).toBe(1);
|
||||
});
|
||||
|
||||
it('getNextChapterBatch does nothing when already at last batch', async () => {
|
||||
(getChapterCount as jest.Mock).mockResolvedValue(mockChapters.length);
|
||||
// total = Math.floor(3 / 300) = 0, so nextBatch(1) > total(0)
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
const chaptersBefore = result.current.chapters.length;
|
||||
await act(() => result.current.getNextChapterBatch());
|
||||
|
||||
expect(result.current.chapters).toHaveLength(chaptersBefore);
|
||||
});
|
||||
|
||||
it('loadUpToBatch loads all intermediate batches sequentially', async () => {
|
||||
(getChapterCount as jest.Mock).mockResolvedValue(900);
|
||||
const batchChapter = makeChapter(99);
|
||||
(getPageChaptersBatched as jest.Mock).mockResolvedValue([batchChapter]);
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
const initialChapterCount = result.current.chapters.length;
|
||||
|
||||
await act(() => result.current.loadUpToBatch(3));
|
||||
|
||||
expect(result.current.batchInformation.batch).toBe(3);
|
||||
// 3 batches loaded, each returning 1 chapter
|
||||
expect(result.current.chapters.length).toBe(initialChapterCount + 3);
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region fetching missing page from source
|
||||
|
||||
describe('fetching missing page chapters from source', () => {
|
||||
it('fetches page from source when chapter count is 0 for that page', async () => {
|
||||
const pagedNovel = { ...mockNovel, totalPages: 2 };
|
||||
(getNovelByPath as jest.Mock).mockReturnValue(pagedNovel);
|
||||
(getChapterCount as jest.Mock)
|
||||
.mockResolvedValueOnce(0) // page 1 missing
|
||||
.mockResolvedValueOnce(2); // after insert
|
||||
(fetchPage as jest.Mock).mockResolvedValue({ chapters: mockChapters });
|
||||
(_getPageChapters as jest.Mock).mockResolvedValue(mockChapters);
|
||||
|
||||
const { result } = await renderUseNovel(pagedNovel.path);
|
||||
|
||||
await waitFor(() => expect(result.current.fetching).toBe(false));
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledWith(PLUGIN_ID, pagedNovel.path, '1');
|
||||
expect(insertChapters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region mark chapters
|
||||
|
||||
describe('markChapterRead', () => {
|
||||
it('marks single chapter as read in state', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.markChapterRead(1));
|
||||
|
||||
expect(_markChapterRead).toHaveBeenCalledWith(1);
|
||||
const ch = result.current.chapters.find(c => c.id === 1);
|
||||
expect(ch?.unread).toBe(false);
|
||||
});
|
||||
|
||||
it('markChaptersRead marks multiple chapters', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() =>
|
||||
result.current.markChaptersRead([mockChapters[0], mockChapters[1]]),
|
||||
);
|
||||
|
||||
expect(_markChaptersRead).toHaveBeenCalledWith([1, 2]);
|
||||
result.current.chapters
|
||||
.filter(c => [1, 2].includes(c.id))
|
||||
.forEach(c => expect(c.unread).toBe(false));
|
||||
});
|
||||
|
||||
it('markChaptersUnread marks multiple chapters', async () => {
|
||||
const readChapters = mockChapters.map(c => ({ ...c, unread: false }));
|
||||
(getPageChaptersBatched as jest.Mock).mockResolvedValue(readChapters);
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.markChaptersUnread([readChapters[0]]));
|
||||
|
||||
expect(_markChaptersUnread).toHaveBeenCalledWith([1]);
|
||||
expect(result.current.chapters.find(c => c.id === 1)?.unread).toBe(true);
|
||||
});
|
||||
|
||||
it('markPreviouschaptersRead marks chapters with id <= given id', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.markPreviouschaptersRead(2));
|
||||
|
||||
expect(_markPreviuschaptersRead).toHaveBeenCalledWith(2, mockNovel.id);
|
||||
result.current.chapters
|
||||
.filter(c => c.id <= 2)
|
||||
.forEach(c => expect(c.unread).toBe(false));
|
||||
expect(result.current.chapters.find(c => c.id === 3)?.unread).toBe(true);
|
||||
});
|
||||
|
||||
it('markPreviousChaptersUnread marks chapters with id <= given id as unread', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.markPreviousChaptersUnread(2));
|
||||
|
||||
expect(_markPreviousChaptersUnread).toHaveBeenCalledWith(2, mockNovel.id);
|
||||
result.current.chapters
|
||||
.filter(c => c.id <= 2)
|
||||
.forEach(c => expect(c.unread).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region bookmark
|
||||
|
||||
describe('bookmarkChapters', () => {
|
||||
it('toggles bookmark state for given chapters', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
const before = result.current.chapters.find(c => c.id === 1)?.bookmark;
|
||||
|
||||
act(() => result.current.bookmarkChapters([mockChapters[0]]));
|
||||
|
||||
expect(_bookmarkChapter).toHaveBeenCalledWith(1);
|
||||
expect(result.current.chapters.find(c => c.id === 1)?.bookmark).toBe(
|
||||
!before,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region progress
|
||||
|
||||
describe('updateChapterProgress', () => {
|
||||
it('updates progress in state and caps at 100', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.updateChapterProgress(1, 150));
|
||||
|
||||
expect(_updateChapterProgress).toHaveBeenCalledWith(1, 100);
|
||||
expect(result.current.chapters.find(c => c.id === 1)?.progress).toBe(150);
|
||||
});
|
||||
|
||||
it('stores the raw progress value in state', async () => {
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.updateChapterProgress(1, 42));
|
||||
|
||||
expect(result.current.chapters.find(c => c.id === 1)?.progress).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region delete
|
||||
|
||||
describe('deleteChapter / deleteChapters', () => {
|
||||
it('sets isDownloaded to false after deleteChapter', async () => {
|
||||
(_deleteChapter as jest.Mock).mockResolvedValue(undefined);
|
||||
const downloaded = mockChapters.map(c => ({ ...c, isDownloaded: true }));
|
||||
(getPageChaptersBatched as jest.Mock).mockResolvedValue(downloaded);
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.deleteChapter(downloaded[0]));
|
||||
|
||||
expect(_deleteChapter).toHaveBeenCalledWith(PLUGIN_ID, mockNovel.id, 1);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
result.current.chapters.find(c => c.id === 1)?.isDownloaded,
|
||||
).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets isDownloaded to false for all deleted chapters', async () => {
|
||||
(_deleteChapters as jest.Mock).mockResolvedValue(undefined);
|
||||
const downloaded = mockChapters.map(c => ({ ...c, isDownloaded: true }));
|
||||
(getPageChaptersBatched as jest.Mock).mockResolvedValue(downloaded);
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
act(() => result.current.deleteChapters([downloaded[0], downloaded[1]]));
|
||||
|
||||
await waitFor(() =>
|
||||
[1, 2].forEach(id => {
|
||||
expect(
|
||||
result.current.chapters.find(c => c.id === id)?.isDownloaded,
|
||||
).toBe(false);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
// #region followNovel
|
||||
|
||||
describe('followNovel', () => {
|
||||
it('toggles inLibrary on the novel', async () => {
|
||||
const { switchNovelToLibrary } =
|
||||
require('@components/Context/LibraryContext').useLibraryContext();
|
||||
(switchNovelToLibrary as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = await renderUseNovel();
|
||||
|
||||
const before = result.current.novel?.inLibrary;
|
||||
|
||||
act(() => result.current.followNovel());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.novel?.inLibrary).toBe(!before),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
});
|
||||
|
||||
// #region deleteCachedNovels
|
||||
|
||||
describe('deleteCachedNovels', () => {
|
||||
const cachedNovels: NovelInfo[] = [
|
||||
const cachedNovels = [
|
||||
{ id: 10, pluginId: 'p1', path: '/n/1', name: 'N1', inLibrary: false },
|
||||
{ id: 11, pluginId: 'p2', path: '/n/2', name: 'N2', inLibrary: false },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
MMKVStorage.clearAll();
|
||||
(_getCachedNovels as jest.Mock).mockResolvedValue(cachedNovels);
|
||||
(NativeFile.exists as jest.Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('clears tracked novel and legacy persistence keys for each cached novel', async () => {
|
||||
for (const novel of cachedNovels) {
|
||||
MMKVStorage.set(`${TRACKED_NOVEL_PREFIX}_${novel.id}`, 'tracked');
|
||||
MMKVStorage.set(
|
||||
keyContract.pageIndex({
|
||||
pluginId: novel.pluginId,
|
||||
novelPath: novel.path,
|
||||
}),
|
||||
4,
|
||||
);
|
||||
MMKVStorage.set(
|
||||
keyContract.settings({
|
||||
pluginId: novel.pluginId,
|
||||
novelPath: novel.path,
|
||||
}),
|
||||
JSON.stringify({ filter: [], showChapterTitles: true }),
|
||||
);
|
||||
MMKVStorage.set(
|
||||
keyContract.lastRead({
|
||||
pluginId: novel.pluginId,
|
||||
novelPath: novel.path,
|
||||
}),
|
||||
JSON.stringify({ id: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
await deleteCachedNovels();
|
||||
|
||||
for (const novel of cachedNovels) {
|
||||
expect(MMKVStorage.contains(`${TRACKED_NOVEL_PREFIX}_${novel.id}`)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
MMKVStorage.contains(
|
||||
keyContract.pageIndex({
|
||||
pluginId: novel.pluginId,
|
||||
novelPath: novel.path,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
MMKVStorage.contains(
|
||||
novelPersistence.keys.settings({
|
||||
pluginId: novel.pluginId,
|
||||
novelPath: novel.path,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
MMKVStorage.contains(
|
||||
novelPersistence.keys.lastRead({
|
||||
pluginId: novel.pluginId,
|
||||
novelPath: novel.path,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('unlinks novel directory when it exists on disk', async () => {
|
||||
(NativeFile.exists as jest.Mock).mockReturnValue(true);
|
||||
|
||||
@@ -508,11 +112,9 @@ describe('deleteCachedNovels', () => {
|
||||
expect(NativeFile.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls _deleteCachedNovels after cleanup', async () => {
|
||||
it('calls database cached-novel delete after cleanup', async () => {
|
||||
await deleteCachedNovels();
|
||||
|
||||
expect(_deleteCachedNovels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import './mocks';
|
||||
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { createStore } from '@hooks/persisted/useNovel/store/createStore';
|
||||
import { novelPersistence } from '@hooks/persisted/useNovel/store-helper/persistence';
|
||||
|
||||
const defaultSort = 'positionAsc';
|
||||
|
||||
const mockNovel: NovelInfo = {
|
||||
id: 1,
|
||||
pluginId: 'plugin-id',
|
||||
path: '/novel/path',
|
||||
name: 'Novel',
|
||||
cover: '',
|
||||
summary: '',
|
||||
author: '',
|
||||
artist: '',
|
||||
genres: 'Genre1, Genre2',
|
||||
status: 'Unknown',
|
||||
totalPages: 0,
|
||||
inLibrary: false,
|
||||
};
|
||||
|
||||
const createNovelStore = (
|
||||
overrides: Partial<NovelInfo> = {},
|
||||
switchNovelToLibrary = jest.fn().mockResolvedValue(undefined),
|
||||
) =>
|
||||
createStore({
|
||||
pluginId: 'plugin-id',
|
||||
path: '/novel/path',
|
||||
novel: { ...mockNovel, ...overrides },
|
||||
defaultChapterSort: defaultSort,
|
||||
switchNovelToLibrary,
|
||||
});
|
||||
|
||||
describe('useNovel store', () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(novelPersistence, 'readLastRead').mockReturnValue(undefined);
|
||||
jest.spyOn(novelPersistence, 'readPageIndex').mockReturnValue(0);
|
||||
jest.spyOn(novelPersistence, 'readSettings').mockReturnValue({
|
||||
sort: defaultSort,
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
});
|
||||
jest
|
||||
.spyOn(novelPersistence, 'writePageIndex')
|
||||
.mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(novelPersistence, 'writeSettings')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('hydrates persisted page index and settings defaults', () => {
|
||||
jest.spyOn(novelPersistence, 'readPageIndex').mockReturnValue(3);
|
||||
jest.spyOn(novelPersistence, 'readSettings').mockReturnValue({
|
||||
filter: ['read'],
|
||||
showChapterTitles: false,
|
||||
});
|
||||
|
||||
const store = createNovelStore();
|
||||
|
||||
expect(store.getState().pageIndex).toBe(3);
|
||||
expect(store.getState().novelSettings).toEqual({
|
||||
sort: defaultSort,
|
||||
filter: ['read'],
|
||||
showChapterTitles: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists page index updates through actions', () => {
|
||||
const writePageIndex = jest.spyOn(novelPersistence, 'writePageIndex');
|
||||
const store = createNovelStore();
|
||||
|
||||
store.getState().actions.setPageIndex(2);
|
||||
|
||||
expect(store.getState().pageIndex).toBe(2);
|
||||
expect(writePageIndex).toHaveBeenCalledWith(
|
||||
{ pluginId: 'plugin-id', novelPath: '/novel/path' },
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('persists novel settings updates through actions', () => {
|
||||
const writeSettings = jest.spyOn(novelPersistence, 'writeSettings');
|
||||
const store = createNovelStore();
|
||||
|
||||
store.getState().actions.setNovelSettings({
|
||||
sort: 'positionDesc',
|
||||
filter: ['downloaded'],
|
||||
showChapterTitles: false,
|
||||
});
|
||||
|
||||
expect(store.getState().novelSettings).toEqual({
|
||||
sort: 'positionDesc',
|
||||
filter: ['downloaded'],
|
||||
showChapterTitles: false,
|
||||
});
|
||||
expect(writeSettings).toHaveBeenCalledWith(
|
||||
{ pluginId: 'plugin-id', novelPath: '/novel/path' },
|
||||
{
|
||||
sort: 'positionDesc',
|
||||
filter: ['downloaded'],
|
||||
showChapterTitles: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('updates chapterTextCache via cache action helpers', () => {
|
||||
const store = createNovelStore();
|
||||
const cache = store.getState().actions.chapterTextCache;
|
||||
|
||||
cache.write(10, 'chapter text');
|
||||
expect(store.getState().chapterTextCache).toEqual({ 10: 'chapter text' });
|
||||
expect(cache.read(10)).toBe('chapter text');
|
||||
|
||||
cache.remove(10);
|
||||
expect(cache.read(10)).toBeUndefined();
|
||||
|
||||
cache.write(11, 'next chapter');
|
||||
cache.clear();
|
||||
expect(store.getState().chapterTextCache).toEqual({});
|
||||
});
|
||||
|
||||
it('toggles follow state after followNovel action', async () => {
|
||||
const switchNovelToLibrary = jest.fn().mockResolvedValue(undefined);
|
||||
const store = createNovelStore({ inLibrary: false }, switchNovelToLibrary);
|
||||
|
||||
await store.getState().actions.followNovel();
|
||||
|
||||
expect(switchNovelToLibrary).toHaveBeenCalledWith(
|
||||
'/novel/path',
|
||||
'plugin-id',
|
||||
);
|
||||
expect(store.getState().novel?.inLibrary).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const mockCategories = [];
|
||||
const mockCategories: string[] = [];
|
||||
|
||||
const useCategories = jest.fn(() => ({
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX';
|
||||
export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS';
|
||||
export const LAST_READ_PREFIX = 'LAST_READ_PREFIX';
|
||||
export const defaultPageIndex = 0;
|
||||
export const defaultNovelSettings = {
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
};
|
||||
|
||||
type ChapterTextValue = string | Promise<string>;
|
||||
|
||||
const createChapterTextCache = () => {
|
||||
const cache = new Map<number, ChapterTextValue>();
|
||||
|
||||
return {
|
||||
read: jest.fn((chapterId: number) => cache.get(chapterId)),
|
||||
write: jest.fn((chapterId: number, value: ChapterTextValue) => {
|
||||
cache.set(chapterId, value);
|
||||
}),
|
||||
remove: jest.fn((chapterId: number) => cache.delete(chapterId)),
|
||||
clear: jest.fn(() => cache.clear()),
|
||||
};
|
||||
};
|
||||
|
||||
const mockNovel = {
|
||||
id: 123,
|
||||
pluginId: 'mock-plugin',
|
||||
@@ -10,6 +31,8 @@ const mockNovel = {
|
||||
};
|
||||
const mockChapters: unknown[] = [];
|
||||
const useNovel = jest.fn(() => ({
|
||||
pluginId: 'mock-plugin',
|
||||
novelPath: '/mock/path',
|
||||
loading: false,
|
||||
fetching: false,
|
||||
pageIndex: 0,
|
||||
@@ -26,14 +49,22 @@ const useNovel = jest.fn(() => ({
|
||||
batch: 1,
|
||||
total: 1,
|
||||
},
|
||||
chapterTextCache: createChapterTextCache(),
|
||||
bootstrapNovel: jest.fn().mockResolvedValue(true),
|
||||
getChapters: jest.fn().mockResolvedValue(undefined),
|
||||
getNextChapterBatch: jest.fn(),
|
||||
loadUpToBatch: jest.fn(),
|
||||
refreshNovel: jest.fn().mockResolvedValue(undefined),
|
||||
getNovel: jest.fn().mockResolvedValue(mockNovel),
|
||||
setPages: jest.fn(),
|
||||
setPageIndex: jest.fn(),
|
||||
openPage: jest.fn(),
|
||||
setNovel: jest.fn(),
|
||||
setNovelSettings: jest.fn(),
|
||||
setLastRead: jest.fn(),
|
||||
followNovel: jest.fn(),
|
||||
setChapters: jest.fn(),
|
||||
extendChapters: jest.fn(),
|
||||
bookmarkChapters: jest.fn(),
|
||||
markPreviouschaptersRead: jest.fn(),
|
||||
markChapterRead: jest.fn(),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX';
|
||||
export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS';
|
||||
export const LAST_READ_PREFIX = 'LAST_READ_PREFIX';
|
||||
export const defaultPageIndex = 0;
|
||||
|
||||
const defaultNovelSettings = {
|
||||
export const defaultNovelSettings = {
|
||||
sort: undefined,
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
|
||||
@@ -12,6 +12,6 @@ export {
|
||||
export { default as usePlugins } from './usePlugins';
|
||||
export { getTracker, useTracker } from './useTracker';
|
||||
export { useTrackedNovel } from './useTrackedNovel';
|
||||
export { useNovel } from './useNovel';
|
||||
export { deleteCachedNovels } from './useNovel';
|
||||
export { default as useDownload } from './useDownload';
|
||||
export { default as useUserAgent } from './useUserAgent';
|
||||
|
||||
+46
-679
@@ -1,694 +1,61 @@
|
||||
/* eslint-disable no-console */
|
||||
import { useLibraryContext } from '@components/Context/LibraryContext';
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import {
|
||||
bookmarkChapter as _bookmarkChapter,
|
||||
deleteChapter as _deleteChapter,
|
||||
deleteChapters as _deleteChapters,
|
||||
getFirstUnreadChapter as _getFirstUnreadChapter,
|
||||
getPageChapters as _getPageChapters,
|
||||
markChapterRead as _markChapterRead,
|
||||
markChaptersRead as _markChaptersRead,
|
||||
markChaptersUnread as _markChaptersUnread,
|
||||
markPreviousChaptersUnread as _markPreviousChaptersUnread,
|
||||
markPreviuschaptersRead as _markPreviuschaptersRead,
|
||||
updateChapterProgress as _updateChapterProgress,
|
||||
getChapterCount,
|
||||
getCustomPages,
|
||||
getPageChaptersBatched,
|
||||
insertChapters,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import {
|
||||
deleteCachedNovels as _deleteCachedNovels,
|
||||
getCachedNovels as _getCachedNovels,
|
||||
getNovelByPath,
|
||||
insertNovelAndChapters,
|
||||
} from '@database/queries/NovelQueries';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { fetchNovel, fetchPage } from '@services/plugin/fetch';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
import { getString } from '@strings/translations';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMMKVNumber, useMMKVObject } from 'react-native-mmkv';
|
||||
import { useAppSettings } from './useSettings';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import {
|
||||
deleteCachedNovels as deleteCachedNovelsFromDb,
|
||||
getCachedNovels,
|
||||
} from '@database/queries/NovelQueries';
|
||||
import { TRACKED_NOVEL_PREFIX } from './useTrackedNovel';
|
||||
|
||||
// #region constants
|
||||
|
||||
export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX';
|
||||
export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS';
|
||||
export const LAST_READ_PREFIX = 'LAST_READ_PREFIX';
|
||||
|
||||
const defaultNovelSettings: NovelSettings = {
|
||||
showChapterTitles: true,
|
||||
filter: [],
|
||||
};
|
||||
const defaultPageIndex = 0;
|
||||
|
||||
// #endregion
|
||||
// #region types
|
||||
|
||||
export interface NovelSettings {
|
||||
sort?: ChapterOrderKey;
|
||||
filter: ChapterFilterKey[];
|
||||
showChapterTitles?: boolean;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
// #region definition useNovel
|
||||
|
||||
export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
const { switchNovelToLibrary } = useLibraryContext();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [novel, setNovel] = useState<NovelInfo | undefined>(
|
||||
typeof novelOrPath === 'object' ? novelOrPath : undefined,
|
||||
);
|
||||
const [pages, setPages] = useState<string[]>(() => {
|
||||
if (novel && (novel.totalPages ?? 0) > 0) {
|
||||
const tmpPages = Array(novel.totalPages)
|
||||
.fill(0)
|
||||
.map((_, idx) => String(idx + 1));
|
||||
return tmpPages.length > 1 ? tmpPages : ['1'];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
|
||||
const novelPath = novel?.path ?? (novelOrPath as string);
|
||||
|
||||
const [pageIndex = defaultPageIndex, setPageIndex] = useMMKVNumber(
|
||||
`${NOVEL_PAGE_INDEX_PREFIX}_${pluginId}_${novelPath}`,
|
||||
);
|
||||
const currentPage = pages[pageIndex];
|
||||
|
||||
const [lastRead, setLastRead] = useMMKVObject<ChapterInfo>(
|
||||
`${LAST_READ_PREFIX}_${pluginId}_${novelPath}`,
|
||||
);
|
||||
const [novelSettings = defaultNovelSettings, _setNovelSettings] =
|
||||
useMMKVObject<NovelSettings>(
|
||||
`${NOVEL_SETTINGS_PREFIX}_${pluginId}_${novelPath}`,
|
||||
);
|
||||
|
||||
const [chapters, _setChapters] = useState<ChapterInfo[]>([]);
|
||||
const [firstUnreadChapter, setFirstUnreadChapter] = useState<
|
||||
ChapterInfo | undefined
|
||||
>();
|
||||
const [batchInformation, setBatchInformation] = useState<{
|
||||
batch: number;
|
||||
total: number;
|
||||
totalChapters?: number;
|
||||
}>({ batch: 0, total: 0 });
|
||||
|
||||
const settingsSort: ChapterOrderKey =
|
||||
novelSettings.sort || defaultChapterSort;
|
||||
const settingsFilter: ChapterFilterKey[] = useMemo(
|
||||
() => novelSettings.filter ?? [],
|
||||
[novelSettings.filter],
|
||||
);
|
||||
|
||||
// #endregion
|
||||
// #region setters
|
||||
|
||||
async function calculatePages(tmpNovel: NovelInfo) {
|
||||
let tmpPages: string[];
|
||||
if ((tmpNovel.totalPages ?? 0) > 0) {
|
||||
tmpPages = Array(tmpNovel.totalPages)
|
||||
.fill(0)
|
||||
.map((_, idx) => String(idx + 1));
|
||||
} else {
|
||||
tmpPages = (await getCustomPages(tmpNovel.id))
|
||||
.map(c => c.page)
|
||||
.filter((page): page is string => page !== null);
|
||||
}
|
||||
|
||||
return tmpPages.length > 1 ? tmpPages : ['1'];
|
||||
}
|
||||
|
||||
const mutateChapters = useCallback(
|
||||
(mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => {
|
||||
if (novel) {
|
||||
_setChapters(mutation);
|
||||
}
|
||||
},
|
||||
[novel],
|
||||
);
|
||||
|
||||
const updateChapter = useCallback(
|
||||
(index: number, update: Partial<ChapterInfo>) => {
|
||||
if (novel) {
|
||||
_setChapters(chs => {
|
||||
const next = [...chs];
|
||||
next[index] = { ...next[index], ...update };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[novel],
|
||||
);
|
||||
|
||||
const openPage = useCallback(
|
||||
(index: number) => {
|
||||
setPageIndex(index);
|
||||
},
|
||||
[setPageIndex],
|
||||
);
|
||||
|
||||
const transformChapters = useCallback(
|
||||
(chs: ChapterInfo[]) => {
|
||||
if (!novel) return chs;
|
||||
const newChapters = chs.map(chapter => {
|
||||
const parsedTime = dayjs(chapter.releaseTime);
|
||||
const releaseTime = parsedTime.isValid()
|
||||
? parsedTime.format('LL')
|
||||
: chapter.releaseTime;
|
||||
const chapterNumber = chapter.chapterNumber
|
||||
? chapter.chapterNumber
|
||||
: parseChapterNumber(novel.name, chapter.name);
|
||||
return {
|
||||
...chapter,
|
||||
releaseTime,
|
||||
chapterNumber,
|
||||
};
|
||||
});
|
||||
return newChapters;
|
||||
},
|
||||
[novel],
|
||||
);
|
||||
|
||||
const setChapters = useCallback(
|
||||
async (chs: ChapterInfo[]) => {
|
||||
_setChapters(transformChapters(chs));
|
||||
},
|
||||
[transformChapters],
|
||||
);
|
||||
|
||||
const extendChapters = useCallback(
|
||||
async (chs: ChapterInfo[]) => {
|
||||
_setChapters(prev => prev.concat(transformChapters(chs)));
|
||||
},
|
||||
[transformChapters],
|
||||
);
|
||||
|
||||
const followNovel = useCallback(() => {
|
||||
switchNovelToLibrary(novelPath, pluginId).then(() => {
|
||||
if (novel) {
|
||||
setNovel({
|
||||
...novel,
|
||||
inLibrary: !novel?.inLibrary,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [novel, novelPath, pluginId, switchNovelToLibrary]);
|
||||
|
||||
// #endregion
|
||||
// #region getters
|
||||
|
||||
const getNovel = useCallback(async () => {
|
||||
let tmpNovel = getNovelByPath(novelPath, pluginId);
|
||||
if (!tmpNovel) {
|
||||
const sourceNovel = await fetchNovel(pluginId, novelPath).catch(() => {
|
||||
throw new Error(getString('updatesScreen.unableToGetNovel'));
|
||||
});
|
||||
|
||||
await insertNovelAndChapters(pluginId, sourceNovel);
|
||||
tmpNovel = getNovelByPath(novelPath, pluginId);
|
||||
|
||||
if (!tmpNovel) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPages(await calculatePages(tmpNovel));
|
||||
|
||||
setNovel(tmpNovel);
|
||||
}, [novelPath, pluginId]);
|
||||
|
||||
const getChapters = useCallback(async () => {
|
||||
const page = pages[pageIndex] ?? '1';
|
||||
|
||||
if (novel && page) {
|
||||
let newChapters: ChapterInfo[] = [];
|
||||
|
||||
const config = [novel.id, settingsSort, settingsFilter, page] as const;
|
||||
|
||||
let chapterCount = await getChapterCount(novel.id, page, settingsFilter);
|
||||
|
||||
if (chapterCount) {
|
||||
try {
|
||||
newChapters = (await getPageChaptersBatched(...config)) || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching chapters:', error);
|
||||
}
|
||||
}
|
||||
// Fetch next page if no chapters
|
||||
else if (Number(page)) {
|
||||
_setChapters([]);
|
||||
const sourcePage = await fetchPage(pluginId, novelPath, page);
|
||||
const sourceChapters = sourcePage.chapters.map(ch => {
|
||||
return {
|
||||
...ch,
|
||||
page,
|
||||
};
|
||||
});
|
||||
await insertChapters(novel.id, sourceChapters);
|
||||
newChapters = await _getPageChapters(...config);
|
||||
chapterCount = await getChapterCount(novel.id, page, settingsFilter);
|
||||
}
|
||||
|
||||
setBatchInformation({
|
||||
batch: 0,
|
||||
total: Math.floor(chapterCount / 300),
|
||||
totalChapters: chapterCount,
|
||||
});
|
||||
setChapters(newChapters);
|
||||
|
||||
const unread = await _getFirstUnreadChapter(
|
||||
novel.id,
|
||||
settingsFilter,
|
||||
page,
|
||||
);
|
||||
setFirstUnreadChapter(unread ?? undefined);
|
||||
}
|
||||
}, [
|
||||
pages,
|
||||
pageIndex,
|
||||
novel,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
setChapters,
|
||||
novelSettings.filter,
|
||||
pluginId,
|
||||
novelPath,
|
||||
]);
|
||||
|
||||
const getNextChapterBatch = useCallback(async () => {
|
||||
const page = pages[pageIndex];
|
||||
const nextBatch = batchInformation.batch + 1;
|
||||
if (novel && page && nextBatch <= batchInformation.total) {
|
||||
let newChapters: ChapterInfo[] = [];
|
||||
|
||||
try {
|
||||
newChapters =
|
||||
(await getPageChaptersBatched(
|
||||
novel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
page,
|
||||
nextBatch,
|
||||
)) || [];
|
||||
} catch (error) {
|
||||
console.error('teaser', error);
|
||||
}
|
||||
setBatchInformation({ ...batchInformation, batch: nextBatch });
|
||||
extendChapters(newChapters);
|
||||
}
|
||||
}, [
|
||||
batchInformation,
|
||||
extendChapters,
|
||||
novel,
|
||||
pageIndex,
|
||||
pages,
|
||||
settingsFilter,
|
||||
settingsSort,
|
||||
]);
|
||||
|
||||
const loadUpToBatch = useCallback(
|
||||
async (targetBatch: number) => {
|
||||
const page = pages[pageIndex] ?? '1';
|
||||
if (!novel || !page || targetBatch <= batchInformation.batch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all batches from current + 1 up to targetBatch
|
||||
for (
|
||||
let batch = batchInformation.batch + 1;
|
||||
batch <= targetBatch;
|
||||
batch++
|
||||
) {
|
||||
if (batch > batchInformation.total) break;
|
||||
|
||||
let newChapters: ChapterInfo[] = [];
|
||||
try {
|
||||
newChapters =
|
||||
(await getPageChaptersBatched(
|
||||
novel.id,
|
||||
settingsSort,
|
||||
novelSettings.filter,
|
||||
page,
|
||||
batch,
|
||||
)) || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading batch', batch, error);
|
||||
}
|
||||
|
||||
setBatchInformation(prev => ({ ...prev, batch }));
|
||||
extendChapters(newChapters);
|
||||
}
|
||||
},
|
||||
[
|
||||
batchInformation,
|
||||
extendChapters,
|
||||
novel,
|
||||
novelSettings.filter,
|
||||
pageIndex,
|
||||
pages,
|
||||
settingsSort,
|
||||
],
|
||||
);
|
||||
|
||||
// #endregion
|
||||
// #region Mark chapters
|
||||
|
||||
const bookmarkChapters = useCallback(
|
||||
(_chapters: ChapterInfo[]) => {
|
||||
_chapters.forEach(_chapter => {
|
||||
_bookmarkChapter(_chapter.id);
|
||||
});
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (_chapters.some(_c => _c.id === chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
bookmark: !chapter.bookmark,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const markPreviouschaptersRead = useCallback(
|
||||
(chapterId: number) => {
|
||||
if (novel) {
|
||||
_markPreviuschaptersRead(chapterId, novel.id);
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter =>
|
||||
chapter.id <= chapterId ? { ...chapter, unread: false } : chapter,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[mutateChapters, novel],
|
||||
);
|
||||
|
||||
const markChapterRead = useCallback(
|
||||
(chapterId: number) => {
|
||||
_markChapterRead(chapterId);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(c => {
|
||||
if (c.id !== chapterId) {
|
||||
return c;
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
unread: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const updateChapterProgress = useCallback(
|
||||
(chapterId: number, progress: number) => {
|
||||
_updateChapterProgress(chapterId, Math.min(progress, 100));
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(c => {
|
||||
if (c.id !== chapterId) {
|
||||
return c;
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
progress,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const markChaptersRead = useCallback(
|
||||
(_chapters: ChapterInfo[]) => {
|
||||
const chapterIds = _chapters.map(chapter => chapter.id);
|
||||
_markChaptersRead(chapterIds);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (chapterIds.includes(chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
unread: false,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const markPreviousChaptersUnread = useCallback(
|
||||
(chapterId: number) => {
|
||||
if (novel) {
|
||||
_markPreviousChaptersUnread(chapterId, novel.id);
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter =>
|
||||
chapter.id <= chapterId ? { ...chapter, unread: true } : chapter,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[mutateChapters, novel],
|
||||
);
|
||||
|
||||
const markChaptersUnread = useCallback(
|
||||
(_chapters: ChapterInfo[]) => {
|
||||
const chapterIds = _chapters.map(chapter => chapter.id);
|
||||
_markChaptersUnread(chapterIds);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (chapterIds.includes(chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
unread: true,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
// #endregion
|
||||
// #region refresh and delete
|
||||
|
||||
const deleteChapter = useCallback(
|
||||
(_chapter: ChapterInfo) => {
|
||||
if (novel) {
|
||||
_deleteChapter(novel.pluginId, novel.id, _chapter.id).then(() => {
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (chapter.id !== _chapter.id) {
|
||||
return chapter;
|
||||
}
|
||||
return {
|
||||
...chapter,
|
||||
isDownloaded: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
showToast(getString('common.deleted', { name: _chapter.name }));
|
||||
});
|
||||
}
|
||||
},
|
||||
[mutateChapters, novel],
|
||||
);
|
||||
|
||||
const deleteChapters = useCallback(
|
||||
(_chaters: ChapterInfo[]) => {
|
||||
if (novel) {
|
||||
_deleteChapters(novel.pluginId, novel.id, _chaters).then(() => {
|
||||
showToast(
|
||||
getString('updatesScreen.deletedChapters', {
|
||||
num: _chaters.length,
|
||||
}),
|
||||
);
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (_chaters.some(_c => _c.id === chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
isDownloaded: false,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
[novel, mutateChapters],
|
||||
);
|
||||
|
||||
const refreshChapters = useCallback(() => {
|
||||
if (novel?.id && !fetching) {
|
||||
_getPageChapters(
|
||||
novel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
currentPage,
|
||||
).then(chs => {
|
||||
setChapters(chs);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
novel?.id,
|
||||
fetching,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
currentPage,
|
||||
setChapters,
|
||||
]);
|
||||
|
||||
// #endregion
|
||||
// #region useEffects
|
||||
|
||||
useEffect(() => {
|
||||
if (novel) {
|
||||
if (pages.length === 0) {
|
||||
calculatePages(novel).then(setPages);
|
||||
}
|
||||
setLoading(false);
|
||||
} else {
|
||||
getNovel()
|
||||
.catch(() => {
|
||||
// Error is handled - novel stays undefined and loading becomes false
|
||||
})
|
||||
.finally(() => {
|
||||
//? Sometimes loading state changes doesn't trigger rerender causing NovelScreen to be in endless loading state
|
||||
setLoading(false);
|
||||
// getNovel();
|
||||
});
|
||||
}
|
||||
}, [getNovel, novel, pages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (novel === undefined || pages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
getChapters()
|
||||
.catch(e => {
|
||||
if (__DEV__) console.error(e);
|
||||
|
||||
showToast(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setFetching(false);
|
||||
});
|
||||
}, [getChapters, novel, novelOrPath, pages.length]);
|
||||
|
||||
// #endregion
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
loading,
|
||||
fetching,
|
||||
pageIndex,
|
||||
pages,
|
||||
novel,
|
||||
lastRead,
|
||||
firstUnreadChapter,
|
||||
chapters,
|
||||
novelSettings,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
loadUpToBatch,
|
||||
getNovel,
|
||||
setPageIndex,
|
||||
openPage,
|
||||
setNovel,
|
||||
setLastRead,
|
||||
|
||||
followNovel,
|
||||
bookmarkChapters,
|
||||
markPreviouschaptersRead,
|
||||
markChapterRead,
|
||||
markChaptersRead,
|
||||
markPreviousChaptersUnread,
|
||||
markChaptersUnread,
|
||||
|
||||
refreshChapters,
|
||||
updateChapter,
|
||||
updateChapterProgress,
|
||||
deleteChapter,
|
||||
deleteChapters,
|
||||
}),
|
||||
[
|
||||
loading,
|
||||
fetching,
|
||||
pageIndex,
|
||||
pages,
|
||||
novel,
|
||||
lastRead,
|
||||
firstUnreadChapter,
|
||||
chapters,
|
||||
novelSettings,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
loadUpToBatch,
|
||||
getNovel,
|
||||
setPageIndex,
|
||||
openPage,
|
||||
setLastRead,
|
||||
followNovel,
|
||||
bookmarkChapters,
|
||||
markPreviouschaptersRead,
|
||||
markChapterRead,
|
||||
markChaptersRead,
|
||||
markPreviousChaptersUnread,
|
||||
markChaptersUnread,
|
||||
refreshChapters,
|
||||
updateChapter,
|
||||
updateChapterProgress,
|
||||
deleteChapter,
|
||||
deleteChapters,
|
||||
],
|
||||
import {
|
||||
LAST_READ_PREFIX,
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
defaultNovelSettings,
|
||||
defaultPageIndex,
|
||||
keyContract,
|
||||
type NovelPersistenceInput,
|
||||
novelPersistence,
|
||||
} from './useNovel/store-helper/contracts';
|
||||
import type { BatchInfo, NovelSettings } from './useNovel/types';
|
||||
|
||||
export { NOVEL_PAGE_INDEX_PREFIX, NOVEL_SETTINGS_PREFIX, LAST_READ_PREFIX };
|
||||
export { defaultNovelSettings, defaultPageIndex };
|
||||
export type { NovelSettings, BatchInfo };
|
||||
|
||||
export const useNovel = () => {
|
||||
throw new Error(
|
||||
'useNovel has been retired. Access novel domain state/actions via useNovelContext().novelStore selectors.',
|
||||
);
|
||||
};
|
||||
|
||||
// #region DeleteCachedNovels
|
||||
const clearNovelPersistence = ({
|
||||
pluginId,
|
||||
novelPath,
|
||||
}: NovelPersistenceInput) => {
|
||||
MMKVStorage.remove(keyContract.pageIndex({ pluginId, novelPath }));
|
||||
MMKVStorage.remove(keyContract.settings({ pluginId, novelPath }));
|
||||
MMKVStorage.remove(keyContract.lastRead({ pluginId, novelPath }));
|
||||
|
||||
MMKVStorage.remove(novelPersistence.keys.pageIndex({ pluginId, novelPath }));
|
||||
MMKVStorage.remove(novelPersistence.keys.settings({ pluginId, novelPath }));
|
||||
MMKVStorage.remove(novelPersistence.keys.lastRead({ pluginId, novelPath }));
|
||||
};
|
||||
|
||||
export const deleteCachedNovels = async () => {
|
||||
const cachedNovels = await _getCachedNovels();
|
||||
const cachedNovels = await getCachedNovels();
|
||||
|
||||
for (const novel of cachedNovels) {
|
||||
MMKVStorage.remove(`${TRACKED_NOVEL_PREFIX}_${novel.id}`);
|
||||
MMKVStorage.remove(
|
||||
`${NOVEL_PAGE_INDEX_PREFIX}_${novel.pluginId}_${novel.path}`,
|
||||
);
|
||||
MMKVStorage.remove(
|
||||
`${NOVEL_SETTINGS_PREFIX}_${novel.pluginId}_${novel.path}`,
|
||||
);
|
||||
MMKVStorage.remove(`${LAST_READ_PREFIX}_${novel.pluginId}_${novel.path}`);
|
||||
const novelDir = NOVEL_STORAGE + '/' + novel.pluginId + '/' + novel.id;
|
||||
clearNovelPersistence({
|
||||
pluginId: novel.pluginId,
|
||||
novelPath: novel.path,
|
||||
});
|
||||
|
||||
const novelDir = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}`;
|
||||
if (NativeFile.exists(novelDir)) {
|
||||
NativeFile.unlink(novelDir);
|
||||
}
|
||||
}
|
||||
_deleteCachedNovels();
|
||||
|
||||
await deleteCachedNovelsFromDb();
|
||||
};
|
||||
// #endregion
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import '../../../__tests__/mocks';
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import { ChapterInfo, DBNovelInfo } from '@database/types';
|
||||
import {
|
||||
getChapterCount,
|
||||
getChapterCountSync,
|
||||
getCustomPages,
|
||||
getFirstUnreadChapter,
|
||||
getNovelChaptersSync,
|
||||
getPageChapters,
|
||||
getPageChaptersBatched,
|
||||
insertChapters,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import {
|
||||
getNovelById,
|
||||
getNovelByPath,
|
||||
insertNovelAndChapters,
|
||||
} from '@database/queries/NovelQueries';
|
||||
import { fetchNovel, fetchPage } from '@services/plugin/fetch';
|
||||
import { createBootstrapService } from '../store-helper/bootstrapService';
|
||||
|
||||
const PLUGIN_ID = 'test-plugin';
|
||||
const NOVEL_PATH = '/novels/test';
|
||||
|
||||
const settingsSort: ChapterOrderKey = 'positionAsc';
|
||||
const settingsFilter: ChapterFilterKey[] = [];
|
||||
|
||||
const mockNovel: DBNovelInfo = {
|
||||
id: 1,
|
||||
path: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
name: 'Test Novel',
|
||||
inLibrary: false,
|
||||
totalPages: 0,
|
||||
chaptersDownloaded: 0,
|
||||
chaptersUnread: 0,
|
||||
totalChapters: 0,
|
||||
lastReadAt: null,
|
||||
lastUpdatedAt: null,
|
||||
};
|
||||
|
||||
const makeChapter = (id: number, overrides: Partial<ChapterInfo> = {}) => ({
|
||||
id,
|
||||
novelId: mockNovel.id,
|
||||
name: `Chapter ${id}`,
|
||||
path: `/chapter/${id}`,
|
||||
updatedTime: '2024-01-02',
|
||||
readTime: '2024-01-03',
|
||||
chapterNumber: id,
|
||||
unread: true,
|
||||
isDownloaded: false,
|
||||
bookmark: false,
|
||||
progress: 0,
|
||||
page: '1',
|
||||
position: id,
|
||||
...overrides,
|
||||
releaseTime: overrides.releaseTime || '2024-01-01',
|
||||
});
|
||||
|
||||
const mockChapters: ChapterInfo[] = [
|
||||
makeChapter(1),
|
||||
makeChapter(2),
|
||||
makeChapter(3),
|
||||
];
|
||||
|
||||
const mockGetCustomPages = getCustomPages as jest.MockedFunction<
|
||||
typeof getCustomPages
|
||||
>;
|
||||
const mockGetNovelByPath = getNovelByPath as jest.MockedFunction<
|
||||
typeof getNovelByPath
|
||||
>;
|
||||
const mockGetNovelById = getNovelById as jest.MockedFunction<
|
||||
typeof getNovelById
|
||||
>;
|
||||
const mockFetchNovel = fetchNovel as jest.MockedFunction<typeof fetchNovel>;
|
||||
const mockInsertNovelAndChapters =
|
||||
insertNovelAndChapters as jest.MockedFunction<typeof insertNovelAndChapters>;
|
||||
const mockGetChapterCount = getChapterCount as jest.MockedFunction<
|
||||
typeof getChapterCount
|
||||
>;
|
||||
const mockGetChapterCountSync = getChapterCountSync as jest.MockedFunction<
|
||||
typeof getChapterCountSync
|
||||
>;
|
||||
const mockGetPageChaptersBatched =
|
||||
getPageChaptersBatched as jest.MockedFunction<typeof getPageChaptersBatched>;
|
||||
const mockGetNovelChaptersSync = getNovelChaptersSync as jest.MockedFunction<
|
||||
typeof getNovelChaptersSync
|
||||
>;
|
||||
const mockFetchPage = fetchPage as jest.MockedFunction<typeof fetchPage>;
|
||||
const mockInsertChapters = insertChapters as jest.MockedFunction<
|
||||
typeof insertChapters
|
||||
>;
|
||||
const mockGetPageChapters = getPageChapters as jest.MockedFunction<
|
||||
typeof getPageChapters
|
||||
>;
|
||||
const mockGetFirstUnreadChapter = getFirstUnreadChapter as jest.MockedFunction<
|
||||
typeof getFirstUnreadChapter
|
||||
>;
|
||||
|
||||
const setupDbFirstSuccess = () => {
|
||||
mockGetCustomPages.mockReturnValue([]);
|
||||
mockGetNovelById.mockReturnValue(mockNovel);
|
||||
mockGetNovelByPath.mockReturnValue(mockNovel);
|
||||
mockGetChapterCount.mockResolvedValue(mockChapters.length);
|
||||
mockGetChapterCountSync.mockReturnValue(mockChapters.length); //@ts-ignore
|
||||
mockGetPageChaptersBatched.mockResolvedValue(mockChapters);
|
||||
mockGetNovelChaptersSync.mockReturnValue(mockChapters); //@ts-ignore
|
||||
mockGetFirstUnreadChapter.mockReturnValue(mockChapters[0]);
|
||||
};
|
||||
|
||||
describe('bootstrapService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns success payload from db-first branch', async () => {
|
||||
setupDbFirstSuccess();
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = await service.bootstrapNovelAsync({
|
||||
novel: undefined,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.novel).toEqual(mockNovel);
|
||||
expect(result.pages).toEqual(['1']);
|
||||
expect(result.chapters).toEqual(mockChapters);
|
||||
expect(result.firstUnreadChapter).toEqual(mockChapters[0]);
|
||||
expect(result.batchInformation).toEqual({
|
||||
batch: 0,
|
||||
total: 0,
|
||||
totalChapters: mockChapters.length,
|
||||
});
|
||||
expect(mockGetNovelByPath).toHaveBeenCalledWith(NOVEL_PATH, PLUGIN_ID);
|
||||
expect(mockGetChapterCount).toHaveBeenCalledWith(
|
||||
mockNovel.id,
|
||||
'1',
|
||||
settingsFilter,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to source page and inserts chapters when db count is 0', async () => {
|
||||
setupDbFirstSuccess();
|
||||
mockGetChapterCount
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(mockChapters.length);
|
||||
mockFetchPage.mockResolvedValue({
|
||||
chapters: mockChapters.map(ch => ({ ...ch, page: null })),
|
||||
} as never);
|
||||
mockGetPageChapters.mockResolvedValue(mockChapters);
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = await service.bootstrapNovelAsync({
|
||||
novel: mockNovel,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(mockFetchPage).toHaveBeenCalledWith(PLUGIN_ID, NOVEL_PATH, '1');
|
||||
expect(mockInsertChapters).toHaveBeenCalled();
|
||||
expect(mockGetPageChapters).toHaveBeenCalledWith(
|
||||
mockNovel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
'1',
|
||||
);
|
||||
expect(result.batchInformation.totalChapters).toBe(mockChapters.length);
|
||||
});
|
||||
|
||||
it('returns missing-novel when source insert path still resolves no novel', async () => {
|
||||
mockGetNovelByPath
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(undefined);
|
||||
mockFetchNovel.mockResolvedValue({ ...mockNovel, chapters: [] } as never);
|
||||
mockInsertNovelAndChapters.mockResolvedValue(undefined);
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = await service.bootstrapNovelAsync({
|
||||
novel: undefined,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missing-novel' });
|
||||
});
|
||||
|
||||
it('returns error result when underlying data operation throws', async () => {
|
||||
setupDbFirstSuccess();
|
||||
mockGetChapterCount.mockRejectedValue(new Error('db failed'));
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = await service.bootstrapNovelAsync({
|
||||
novel: mockNovel,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.reason).toBe('error');
|
||||
});
|
||||
|
||||
it('dedupes in-flight bootstrap per ${pluginId}_${novelPath}', async () => {
|
||||
setupDbFirstSuccess();
|
||||
mockGetChapterCount.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve =>
|
||||
setTimeout(() => resolve(mockChapters.length), 10),
|
||||
),
|
||||
);
|
||||
const service = createBootstrapService();
|
||||
|
||||
const [result1, result2] = await Promise.all([
|
||||
service.bootstrapNovelAsync({
|
||||
novel: mockNovel,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
}),
|
||||
service.bootstrapNovelAsync({
|
||||
novel: mockNovel,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result1.ok).toBe(true);
|
||||
expect(result2.ok).toBe(true);
|
||||
expect(mockGetChapterCount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses custom pages and selected page index when custom pages are available', async () => {
|
||||
setupDbFirstSuccess();
|
||||
mockGetCustomPages.mockReturnValue([
|
||||
{ page: '1' },
|
||||
{ page: '3' },
|
||||
] as ReturnType<typeof getCustomPages>);
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = await service.bootstrapNovelAsync({
|
||||
novel: mockNovel,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 1,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
expect(result.pages).toEqual(['1', '3']);
|
||||
expect(mockGetChapterCount).toHaveBeenCalledWith(
|
||||
mockNovel.id,
|
||||
'3',
|
||||
settingsFilter,
|
||||
);
|
||||
expect(mockGetPageChaptersBatched).toHaveBeenCalledWith(
|
||||
mockNovel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
'3',
|
||||
);
|
||||
});
|
||||
|
||||
it('getNextChapterBatch loads the next batch when available', async () => {
|
||||
mockGetPageChaptersBatched.mockResolvedValue([makeChapter(10)]);
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = await service.getNextChapterBatch({
|
||||
novel: mockNovel,
|
||||
pages: ['1'],
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
batchInformation: { batch: 0, total: 2 },
|
||||
});
|
||||
|
||||
expect(mockGetPageChaptersBatched).toHaveBeenCalledWith(
|
||||
mockNovel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
'1',
|
||||
1,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
batch: 1,
|
||||
chapters: [expect.objectContaining({ id: 10 })],
|
||||
});
|
||||
});
|
||||
|
||||
it('getNextChapterBatch returns undefined when at last batch', async () => {
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = await service.getNextChapterBatch({
|
||||
novel: mockNovel,
|
||||
pages: ['1'],
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
batchInformation: { batch: 1, total: 1 },
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockGetPageChaptersBatched).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loadUpToBatch only loads until total batch count', async () => {
|
||||
mockGetPageChaptersBatched.mockResolvedValue([makeChapter(11)]);
|
||||
const onBatchLoaded = jest.fn();
|
||||
const service = createBootstrapService();
|
||||
|
||||
await service.loadUpToBatch({
|
||||
targetBatch: 4,
|
||||
novel: mockNovel,
|
||||
pages: ['1'],
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
batchInformation: { batch: 0, total: 1 },
|
||||
onBatchLoaded,
|
||||
});
|
||||
|
||||
expect(mockGetPageChaptersBatched).toHaveBeenCalledTimes(1);
|
||||
expect(onBatchLoaded).toHaveBeenCalledWith(1, [
|
||||
expect.objectContaining({ id: 11 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('bootstrapNovelSync uses filtered sync count for totalChapters', () => {
|
||||
setupDbFirstSuccess();
|
||||
mockGetChapterCountSync.mockReturnValue(2);
|
||||
mockGetNovelByPath.mockReturnValue({
|
||||
...mockNovel,
|
||||
totalChapters: 999,
|
||||
chaptersDownloaded: 3,
|
||||
chaptersUnread: 3,
|
||||
lastReadAt: null,
|
||||
lastUpdatedAt: null,
|
||||
});
|
||||
mockGetNovelChaptersSync.mockReturnValue([
|
||||
mockChapters[0],
|
||||
mockChapters[2],
|
||||
]);
|
||||
const service = createBootstrapService();
|
||||
|
||||
const result = service.bootstrapNovelSync({
|
||||
novel: undefined,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter: ['not-read'],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.batchInformation.totalChapters).toBe(2);
|
||||
expect(mockGetChapterCountSync).toHaveBeenCalledWith(mockNovel.id, '1', [
|
||||
'not-read',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bootstrapNovelSync returns missing-chapters only when unfiltered count is zero', () => {
|
||||
setupDbFirstSuccess();
|
||||
mockGetChapterCountSync.mockReturnValue(0);
|
||||
const service = createBootstrapService();
|
||||
|
||||
const unfiltered = service.bootstrapNovelSync({
|
||||
novel: mockNovel,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter: [],
|
||||
});
|
||||
expect(unfiltered).toEqual({ ok: false, reason: 'missing-chapters' });
|
||||
|
||||
const filtered = service.bootstrapNovelSync({
|
||||
novel: mockNovel,
|
||||
novelPath: NOVEL_PATH,
|
||||
pluginId: PLUGIN_ID,
|
||||
pageIndex: 0,
|
||||
settingsSort,
|
||||
settingsFilter: ['not-read'],
|
||||
});
|
||||
expect(filtered.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import '../../../__tests__/mocks';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import {
|
||||
bookmarkChaptersAction,
|
||||
ChapterActionsDependencies,
|
||||
deleteChapterAction,
|
||||
deleteChaptersAction,
|
||||
markChapterReadAction,
|
||||
markChaptersReadAction,
|
||||
markChaptersUnreadAction,
|
||||
markPreviouschaptersReadAction,
|
||||
markPreviousChaptersUnreadAction,
|
||||
refreshChaptersAction,
|
||||
updateChapterProgressAction,
|
||||
} from '../store/chapterActions';
|
||||
|
||||
const makeChapter = (id: number, overrides: Partial<ChapterInfo> = {}) => ({
|
||||
id,
|
||||
novelId: 1,
|
||||
path: `/chapter/${id}`,
|
||||
name: `Chapter ${id}`,
|
||||
releaseTime: '2024-01-01',
|
||||
readTime: null,
|
||||
bookmark: false,
|
||||
unread: true,
|
||||
isDownloaded: true,
|
||||
updatedTime: '2024-01-01',
|
||||
chapterNumber: id,
|
||||
page: '1',
|
||||
progress: 0,
|
||||
position: id - 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockNovel: NovelInfo = {
|
||||
id: 1,
|
||||
path: '/novels/test',
|
||||
pluginId: 'plugin.test',
|
||||
name: 'Test Novel',
|
||||
};
|
||||
|
||||
const createDeps = (): jest.Mocked<ChapterActionsDependencies> => ({
|
||||
bookmarkChapter: jest.fn().mockResolvedValue(undefined),
|
||||
markChapterRead: jest.fn().mockResolvedValue(undefined),
|
||||
markChaptersRead: jest.fn().mockResolvedValue(undefined),
|
||||
markPreviuschaptersRead: jest.fn().mockResolvedValue(undefined),
|
||||
markPreviousChaptersUnread: jest.fn().mockResolvedValue(undefined),
|
||||
markChaptersUnread: jest.fn().mockResolvedValue(undefined),
|
||||
updateChapterProgress: jest.fn().mockResolvedValue(undefined),
|
||||
deleteChapter: jest.fn().mockResolvedValue(undefined),
|
||||
deleteChapters: jest.fn().mockResolvedValue(undefined),
|
||||
getPageChapters: jest.fn().mockResolvedValue([]),
|
||||
showToast: jest.fn(),
|
||||
getString: jest
|
||||
.fn<
|
||||
ReturnType<ChapterActionsDependencies['getString']>,
|
||||
Parameters<ChapterActionsDependencies['getString']>
|
||||
>()
|
||||
.mockImplementation(stringKey => String(stringKey)),
|
||||
});
|
||||
|
||||
const createStateMutator = (initial: ChapterInfo[]) => {
|
||||
let state = [...initial];
|
||||
const mutate = (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => {
|
||||
state = mutation(state);
|
||||
};
|
||||
|
||||
return {
|
||||
mutate,
|
||||
getState: () => state,
|
||||
};
|
||||
};
|
||||
|
||||
describe('chapterActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('bookmarkChaptersAction toggles bookmark state and calls db mutation for each id', () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([makeChapter(1), makeChapter(2)]);
|
||||
|
||||
bookmarkChaptersAction([makeChapter(2)], state.mutate, deps);
|
||||
|
||||
expect(deps.bookmarkChapter).toHaveBeenCalledWith(2);
|
||||
expect(state.getState().map(ch => ch.bookmark)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('markChapterReadAction marks target chapter read in db and state', () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([makeChapter(1), makeChapter(2)]);
|
||||
|
||||
markChapterReadAction(1, state.mutate, deps);
|
||||
|
||||
expect(deps.markChapterRead).toHaveBeenCalledWith(1);
|
||||
expect(state.getState().map(ch => ch.unread)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('markChaptersReadAction supports empty selection and still keeps state stable', () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([makeChapter(1), makeChapter(2)]);
|
||||
|
||||
markChaptersReadAction([], state.mutate, deps);
|
||||
|
||||
expect(deps.markChaptersRead).toHaveBeenCalledWith([]);
|
||||
expect(state.getState().map(ch => ch.unread)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('markPreviouschaptersReadAction is safe no-op when novel is absent', () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([makeChapter(1), makeChapter(2)]);
|
||||
|
||||
markPreviouschaptersReadAction(2, undefined, state.mutate, deps);
|
||||
|
||||
expect(deps.markPreviuschaptersRead).not.toHaveBeenCalled();
|
||||
expect(state.getState().map(ch => ch.unread)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('markPreviousChaptersUnreadAction updates previous chapters and persists mutation', () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([
|
||||
makeChapter(1, { unread: false }),
|
||||
makeChapter(2, { unread: false }),
|
||||
makeChapter(3, { unread: false }),
|
||||
]);
|
||||
|
||||
markPreviousChaptersUnreadAction(2, mockNovel, state.mutate, deps);
|
||||
|
||||
expect(deps.markPreviousChaptersUnread).toHaveBeenCalledWith(
|
||||
2,
|
||||
mockNovel.id,
|
||||
);
|
||||
expect(state.getState().map(ch => ch.unread)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it('markChaptersUnreadAction marks selected chapters unread in db and state', () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([
|
||||
makeChapter(1, { unread: false }),
|
||||
makeChapter(2, { unread: false }),
|
||||
]);
|
||||
|
||||
markChaptersUnreadAction([makeChapter(2)], state.mutate, deps);
|
||||
|
||||
expect(deps.markChaptersUnread).toHaveBeenCalledWith([2]);
|
||||
expect(state.getState().map(ch => ch.unread)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('updateChapterProgressAction clamps persisted and in-memory progress values', () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([makeChapter(1, { progress: 10 })]);
|
||||
|
||||
updateChapterProgressAction(1, 145, state.mutate, deps);
|
||||
|
||||
expect(deps.updateChapterProgress).toHaveBeenCalledWith(1, 100);
|
||||
expect(state.getState()[0].progress).toBe(100);
|
||||
});
|
||||
|
||||
it('deleteChapterAction is safe no-op when novel is absent', async () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([makeChapter(1), makeChapter(2)]);
|
||||
|
||||
deleteChapterAction(makeChapter(1), undefined, state.mutate, deps);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(deps.deleteChapter).not.toHaveBeenCalled();
|
||||
expect(deps.showToast).not.toHaveBeenCalled();
|
||||
expect(state.getState().map(ch => ch.isDownloaded)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('deleteChapterAction updates downloaded flag and emits toast after delete resolves', async () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([makeChapter(1), makeChapter(2)]);
|
||||
|
||||
deleteChapterAction(makeChapter(2), mockNovel, state.mutate, deps);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(deps.deleteChapter).toHaveBeenCalledWith(
|
||||
mockNovel.pluginId,
|
||||
mockNovel.id,
|
||||
2,
|
||||
);
|
||||
expect(deps.getString).toHaveBeenCalledWith('common.deleted', {
|
||||
name: 'Chapter 2',
|
||||
});
|
||||
expect(deps.showToast).toHaveBeenCalledWith('common.deleted');
|
||||
expect(state.getState().map(ch => ch.isDownloaded)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('deleteChaptersAction updates selected chapters and toast payload after delete resolves', async () => {
|
||||
const deps = createDeps();
|
||||
const state = createStateMutator([
|
||||
makeChapter(1),
|
||||
makeChapter(2),
|
||||
makeChapter(3),
|
||||
]);
|
||||
|
||||
deleteChaptersAction(
|
||||
[makeChapter(1), makeChapter(3)],
|
||||
mockNovel,
|
||||
state.mutate,
|
||||
deps,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(deps.deleteChapters).toHaveBeenCalledWith(
|
||||
mockNovel.pluginId,
|
||||
mockNovel.id,
|
||||
[expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 3 })],
|
||||
);
|
||||
expect(deps.getString).toHaveBeenCalledWith(
|
||||
'updatesScreen.deletedChapters',
|
||||
{
|
||||
num: 2,
|
||||
},
|
||||
);
|
||||
expect(deps.showToast).toHaveBeenCalledWith(
|
||||
'updatesScreen.deletedChapters',
|
||||
);
|
||||
expect(state.getState().map(ch => ch.isDownloaded)).toEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshChaptersAction guards on missing novel/fetching and transforms fetched chapters', async () => {
|
||||
const deps = createDeps();
|
||||
const sourceChapters = [makeChapter(1), makeChapter(2)];
|
||||
deps.getPageChapters.mockResolvedValue(sourceChapters);
|
||||
const setChapters = jest.fn();
|
||||
|
||||
refreshChaptersAction({
|
||||
novel: undefined,
|
||||
fetching: false,
|
||||
settingsSort: 'positionAsc',
|
||||
settingsFilter: [],
|
||||
currentPage: '1',
|
||||
transformChapters: chs => chs,
|
||||
setChapters,
|
||||
deps,
|
||||
});
|
||||
|
||||
refreshChaptersAction({
|
||||
novel: mockNovel,
|
||||
fetching: true,
|
||||
settingsSort: 'positionAsc',
|
||||
settingsFilter: [],
|
||||
currentPage: '1',
|
||||
transformChapters: chs => chs,
|
||||
setChapters,
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(deps.getPageChapters).not.toHaveBeenCalled();
|
||||
|
||||
refreshChaptersAction({
|
||||
novel: mockNovel,
|
||||
fetching: false,
|
||||
settingsSort: 'positionAsc',
|
||||
settingsFilter: [],
|
||||
currentPage: '2',
|
||||
transformChapters: chs => chs.map(ch => ({ ...ch, unread: false })),
|
||||
setChapters,
|
||||
deps,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(deps.getPageChapters).toHaveBeenCalledWith(
|
||||
mockNovel.id,
|
||||
'positionAsc',
|
||||
[],
|
||||
'2',
|
||||
);
|
||||
expect(setChapters).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 1, unread: false }),
|
||||
expect.objectContaining({ id: 2, unread: false }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { keyContract, KeyContractInput } from '../store-helper/keyContract';
|
||||
import {
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
LAST_READ_PREFIX,
|
||||
} from '../types';
|
||||
|
||||
describe('keyContract', () => {
|
||||
describe('pageIndex', () => {
|
||||
it('generates legacy format key: ${PREFIX}_${pluginId}_${novelPath}', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'webnovel',
|
||||
novelPath: 'api/novels/xyz-123',
|
||||
};
|
||||
|
||||
const result = keyContract.pageIndex(input);
|
||||
|
||||
expect(result).toBe(
|
||||
'NOVEL_PAGE_INDEX_PREFIX_webnovel_api/novels/xyz-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves pluginId and novelPath in exact order', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'archive',
|
||||
novelPath: 'light-novel/ch1',
|
||||
};
|
||||
|
||||
const result = keyContract.pageIndex(input);
|
||||
|
||||
expect(result).toContain(
|
||||
`${NOVEL_PAGE_INDEX_PREFIX}_archive_light-novel/ch1`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles complex novelPath with special characters', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'source',
|
||||
novelPath: 'path/to/novel-with-dashes_and_underscores/123',
|
||||
};
|
||||
|
||||
const result = keyContract.pageIndex(input);
|
||||
|
||||
expect(result).toBe(
|
||||
`${NOVEL_PAGE_INDEX_PREFIX}_source_path/to/novel-with-dashes_and_underscores/123`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
it('generates legacy format key: ${PREFIX}_${pluginId}_${novelPath}', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'webnovel',
|
||||
novelPath: 'api/novels/xyz-123',
|
||||
};
|
||||
|
||||
const result = keyContract.settings(input);
|
||||
|
||||
expect(result).toBe('NOVEL_SETTINGS_webnovel_api/novels/xyz-123');
|
||||
});
|
||||
|
||||
it('preserves pluginId and novelPath in exact order', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'archive',
|
||||
novelPath: 'light-novel/ch1',
|
||||
};
|
||||
|
||||
const result = keyContract.settings(input);
|
||||
|
||||
expect(result).toContain(
|
||||
`${NOVEL_SETTINGS_PREFIX}_archive_light-novel/ch1`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastRead', () => {
|
||||
it('generates legacy format key: ${PREFIX}_${pluginId}_${novelPath}', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'webnovel',
|
||||
novelPath: 'api/novels/xyz-123',
|
||||
};
|
||||
|
||||
const result = keyContract.lastRead(input);
|
||||
|
||||
expect(result).toBe('LAST_READ_PREFIX_webnovel_api/novels/xyz-123');
|
||||
});
|
||||
|
||||
it('preserves pluginId and novelPath in exact order', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'archive',
|
||||
novelPath: 'light-novel/ch1',
|
||||
};
|
||||
|
||||
const result = keyContract.lastRead(input);
|
||||
|
||||
expect(result).toContain(`${LAST_READ_PREFIX}_archive_light-novel/ch1`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key continuity across calls', () => {
|
||||
it('produces deterministic keys for same input', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'plugin-a',
|
||||
novelPath: 'novel/path',
|
||||
};
|
||||
|
||||
const key1 = keyContract.pageIndex(input);
|
||||
const key2 = keyContract.pageIndex(input);
|
||||
|
||||
expect(key1).toBe(key2);
|
||||
});
|
||||
|
||||
it('differentiates keys by pluginId', () => {
|
||||
const base: KeyContractInput = {
|
||||
pluginId: 'plugin-a',
|
||||
novelPath: 'same/path',
|
||||
};
|
||||
|
||||
const otherPluginId: KeyContractInput = {
|
||||
pluginId: 'plugin-b',
|
||||
novelPath: 'same/path',
|
||||
};
|
||||
|
||||
const key1 = keyContract.pageIndex(base);
|
||||
const key2 = keyContract.pageIndex(otherPluginId);
|
||||
|
||||
expect(key1).not.toBe(key2);
|
||||
expect(key2).toContain('plugin-b');
|
||||
});
|
||||
|
||||
it('differentiates keys by novelPath', () => {
|
||||
const base: KeyContractInput = {
|
||||
pluginId: 'same-plugin',
|
||||
novelPath: 'novel/path-a',
|
||||
};
|
||||
|
||||
const otherPath: KeyContractInput = {
|
||||
pluginId: 'same-plugin',
|
||||
novelPath: 'novel/path-b',
|
||||
};
|
||||
|
||||
const key1 = keyContract.pageIndex(base);
|
||||
const key2 = keyContract.pageIndex(otherPath);
|
||||
|
||||
expect(key1).not.toBe(key2);
|
||||
expect(key2).toContain('novel/path-b');
|
||||
});
|
||||
|
||||
it('uses correct prefix constants from types', () => {
|
||||
const input: KeyContractInput = {
|
||||
pluginId: 'p1',
|
||||
novelPath: 'n1',
|
||||
};
|
||||
|
||||
expect(keyContract.pageIndex(input)).toContain(NOVEL_PAGE_INDEX_PREFIX);
|
||||
expect(keyContract.settings(input)).toContain(NOVEL_SETTINGS_PREFIX);
|
||||
expect(keyContract.lastRead(input)).toContain(LAST_READ_PREFIX);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import '../../../__tests__/mocks';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { createBootstrapService } from '../store-helper/bootstrapService';
|
||||
import {
|
||||
bookmarkChaptersAction,
|
||||
ChapterActionsDependencies,
|
||||
deleteChapterAction,
|
||||
deleteChaptersAction,
|
||||
markChapterReadAction,
|
||||
markChaptersReadAction,
|
||||
markChaptersUnreadAction,
|
||||
markPreviouschaptersReadAction,
|
||||
markPreviousChaptersUnreadAction,
|
||||
refreshChaptersAction,
|
||||
updateChapterProgressAction,
|
||||
} from '../store/chapterActions';
|
||||
import { createNovelStoreChapterActions } from '../store/novelStore.chapterActions';
|
||||
import { BatchInfo, NovelSettingsWithoutSort } from '../types';
|
||||
|
||||
jest.mock('../store/chapterActions', () => {
|
||||
const actual = jest.requireActual('../store/chapterActions');
|
||||
return {
|
||||
...actual,
|
||||
bookmarkChaptersAction: jest.fn(),
|
||||
deleteChapterAction: jest.fn(),
|
||||
deleteChaptersAction: jest.fn(),
|
||||
markChapterReadAction: jest.fn(),
|
||||
markChaptersReadAction: jest.fn(),
|
||||
markChaptersUnreadAction: jest.fn(),
|
||||
markPreviouschaptersReadAction: jest.fn(),
|
||||
markPreviousChaptersUnreadAction: jest.fn(),
|
||||
refreshChaptersAction: jest.fn(),
|
||||
updateChapterProgressAction: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type BootstrapServiceSlice = Pick<
|
||||
ReturnType<typeof createBootstrapService>,
|
||||
'getNextChapterBatch' | 'loadUpToBatch'
|
||||
>;
|
||||
|
||||
interface TestState {
|
||||
novel: NovelInfo | undefined;
|
||||
pages: string[];
|
||||
pageIndex: number;
|
||||
chapters: ChapterInfo[];
|
||||
chapterTextCache: Record<number, string | Promise<string>>;
|
||||
fetching: boolean;
|
||||
novelSettings: NovelSettingsWithoutSort;
|
||||
batchInformation: BatchInfo;
|
||||
}
|
||||
|
||||
const makeChapter = (id: number, overrides: Partial<ChapterInfo> = {}) => ({
|
||||
id,
|
||||
novelId: 1,
|
||||
path: `/chapter/${id}`,
|
||||
name: `Chapter ${id}`,
|
||||
releaseTime: '2024-01-01',
|
||||
readTime: null,
|
||||
bookmark: false,
|
||||
unread: true,
|
||||
isDownloaded: true,
|
||||
updatedTime: '2024-01-01',
|
||||
chapterNumber: id,
|
||||
page: '1',
|
||||
progress: 0,
|
||||
position: id - 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockNovel: NovelInfo = {
|
||||
id: 1,
|
||||
path: '/novels/test',
|
||||
pluginId: 'plugin.test',
|
||||
name: 'Test Novel',
|
||||
};
|
||||
|
||||
const createDeps = (): jest.Mocked<ChapterActionsDependencies> => ({
|
||||
bookmarkChapter: jest.fn().mockResolvedValue(undefined),
|
||||
markChapterRead: jest.fn().mockResolvedValue(undefined),
|
||||
markChaptersRead: jest.fn().mockResolvedValue(undefined),
|
||||
markPreviuschaptersRead: jest.fn().mockResolvedValue(undefined),
|
||||
markPreviousChaptersUnread: jest.fn().mockResolvedValue(undefined),
|
||||
markChaptersUnread: jest.fn().mockResolvedValue(undefined),
|
||||
updateChapterProgress: jest.fn().mockResolvedValue(undefined),
|
||||
deleteChapter: jest.fn().mockResolvedValue(undefined),
|
||||
deleteChapters: jest.fn().mockResolvedValue(undefined),
|
||||
getPageChapters: jest.fn().mockResolvedValue([]),
|
||||
showToast: jest.fn(),
|
||||
getString: jest
|
||||
.fn<
|
||||
ReturnType<ChapterActionsDependencies['getString']>,
|
||||
Parameters<ChapterActionsDependencies['getString']>
|
||||
>()
|
||||
.mockImplementation(stringKey => String(stringKey)),
|
||||
});
|
||||
|
||||
const createHarness = (overrides: Partial<TestState> = {}) => {
|
||||
let state: TestState = {
|
||||
novel: mockNovel,
|
||||
pages: ['1', '2'],
|
||||
pageIndex: 0,
|
||||
chapters: [makeChapter(1)],
|
||||
chapterTextCache: {},
|
||||
fetching: false,
|
||||
novelSettings: { filter: [], showChapterTitles: true },
|
||||
batchInformation: { batch: 0, total: 4 },
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const set = jest.fn(
|
||||
(partial: Partial<TestState> | ((s: TestState) => Partial<TestState>)) => {
|
||||
const patch = typeof partial === 'function' ? partial(state) : partial;
|
||||
state = { ...state, ...patch };
|
||||
},
|
||||
);
|
||||
const get = () => state;
|
||||
const bootstrapService: jest.Mocked<BootstrapServiceSlice> = {
|
||||
getNextChapterBatch: jest.fn(),
|
||||
loadUpToBatch: jest.fn(),
|
||||
};
|
||||
const chapterDeps = createDeps();
|
||||
const transformChapters = jest.fn((chs: ChapterInfo[]) =>
|
||||
chs.map(ch => ({ ...ch, name: `[tx] ${ch.name}` })),
|
||||
);
|
||||
|
||||
const actions = createNovelStoreChapterActions({
|
||||
//@ts-expect-error partial state/actions for testing
|
||||
set, //@ts-expect-error
|
||||
get,
|
||||
bootstrapService,
|
||||
chapterActionsDependencies: chapterDeps,
|
||||
transformChapters,
|
||||
defaultChapterSort: 'positionAsc',
|
||||
});
|
||||
|
||||
return {
|
||||
actions,
|
||||
getState: () => state,
|
||||
set,
|
||||
bootstrapService,
|
||||
chapterDeps,
|
||||
transformChapters,
|
||||
};
|
||||
};
|
||||
|
||||
describe('novelStore.chapterActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getNextChapterBatch appends transformed chapters and advances batch', async () => {
|
||||
const harness = createHarness();
|
||||
harness.bootstrapService.getNextChapterBatch.mockResolvedValue({
|
||||
batch: 1,
|
||||
chapters: [makeChapter(2), makeChapter(3)],
|
||||
});
|
||||
|
||||
await harness.actions.getNextChapterBatch();
|
||||
|
||||
expect(harness.bootstrapService.getNextChapterBatch).toHaveBeenCalledWith({
|
||||
novel: mockNovel,
|
||||
pages: ['1', '2'],
|
||||
pageIndex: 0,
|
||||
settingsSort: 'positionAsc',
|
||||
settingsFilter: [],
|
||||
batchInformation: { batch: 0, total: 4 },
|
||||
});
|
||||
expect(harness.getState().batchInformation.batch).toBe(1);
|
||||
expect(harness.getState().chapters.map(ch => ch.id)).toEqual([1, 2, 3]);
|
||||
expect(harness.getState().chapters[1].name).toBe('[tx] Chapter 2');
|
||||
});
|
||||
|
||||
it('getNextChapterBatch dedupes concurrent calls', async () => {
|
||||
const harness = createHarness();
|
||||
harness.bootstrapService.getNextChapterBatch.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
batch: 1,
|
||||
chapters: [makeChapter(2)],
|
||||
}),
|
||||
1,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
harness.actions.getNextChapterBatch(),
|
||||
harness.actions.getNextChapterBatch(),
|
||||
]);
|
||||
|
||||
expect(harness.bootstrapService.getNextChapterBatch).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(harness.getState().batchInformation.batch).toBe(1);
|
||||
expect(harness.getState().chapters.map(ch => ch.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('getNextChapterBatch guard keeps state stable when bootstrap returns no result', async () => {
|
||||
const harness = createHarness();
|
||||
const before = harness.getState();
|
||||
harness.bootstrapService.getNextChapterBatch.mockResolvedValue(undefined);
|
||||
|
||||
await harness.actions.getNextChapterBatch();
|
||||
|
||||
expect(harness.getState()).toEqual(before);
|
||||
expect(harness.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loadUpToBatch merges each loaded batch through onBatchLoaded callback', async () => {
|
||||
const harness = createHarness();
|
||||
harness.bootstrapService.loadUpToBatch.mockImplementation(async params => {
|
||||
params.onBatchLoaded(1, [makeChapter(2)]);
|
||||
params.onBatchLoaded(2, [makeChapter(3)]);
|
||||
});
|
||||
|
||||
await harness.actions.loadUpToBatch(2);
|
||||
|
||||
expect(harness.bootstrapService.loadUpToBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetBatch: 2,
|
||||
novel: mockNovel,
|
||||
settingsSort: 'positionAsc',
|
||||
settingsFilter: [],
|
||||
}),
|
||||
);
|
||||
expect(harness.getState().batchInformation.batch).toBe(2);
|
||||
expect(harness.getState().chapters.map(ch => ch.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('loadUpToBatch coalesces overlapping in-flight targets', async () => {
|
||||
const harness = createHarness();
|
||||
harness.bootstrapService.loadUpToBatch.mockImplementation(async params => {
|
||||
if (params.targetBatch === 2) {
|
||||
params.onBatchLoaded(1, [makeChapter(2)]);
|
||||
params.onBatchLoaded(2, [makeChapter(3)]);
|
||||
await Promise.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.targetBatch === 4) {
|
||||
params.onBatchLoaded(3, [makeChapter(4)]);
|
||||
params.onBatchLoaded(4, [makeChapter(5)]);
|
||||
}
|
||||
});
|
||||
|
||||
const first = harness.actions.loadUpToBatch(2);
|
||||
const second = harness.actions.loadUpToBatch(4);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(harness.bootstrapService.loadUpToBatch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
targetBatch: 2,
|
||||
}),
|
||||
);
|
||||
expect(harness.bootstrapService.loadUpToBatch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
targetBatch: 4,
|
||||
}),
|
||||
);
|
||||
expect(harness.getState().batchInformation.batch).toBe(4);
|
||||
expect(harness.getState().chapters.map(ch => ch.id)).toEqual([
|
||||
1, 2, 3, 4, 5,
|
||||
]);
|
||||
});
|
||||
|
||||
it('chapterTextCache supports read/write/remove/clear through state-backed cache', () => {
|
||||
const harness = createHarness();
|
||||
const pendingText = Promise.resolve('chapter text');
|
||||
|
||||
expect(harness.actions.chapterTextCache.read(1)).toBeUndefined();
|
||||
|
||||
harness.actions.chapterTextCache.write(1, pendingText);
|
||||
expect(harness.actions.chapterTextCache.read(1)).toBe(pendingText);
|
||||
expect(harness.getState().chapterTextCache[1]).toBe(pendingText);
|
||||
|
||||
harness.actions.chapterTextCache.remove(1);
|
||||
expect(harness.actions.chapterTextCache.read(1)).toBeUndefined();
|
||||
|
||||
harness.actions.chapterTextCache.write(2, 'second');
|
||||
harness.actions.chapterTextCache.clear();
|
||||
expect(harness.getState().chapterTextCache).toEqual({});
|
||||
});
|
||||
|
||||
it('updateChapter guard does nothing when novel is missing', () => {
|
||||
const harness = createHarness({ novel: undefined });
|
||||
|
||||
harness.actions.updateChapter(0, { progress: 87 });
|
||||
|
||||
expect(harness.getState().chapters[0].progress).toBe(0);
|
||||
expect(harness.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bookmarkChapters delegates to chapterActions and mutate guard blocks writes without novel', () => {
|
||||
const harness = createHarness({ novel: undefined });
|
||||
(
|
||||
bookmarkChaptersAction as jest.MockedFunction<
|
||||
typeof bookmarkChaptersAction
|
||||
>
|
||||
).mockImplementation((_chapters, mutate) => {
|
||||
mutate(chs => chs.map(ch => ({ ...ch, bookmark: true })));
|
||||
});
|
||||
|
||||
harness.actions.bookmarkChapters([makeChapter(1)]);
|
||||
|
||||
expect(bookmarkChaptersAction).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: 1 })],
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(harness.getState().chapters[0].bookmark).toBe(false);
|
||||
expect(harness.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('markChapterRead delegates mutation to low-level action with dependencies', () => {
|
||||
const harness = createHarness();
|
||||
(
|
||||
markChapterReadAction as jest.MockedFunction<typeof markChapterReadAction>
|
||||
).mockImplementation((chapterId, mutate) => {
|
||||
mutate(chs =>
|
||||
chs.map(ch => (ch.id === chapterId ? { ...ch, unread: false } : ch)),
|
||||
);
|
||||
});
|
||||
|
||||
harness.actions.markChapterRead(1);
|
||||
|
||||
expect(markChapterReadAction).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(harness.getState().chapters[0].unread).toBe(false);
|
||||
});
|
||||
|
||||
it('refreshChapters delegates computed args and fallback currentPage for guard-friendly params', () => {
|
||||
const harness = createHarness({ pages: [], pageIndex: 3, fetching: true });
|
||||
|
||||
harness.actions.refreshChapters();
|
||||
|
||||
expect(refreshChaptersAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
novel: mockNovel,
|
||||
fetching: true,
|
||||
settingsSort: 'positionAsc',
|
||||
settingsFilter: [],
|
||||
currentPage: '1',
|
||||
deps: harness.chapterDeps,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('delegates remaining chapter action entry points to low-level helpers', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.actions.markPreviouschaptersRead(3);
|
||||
harness.actions.markChaptersRead([makeChapter(1)]);
|
||||
harness.actions.markPreviousChaptersUnread(3);
|
||||
harness.actions.markChaptersUnread([makeChapter(1)]);
|
||||
harness.actions.updateChapterProgress(1, 50);
|
||||
harness.actions.deleteChapter(makeChapter(1));
|
||||
harness.actions.deleteChapters([makeChapter(1)]);
|
||||
|
||||
expect(markPreviouschaptersReadAction).toHaveBeenCalledWith(
|
||||
3,
|
||||
mockNovel,
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(markChaptersReadAction).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: 1 })],
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(markPreviousChaptersUnreadAction).toHaveBeenCalledWith(
|
||||
3,
|
||||
mockNovel,
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(markChaptersUnreadAction).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: 1 })],
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(updateChapterProgressAction).toHaveBeenCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(deleteChapterAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 1 }),
|
||||
mockNovel,
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
expect(deleteChaptersAction).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: 1 })],
|
||||
mockNovel,
|
||||
expect.any(Function),
|
||||
harness.chapterDeps,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createInitialChapterSlice } from '../store/novelStore.chapterState';
|
||||
|
||||
describe('novelStore.chapterState', () => {
|
||||
it('creates the expected initial chapter slice state', () => {
|
||||
expect(createInitialChapterSlice()).toEqual({
|
||||
chapters: [],
|
||||
firstUnreadChapter: undefined,
|
||||
chapterTextCache: {},
|
||||
batchInformation: {
|
||||
batch: 0,
|
||||
total: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns independent objects on each call', () => {
|
||||
const first = createInitialChapterSlice();
|
||||
const second = createInitialChapterSlice();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.batchInformation).not.toBe(second.batchInformation);
|
||||
expect(first.chapterTextCache).not.toBe(second.chapterTextCache);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import '../../../__tests__/mocks';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import {
|
||||
createNovelPersistenceBridge,
|
||||
defaultNovelSettings,
|
||||
defaultPageIndex,
|
||||
keyContract,
|
||||
LAST_READ_PREFIX,
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
novelPersistence,
|
||||
} from '../store-helper/contracts';
|
||||
|
||||
jest.mock('@services/ServiceManager', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
manager: {
|
||||
addTask: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@database/db', () => ({
|
||||
dbManager: {
|
||||
write: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const createStorage = () => {
|
||||
const numbers = new Map<string, number>();
|
||||
const strings = new Map<string, string>();
|
||||
|
||||
return {
|
||||
numbers,
|
||||
strings,
|
||||
getNumber: (key: string) => numbers.get(key),
|
||||
getString: (key: string) => strings.get(key),
|
||||
set: (key: string, value: number | string | boolean) => {
|
||||
if (typeof value === 'number') {
|
||||
numbers.set(key, value);
|
||||
strings.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
strings.set(key, String(value));
|
||||
numbers.delete(key);
|
||||
},
|
||||
delete: (key: string) => {
|
||||
numbers.delete(key);
|
||||
strings.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const sampleChapter: ChapterInfo = {
|
||||
id: 42,
|
||||
novelId: 7,
|
||||
name: 'Chapter 42',
|
||||
path: '/chapter/42',
|
||||
releaseTime: '2026-01-01',
|
||||
updatedTime: '2026-01-02',
|
||||
readTime: '2026-01-03',
|
||||
chapterNumber: 42,
|
||||
unread: false,
|
||||
isDownloaded: false,
|
||||
bookmark: true,
|
||||
progress: 70,
|
||||
page: '1',
|
||||
};
|
||||
|
||||
describe('novelPersistence bridge', () => {
|
||||
const input = {
|
||||
pluginId: 'webnovel',
|
||||
novelPath: 'api/novels/xyz-123',
|
||||
};
|
||||
|
||||
it('reads legacy continuity keys for page/settings/lastRead', () => {
|
||||
const storage = createStorage();
|
||||
const bridge = createNovelPersistenceBridge(storage);
|
||||
|
||||
const pageKey = `${NOVEL_PAGE_INDEX_PREFIX}_${input.pluginId}_${input.novelPath}`;
|
||||
const settingsKey = `${NOVEL_SETTINGS_PREFIX}_${input.pluginId}_${input.novelPath}`;
|
||||
const lastReadKey = `${LAST_READ_PREFIX}_${input.pluginId}_${input.novelPath}`;
|
||||
|
||||
storage.numbers.set(pageKey, 5);
|
||||
storage.strings.set(settingsKey, JSON.stringify(defaultNovelSettings));
|
||||
storage.strings.set(lastReadKey, JSON.stringify(sampleChapter));
|
||||
|
||||
expect(bridge.readPageIndex(input)).toBe(5);
|
||||
expect(bridge.readSettings(input)).toEqual(defaultNovelSettings);
|
||||
expect(bridge.readLastRead(input)).toEqual(sampleChapter);
|
||||
});
|
||||
|
||||
it('keeps bridge key builders aligned with shared key contract exports', () => {
|
||||
const bridge = createNovelPersistenceBridge(createStorage());
|
||||
|
||||
expect(bridge.keys.pageIndex(input)).toBe(
|
||||
`${NOVEL_PAGE_INDEX_PREFIX}_${input.pluginId}_${input.novelPath}`,
|
||||
);
|
||||
expect(bridge.keys.settings(input)).toBe(
|
||||
`${NOVEL_SETTINGS_PREFIX}_${input.pluginId}_${input.novelPath}`,
|
||||
);
|
||||
expect(bridge.keys.lastRead(input)).toBe(
|
||||
`${LAST_READ_PREFIX}_${input.pluginId}_${input.novelPath}`,
|
||||
);
|
||||
|
||||
expect(bridge.keys.pageIndex(input)).toBe(keyContract.pageIndex(input));
|
||||
expect(bridge.keys.settings(input)).toBe(keyContract.settings(input));
|
||||
expect(bridge.keys.lastRead(input)).toBe(keyContract.lastRead(input));
|
||||
});
|
||||
|
||||
it('recovers from corrupt persisted values with safe defaults', () => {
|
||||
const storage = createStorage();
|
||||
const bridge = createNovelPersistenceBridge(storage);
|
||||
|
||||
const pageKey = bridge.keys.pageIndex(input);
|
||||
const settingsKey = bridge.keys.settings(input);
|
||||
const lastReadKey = bridge.keys.lastRead(input);
|
||||
|
||||
storage.strings.set(pageKey, 'not-a-number');
|
||||
storage.strings.set(settingsKey, '{invalid-json');
|
||||
storage.strings.set(lastReadKey, JSON.stringify({ bad: 'shape' }));
|
||||
|
||||
expect(bridge.readPageIndex(input)).toBe(defaultPageIndex);
|
||||
expect(bridge.readSettings(input)).toEqual(defaultNovelSettings);
|
||||
expect(bridge.readLastRead(input)).toBeUndefined();
|
||||
expect(storage.numbers.get(pageKey)).toBe(defaultPageIndex);
|
||||
expect(storage.strings.get(settingsKey)).toBe(
|
||||
JSON.stringify(defaultNovelSettings),
|
||||
);
|
||||
expect(storage.strings.has(lastReadKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('copies settings and lastRead via stable bridge API', () => {
|
||||
const storage = createStorage();
|
||||
const bridge = createNovelPersistenceBridge(storage);
|
||||
|
||||
const from = {
|
||||
pluginId: 'source-plugin',
|
||||
novelPath: 'source/path',
|
||||
};
|
||||
const to = {
|
||||
pluginId: 'target-plugin',
|
||||
novelPath: 'target/path',
|
||||
};
|
||||
|
||||
storage.strings.set(
|
||||
bridge.keys.settings(from),
|
||||
JSON.stringify(defaultNovelSettings),
|
||||
);
|
||||
storage.strings.set(
|
||||
bridge.keys.lastRead(from),
|
||||
JSON.stringify(sampleChapter),
|
||||
);
|
||||
|
||||
bridge.copySettings(from, to);
|
||||
bridge.copyLastRead(from, to);
|
||||
|
||||
expect(storage.strings.get(bridge.keys.settings(to))).toBe(
|
||||
JSON.stringify(defaultNovelSettings),
|
||||
);
|
||||
expect(storage.strings.get(bridge.keys.lastRead(to))).toBe(
|
||||
JSON.stringify(sampleChapter),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps migrate contract usage compile-safe through stable exports', () => {
|
||||
const { migrateNovel } = require('@services/migrate/migrateNovel');
|
||||
|
||||
expect(typeof novelPersistence.copySettings).toBe('function');
|
||||
expect(typeof novelPersistence.readLastRead).toBe('function');
|
||||
expect(typeof migrateNovel).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { useNovelSettings } from '../../useNovelSettings';
|
||||
|
||||
const mockUseNovelValue = jest.fn();
|
||||
const mockUseNovelAction = jest.fn();
|
||||
|
||||
jest.mock('@screens/novel/NovelContext', () => ({
|
||||
useNovelValue: (key: string) => mockUseNovelValue(key),
|
||||
useNovelAction: (key: string) => mockUseNovelAction(key),
|
||||
}));
|
||||
|
||||
jest.mock('../../useSettings', () => ({
|
||||
useAppSettings: () => ({
|
||||
defaultChapterSort: 'positionAsc',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useNovelSettings', () => {
|
||||
const baseNovel = {
|
||||
id: 1,
|
||||
path: '/novels/test',
|
||||
pluginId: 'plugin.test',
|
||||
name: 'Novel',
|
||||
inLibrary: false,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads selector-backed values and writes through setNovelSettings', async () => {
|
||||
const storeSetNovelSettings = jest.fn();
|
||||
const storeNovelSettings = {
|
||||
sort: 'positionDesc',
|
||||
filter: ['read'],
|
||||
showChapterTitles: true,
|
||||
};
|
||||
|
||||
mockUseNovelValue.mockImplementation(key => {
|
||||
if (key === 'novel') return baseNovel;
|
||||
if (key === 'novelSettings') return storeNovelSettings;
|
||||
return undefined;
|
||||
});
|
||||
mockUseNovelAction.mockImplementation(key => {
|
||||
if (key === 'setNovelSettings') return storeSetNovelSettings;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNovelSettings());
|
||||
|
||||
expect(result.current.sort).toBe('positionDesc');
|
||||
expect(result.current.filter).toEqual(['read']);
|
||||
expect(result.current.showChapterTitles).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setChapterSort('nameDesc');
|
||||
});
|
||||
|
||||
expect(storeSetNovelSettings).toHaveBeenCalledWith({
|
||||
showChapterTitles: true,
|
||||
sort: 'nameDesc',
|
||||
filter: ['read'],
|
||||
});
|
||||
expect(storeSetNovelSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to app default sort and persists it when changing filter', async () => {
|
||||
const storeSetNovelSettings = jest.fn();
|
||||
const storeNovelSettings = {
|
||||
filter: ['read'],
|
||||
showChapterTitles: true,
|
||||
};
|
||||
|
||||
mockUseNovelValue.mockImplementation(key => {
|
||||
if (key === 'novel') return baseNovel;
|
||||
if (key === 'novelSettings') return storeNovelSettings;
|
||||
return undefined;
|
||||
});
|
||||
mockUseNovelAction.mockImplementation(key => {
|
||||
if (key === 'setNovelSettings') return storeSetNovelSettings;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNovelSettings());
|
||||
|
||||
expect(result.current.sort).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setChapterFilter(['downloaded']);
|
||||
});
|
||||
|
||||
expect(storeSetNovelSettings).toHaveBeenCalledWith({
|
||||
showChapterTitles: true,
|
||||
sort: 'positionAsc',
|
||||
filter: ['downloaded'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write sort/filter settings when novel is absent', async () => {
|
||||
const storeSetNovelSettings = jest.fn();
|
||||
|
||||
mockUseNovelValue.mockImplementation(key => {
|
||||
if (key === 'novel') return undefined;
|
||||
if (key === 'novelSettings') {
|
||||
return {
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
mockUseNovelAction.mockImplementation(key => {
|
||||
if (key === 'setNovelSettings') return storeSetNovelSettings;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNovelSettings());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setChapterSort('nameDesc');
|
||||
await result.current.setChapterFilter(['read']);
|
||||
});
|
||||
|
||||
expect(storeSetNovelSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import {
|
||||
getChapterCount as defaultGetChapterCount,
|
||||
getChapterCountSync as defaultGetChapterCountSync,
|
||||
getCustomPages as defaultGetCustomPages,
|
||||
getFirstUnreadChapter as defaultGetFirstUnreadChapter,
|
||||
getNovelChaptersSync as defaultGetNovelChaptersSync,
|
||||
getPageChapters as defaultGetPageChapters,
|
||||
getPageChaptersBatched as defaultGetPageChaptersBatched,
|
||||
insertChapters as defaultInsertChapters,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import {
|
||||
getNovelById as defaultGetNovelById,
|
||||
getNovelByPath as defaultGetNovelByPath,
|
||||
insertNovelAndChapters as defaultInsertNovelAndChapters,
|
||||
} from '@database/queries/NovelQueries';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import {
|
||||
fetchNovel as defaultFetchNovel,
|
||||
fetchPage as defaultFetchPage,
|
||||
} from '@services/plugin/fetch';
|
||||
import { getString as defaultGetString } from '@strings/translations';
|
||||
import { BatchInfo } from '../types';
|
||||
|
||||
export interface ChapterLoadResult {
|
||||
chapters: ChapterInfo[];
|
||||
batchInformation: BatchInfo;
|
||||
firstUnreadChapter: ChapterInfo | undefined;
|
||||
}
|
||||
|
||||
export interface BootstrapSuccessResult extends ChapterLoadResult {
|
||||
ok: true;
|
||||
novel: NovelInfo;
|
||||
pages: string[];
|
||||
}
|
||||
|
||||
export interface BootstrapFailureResult {
|
||||
ok: false;
|
||||
reason: 'missing-novel' | 'missing-chapters' | 'error';
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export type BootstrapResult = BootstrapSuccessResult | BootstrapFailureResult;
|
||||
|
||||
const inflightBootstraps = new Map<string, Promise<BootstrapResult>>();
|
||||
|
||||
const getBootstrapKey = (pluginId: string, novelPath: string) =>
|
||||
`${pluginId}_${novelPath}`;
|
||||
|
||||
const defaultBootstrapServiceDependencies = {
|
||||
getCustomPages: defaultGetCustomPages,
|
||||
getNovelByPath: defaultGetNovelByPath,
|
||||
getNovelById: defaultGetNovelById,
|
||||
fetchNovel: defaultFetchNovel,
|
||||
insertNovelAndChapters: defaultInsertNovelAndChapters,
|
||||
getChapterCount: defaultGetChapterCount,
|
||||
getChapterCountSync: defaultGetChapterCountSync,
|
||||
getPageChaptersBatched: defaultGetPageChaptersBatched,
|
||||
getNovelChaptersSync: defaultGetNovelChaptersSync,
|
||||
fetchPage: defaultFetchPage,
|
||||
insertChapters: defaultInsertChapters,
|
||||
getPageChapters: defaultGetPageChapters,
|
||||
getFirstUnreadChapter: defaultGetFirstUnreadChapter,
|
||||
getString: defaultGetString,
|
||||
} as const;
|
||||
export type BootstrapServiceDependencies =
|
||||
typeof defaultBootstrapServiceDependencies;
|
||||
|
||||
export const createBootstrapService = (
|
||||
dependencies: Partial<BootstrapServiceDependencies> = {},
|
||||
) => {
|
||||
const deps: BootstrapServiceDependencies = {
|
||||
...defaultBootstrapServiceDependencies,
|
||||
...dependencies,
|
||||
};
|
||||
|
||||
const calculatePages = (tmpNovel: NovelInfo): string[] => {
|
||||
let tmpPages: string[];
|
||||
if ((tmpNovel.totalPages ?? 0) > 0) {
|
||||
tmpPages = Array(tmpNovel.totalPages)
|
||||
.fill(0)
|
||||
.map((_, idx) => String(idx + 1));
|
||||
} else {
|
||||
tmpPages = deps
|
||||
.getCustomPages(tmpNovel.id)
|
||||
.map(c => c.page)
|
||||
.filter((page): page is string => page !== null);
|
||||
}
|
||||
|
||||
return tmpPages.length > 1 ? tmpPages : ['1'];
|
||||
};
|
||||
|
||||
const resolveNovel = async (
|
||||
novelPath: string,
|
||||
pluginId: string,
|
||||
): Promise<NovelInfo | undefined> => {
|
||||
let tmpNovel = deps.getNovelByPath(novelPath, pluginId);
|
||||
if (!tmpNovel) {
|
||||
const sourceNovel = await deps
|
||||
.fetchNovel(pluginId, novelPath)
|
||||
.catch(() => {
|
||||
throw new Error(deps.getString('updatesScreen.unableToGetNovel'));
|
||||
});
|
||||
await deps.insertNovelAndChapters(pluginId, sourceNovel);
|
||||
tmpNovel = deps.getNovelByPath(novelPath, pluginId);
|
||||
|
||||
if (!tmpNovel) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return tmpNovel;
|
||||
};
|
||||
|
||||
const getChaptersForPage = async ({
|
||||
novel,
|
||||
novelPath,
|
||||
pluginId,
|
||||
pages,
|
||||
pageIndex,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
}: {
|
||||
novel: NovelInfo;
|
||||
novelPath: string;
|
||||
pluginId: string;
|
||||
pages: string[];
|
||||
pageIndex: number;
|
||||
settingsSort: ChapterOrderKey;
|
||||
settingsFilter: ChapterFilterKey[];
|
||||
}): Promise<ChapterLoadResult> => {
|
||||
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,
|
||||
);
|
||||
if (chapterCount) {
|
||||
try {
|
||||
newChapters = (await deps.getPageChaptersBatched(...config)) || [];
|
||||
} catch {
|
||||
newChapters = [];
|
||||
}
|
||||
} else if (settingsFilter.length === 0) {
|
||||
const sourcePage = await deps.fetchPage(pluginId, novelPath, page);
|
||||
const sourceChapters = sourcePage.chapters.map(ch => {
|
||||
return {
|
||||
...ch,
|
||||
page,
|
||||
};
|
||||
});
|
||||
await deps.insertChapters(novel.id, sourceChapters);
|
||||
newChapters = await deps.getPageChapters(...config);
|
||||
chapterCount = await deps.getChapterCount(novel.id, page, settingsFilter);
|
||||
}
|
||||
|
||||
const batchInformation: BatchInfo = {
|
||||
batch: 0,
|
||||
total: Math.floor(chapterCount / 1000),
|
||||
totalChapters: chapterCount,
|
||||
};
|
||||
const unread = deps.getFirstUnreadChapter(novel.id, settingsFilter, page);
|
||||
return {
|
||||
chapters: newChapters,
|
||||
batchInformation,
|
||||
firstUnreadChapter: unread ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const getNextChapterBatch = async ({
|
||||
novel,
|
||||
pages,
|
||||
pageIndex,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
batchInformation,
|
||||
}: {
|
||||
novel: NovelInfo | undefined;
|
||||
pages: string[];
|
||||
pageIndex: number;
|
||||
settingsSort: ChapterOrderKey;
|
||||
settingsFilter: ChapterFilterKey[];
|
||||
batchInformation: BatchInfo;
|
||||
}) => {
|
||||
const page = pages[pageIndex];
|
||||
const nextBatch = batchInformation.batch + 1;
|
||||
if (!novel || !page || nextBatch > batchInformation.total) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newChapters: ChapterInfo[] = [];
|
||||
try {
|
||||
newChapters =
|
||||
(await deps.getPageChaptersBatched(
|
||||
novel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
page,
|
||||
nextBatch,
|
||||
)) || [];
|
||||
} catch {
|
||||
newChapters = [];
|
||||
}
|
||||
|
||||
return {
|
||||
batch: nextBatch,
|
||||
chapters: newChapters,
|
||||
};
|
||||
};
|
||||
|
||||
const loadUpToBatch = async ({
|
||||
targetBatch,
|
||||
novel,
|
||||
pages,
|
||||
pageIndex,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
batchInformation,
|
||||
onBatchLoaded,
|
||||
}: {
|
||||
targetBatch: number;
|
||||
novel: NovelInfo | undefined;
|
||||
pages: string[];
|
||||
pageIndex: number;
|
||||
settingsSort: ChapterOrderKey;
|
||||
settingsFilter: ChapterFilterKey[];
|
||||
batchInformation: BatchInfo;
|
||||
onBatchLoaded: (batch: number, chapters: ChapterInfo[]) => void;
|
||||
}) => {
|
||||
const page = pages[pageIndex] ?? '1';
|
||||
if (!novel || !page || targetBatch <= batchInformation.batch) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (
|
||||
let batch = batchInformation.batch + 1;
|
||||
batch <= targetBatch;
|
||||
batch++
|
||||
) {
|
||||
if (batch > batchInformation.total) break;
|
||||
|
||||
let newChapters: ChapterInfo[] = [];
|
||||
try {
|
||||
newChapters =
|
||||
(await deps.getPageChaptersBatched(
|
||||
novel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
page,
|
||||
batch,
|
||||
)) || [];
|
||||
} catch {
|
||||
newChapters = [];
|
||||
}
|
||||
|
||||
onBatchLoaded(batch, newChapters);
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrapNovelAsync = async ({
|
||||
novel,
|
||||
novelPath,
|
||||
pluginId,
|
||||
pageIndex,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
}: {
|
||||
novel: NovelInfo | undefined;
|
||||
novelPath: string;
|
||||
pluginId: string;
|
||||
pageIndex: number;
|
||||
settingsSort: ChapterOrderKey;
|
||||
settingsFilter: ChapterFilterKey[];
|
||||
}): Promise<BootstrapResult> => {
|
||||
const key = getBootstrapKey(pluginId, novelPath);
|
||||
const existing = inflightBootstraps.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bootstrapPromise = (async () => {
|
||||
try {
|
||||
const resolvedNovel =
|
||||
novel ?? (await resolveNovel(novelPath, pluginId));
|
||||
if (!resolvedNovel) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-novel',
|
||||
} satisfies BootstrapFailureResult;
|
||||
}
|
||||
|
||||
const pages = calculatePages(resolvedNovel);
|
||||
const chapterState = await getChaptersForPage({
|
||||
novel: resolvedNovel,
|
||||
novelPath,
|
||||
pluginId,
|
||||
pages,
|
||||
pageIndex,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
novel: resolvedNovel,
|
||||
pages,
|
||||
...chapterState,
|
||||
} satisfies BootstrapSuccessResult;
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'error',
|
||||
error,
|
||||
} satisfies BootstrapFailureResult;
|
||||
} finally {
|
||||
inflightBootstraps.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
inflightBootstraps.set(key, bootstrapPromise);
|
||||
return bootstrapPromise;
|
||||
};
|
||||
const bootstrapNovelSync = ({
|
||||
novel: _novel,
|
||||
novelPath,
|
||||
pluginId,
|
||||
pageIndex,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
}: {
|
||||
novel: NovelInfo | undefined;
|
||||
novelPath: string;
|
||||
pluginId: string;
|
||||
pageIndex: number;
|
||||
settingsSort: ChapterOrderKey;
|
||||
settingsFilter: ChapterFilterKey[];
|
||||
}): BootstrapResult => {
|
||||
try {
|
||||
const novel = !_novel?.id
|
||||
? deps.getNovelByPath(novelPath, pluginId)
|
||||
: deps.getNovelById(_novel.id);
|
||||
if (!novel) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-novel',
|
||||
} satisfies BootstrapFailureResult;
|
||||
}
|
||||
|
||||
const pages = calculatePages(novel);
|
||||
const page = pages[pageIndex] ?? '1';
|
||||
const chapterCount =
|
||||
settingsFilter.length === 0 && pages.length === 1
|
||||
? novel.totalChapters ?? 0
|
||||
: deps.getChapterCountSync(novel.id, page, settingsFilter);
|
||||
if (chapterCount === 0 && settingsFilter.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-chapters',
|
||||
} satisfies BootstrapFailureResult;
|
||||
}
|
||||
|
||||
const config = [
|
||||
novel.id,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
page,
|
||||
1000,
|
||||
] as const;
|
||||
|
||||
const newChapters = deps.getNovelChaptersSync(...config);
|
||||
|
||||
const batchInformation: BatchInfo = {
|
||||
batch: 0,
|
||||
total: Math.floor(chapterCount / 1000),
|
||||
totalChapters: chapterCount,
|
||||
};
|
||||
const unread = deps.getFirstUnreadChapter(novel.id, settingsFilter, page);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
novel,
|
||||
pages,
|
||||
chapters: newChapters,
|
||||
batchInformation,
|
||||
firstUnreadChapter: unread ?? undefined,
|
||||
} satisfies BootstrapSuccessResult;
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'error',
|
||||
error,
|
||||
} satisfies BootstrapFailureResult;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getChaptersForPage,
|
||||
getNextChapterBatch,
|
||||
loadUpToBatch,
|
||||
bootstrapNovelAsync,
|
||||
bootstrapNovelSync,
|
||||
};
|
||||
};
|
||||
|
||||
export const bootstrapService = createBootstrapService();
|
||||
@@ -0,0 +1,11 @@
|
||||
export type { KeyContractInput as NovelPersistenceInput } from './keyContract';
|
||||
export { keyContract } from './keyContract';
|
||||
export {
|
||||
createNovelPersistenceBridge,
|
||||
novelPersistence,
|
||||
defaultNovelSettings,
|
||||
defaultPageIndex,
|
||||
LAST_READ_PREFIX,
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
} from './persistence';
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
LAST_READ_PREFIX,
|
||||
} from '../types';
|
||||
|
||||
export interface KeyContractInput {
|
||||
pluginId: string;
|
||||
novelPath: string;
|
||||
}
|
||||
|
||||
export const keyContract = {
|
||||
pageIndex: (input: KeyContractInput): string => {
|
||||
return `${NOVEL_PAGE_INDEX_PREFIX}_${input.pluginId}_${input.novelPath}`;
|
||||
},
|
||||
|
||||
settings: (input: KeyContractInput): string => {
|
||||
return `${NOVEL_SETTINGS_PREFIX}_${input.pluginId}_${input.novelPath}`;
|
||||
},
|
||||
|
||||
lastRead: (input: KeyContractInput): string => {
|
||||
return `${LAST_READ_PREFIX}_${input.pluginId}_${input.novelPath}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { KeyContractInput, keyContract } from './keyContract';
|
||||
import {
|
||||
defaultNovelSettings,
|
||||
defaultPageIndex,
|
||||
NovelSettings,
|
||||
LAST_READ_PREFIX,
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
NovelSettingsWithoutSort,
|
||||
} from '../types';
|
||||
|
||||
export type NovelPersistenceInput = KeyContractInput;
|
||||
|
||||
interface NovelPersistenceStorage {
|
||||
getNumber: (key: string) => number | undefined;
|
||||
getString: (key: string) => string | undefined;
|
||||
set: (key: string, value: number | string | boolean) => void;
|
||||
delete: (key: string) => void;
|
||||
}
|
||||
|
||||
const defaultStorage: NovelPersistenceStorage = {
|
||||
getNumber: key => MMKVStorage.getNumber(key),
|
||||
getString: key => MMKVStorage.getString(key),
|
||||
set: (key, value) => MMKVStorage.set(key, value),
|
||||
delete: key => MMKVStorage.remove(key),
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const parseJsonSafely = (value: string): unknown | undefined => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const isValidNovelSettings = (value: unknown): value is NovelSettings => {
|
||||
if (!isRecord(value)) return false;
|
||||
if (!Array.isArray(value.filter)) return false;
|
||||
if (!value.filter.every(filter => typeof filter === 'string')) return false;
|
||||
if (value.sort !== undefined && typeof value.sort !== 'string') return false;
|
||||
if (
|
||||
value.showChapterTitles !== undefined &&
|
||||
typeof value.showChapterTitles !== 'boolean'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const isValidChapterLike = (value: unknown): value is ChapterInfo => {
|
||||
return isRecord(value) && typeof value.id === 'number';
|
||||
};
|
||||
|
||||
export const createNovelPersistenceBridge = (
|
||||
storage: NovelPersistenceStorage = defaultStorage,
|
||||
) => {
|
||||
const keys = {
|
||||
pageIndex: (input: NovelPersistenceInput) => keyContract.pageIndex(input),
|
||||
settings: (input: NovelPersistenceInput) => keyContract.settings(input),
|
||||
lastRead: (input: NovelPersistenceInput) => keyContract.lastRead(input),
|
||||
};
|
||||
|
||||
const readPageIndex = (input: NovelPersistenceInput): number => {
|
||||
const key = keys.pageIndex(input);
|
||||
const numberValue = storage.getNumber(key);
|
||||
|
||||
if (typeof numberValue === 'number' && Number.isFinite(numberValue)) {
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
const stringValue = storage.getString(key);
|
||||
if (stringValue !== undefined) {
|
||||
const parsed = Number(stringValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
storage.set(key, parsed);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
storage.set(key, defaultPageIndex);
|
||||
return defaultPageIndex;
|
||||
};
|
||||
|
||||
const writePageIndex = (input: NovelPersistenceInput, value: number) => {
|
||||
const key = keys.pageIndex(input);
|
||||
const safeValue = Number.isFinite(value) ? value : defaultPageIndex;
|
||||
storage.set(key, safeValue);
|
||||
};
|
||||
|
||||
const readSettings = (
|
||||
input: NovelPersistenceInput,
|
||||
): NovelSettingsWithoutSort => {
|
||||
const key = keys.settings(input);
|
||||
const raw = storage.getString(key);
|
||||
if (raw === undefined) {
|
||||
return defaultNovelSettings;
|
||||
}
|
||||
|
||||
const parsed = parseJsonSafely(raw);
|
||||
if (isValidNovelSettings(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
storage.delete(key);
|
||||
storage.set(key, JSON.stringify(defaultNovelSettings));
|
||||
return defaultNovelSettings;
|
||||
};
|
||||
|
||||
const writeSettings = (
|
||||
input: NovelPersistenceInput,
|
||||
value: NovelSettingsWithoutSort,
|
||||
) => {
|
||||
const key = keys.settings(input);
|
||||
storage.set(key, JSON.stringify(value));
|
||||
};
|
||||
|
||||
const readLastRead = (
|
||||
input: NovelPersistenceInput,
|
||||
): ChapterInfo | undefined => {
|
||||
const key = keys.lastRead(input);
|
||||
const raw = storage.getString(key);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = parseJsonSafely(raw);
|
||||
if (isValidChapterLike(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
storage.delete(key);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const writeLastRead = (input: NovelPersistenceInput, value: ChapterInfo) => {
|
||||
const key = keys.lastRead(input);
|
||||
storage.set(key, JSON.stringify(value));
|
||||
};
|
||||
|
||||
const copySettings = (
|
||||
from: NovelPersistenceInput,
|
||||
to: NovelPersistenceInput,
|
||||
) => {
|
||||
const settings = readSettings(from);
|
||||
if (settings) {
|
||||
writeSettings(to, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLastRead = (
|
||||
from: NovelPersistenceInput,
|
||||
to: NovelPersistenceInput,
|
||||
) => {
|
||||
const lastRead = readLastRead(from);
|
||||
if (lastRead) {
|
||||
writeLastRead(to, lastRead);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
keys,
|
||||
readPageIndex,
|
||||
writePageIndex,
|
||||
readSettings,
|
||||
writeSettings,
|
||||
readLastRead,
|
||||
writeLastRead,
|
||||
copySettings,
|
||||
copyLastRead,
|
||||
};
|
||||
};
|
||||
|
||||
export const novelPersistence = createNovelPersistenceBridge();
|
||||
|
||||
export {
|
||||
defaultNovelSettings,
|
||||
defaultPageIndex,
|
||||
LAST_READ_PREFIX,
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
keyContract,
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import {
|
||||
bookmarkChapter as _bookmarkChapter,
|
||||
deleteChapter as _deleteChapter,
|
||||
deleteChapters as _deleteChapters,
|
||||
getPageChapters as _getPageChapters,
|
||||
markChapterRead as _markChapterRead,
|
||||
markChaptersRead as _markChaptersRead,
|
||||
markChaptersUnread as _markChaptersUnread,
|
||||
markPreviousChaptersUnread as _markPreviousChaptersUnread,
|
||||
markPreviuschaptersRead as _markPreviuschaptersRead,
|
||||
updateChapterProgress as _updateChapterProgress,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { getString as translateGetString } from '@strings/translations';
|
||||
import { showToast } from '@utils/showToast';
|
||||
|
||||
type MutateChapters = (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => void;
|
||||
type SetChapters = (chs: ChapterInfo[]) => void;
|
||||
type TransformChapters = (chs: ChapterInfo[]) => ChapterInfo[];
|
||||
|
||||
export interface ChapterActionsDependencies {
|
||||
bookmarkChapter: (chapterId: number) => Promise<void>;
|
||||
markChapterRead: (chapterId: number) => Promise<void>;
|
||||
markChaptersRead: (chapterIds: number[]) => Promise<void>;
|
||||
markPreviuschaptersRead: (
|
||||
chapterId: number,
|
||||
novelId: number,
|
||||
) => Promise<void>;
|
||||
markPreviousChaptersUnread: (
|
||||
chapterId: number,
|
||||
novelId: number,
|
||||
) => Promise<void>;
|
||||
markChaptersUnread: (chapterIds: number[]) => Promise<void>;
|
||||
updateChapterProgress: (chapterId: number, progress: number) => Promise<void>;
|
||||
deleteChapter: (
|
||||
pluginId: string,
|
||||
novelId: number,
|
||||
chapterId: number,
|
||||
) => Promise<void>;
|
||||
deleteChapters: (
|
||||
pluginId: string,
|
||||
novelId: number,
|
||||
chapters?: ChapterInfo[],
|
||||
) => Promise<void>;
|
||||
getPageChapters: (
|
||||
novelId: number,
|
||||
sort?: ChapterOrderKey,
|
||||
filter?: ChapterFilterKey[],
|
||||
page?: string,
|
||||
) => Promise<ChapterInfo[]>;
|
||||
showToast: (message: string) => void;
|
||||
getString: typeof translateGetString;
|
||||
}
|
||||
|
||||
export const defaultChapterActionsDependencies: ChapterActionsDependencies = {
|
||||
bookmarkChapter: _bookmarkChapter,
|
||||
markChapterRead: _markChapterRead,
|
||||
markChaptersRead: _markChaptersRead,
|
||||
markPreviuschaptersRead: _markPreviuschaptersRead,
|
||||
markPreviousChaptersUnread: _markPreviousChaptersUnread,
|
||||
markChaptersUnread: _markChaptersUnread,
|
||||
updateChapterProgress: _updateChapterProgress,
|
||||
deleteChapter: _deleteChapter,
|
||||
deleteChapters: _deleteChapters,
|
||||
getPageChapters: _getPageChapters,
|
||||
showToast,
|
||||
getString: translateGetString,
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
};
|
||||
|
||||
const runAsyncAction = (
|
||||
promise: Promise<unknown>,
|
||||
deps: ChapterActionsDependencies,
|
||||
) => {
|
||||
promise.catch(error => {
|
||||
deps.showToast(getErrorMessage(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const bookmarkChaptersAction = (
|
||||
_chapters: ChapterInfo[],
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
runAsyncAction(
|
||||
Promise.all(_chapters.map(_chapter => deps.bookmarkChapter(_chapter.id))),
|
||||
deps,
|
||||
);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (_chapters.some(_c => _c.id === chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
bookmark: !chapter.bookmark,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const markPreviouschaptersReadAction = (
|
||||
chapterId: number,
|
||||
novel: NovelInfo | undefined,
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
if (novel) {
|
||||
runAsyncAction(deps.markPreviuschaptersRead(chapterId, novel.id), deps);
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter =>
|
||||
chapter.id <= chapterId ? { ...chapter, unread: false } : chapter,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const markChapterReadAction = (
|
||||
chapterId: number,
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
runAsyncAction(deps.markChapterRead(chapterId), deps);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(c => {
|
||||
if (c.id !== chapterId) {
|
||||
return c;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
unread: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const markChaptersReadAction = (
|
||||
_chapters: ChapterInfo[],
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
const chapterIds = _chapters.map(chapter => chapter.id);
|
||||
runAsyncAction(deps.markChaptersRead(chapterIds), deps);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (chapterIds.includes(chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
unread: false,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const markPreviousChaptersUnreadAction = (
|
||||
chapterId: number,
|
||||
novel: NovelInfo | undefined,
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
if (novel) {
|
||||
runAsyncAction(deps.markPreviousChaptersUnread(chapterId, novel.id), deps);
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter =>
|
||||
chapter.id <= chapterId ? { ...chapter, unread: true } : chapter,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const markChaptersUnreadAction = (
|
||||
_chapters: ChapterInfo[],
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
const chapterIds = _chapters.map(chapter => chapter.id);
|
||||
runAsyncAction(deps.markChaptersUnread(chapterIds), deps);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (chapterIds.includes(chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
unread: true,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const updateChapterProgressAction = (
|
||||
chapterId: number,
|
||||
progress: number,
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
const normalizedProgress = Math.min(progress, 100);
|
||||
runAsyncAction(
|
||||
deps.updateChapterProgress(chapterId, normalizedProgress),
|
||||
deps,
|
||||
);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(c => {
|
||||
if (c.id !== chapterId) {
|
||||
return c;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
progress: normalizedProgress,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteChapterAction = (
|
||||
_chapter: ChapterInfo,
|
||||
novel: NovelInfo | undefined,
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
if (novel) {
|
||||
runAsyncAction(
|
||||
(async () => {
|
||||
await deps.deleteChapter(novel.pluginId, novel.id, _chapter.id);
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (chapter.id !== _chapter.id) {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
return {
|
||||
...chapter,
|
||||
isDownloaded: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
deps.showToast(deps.getString('common.deleted', { name: _chapter.name }));
|
||||
})(),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteChaptersAction = (
|
||||
_chapters: ChapterInfo[],
|
||||
novel: NovelInfo | undefined,
|
||||
mutateChapters: MutateChapters,
|
||||
deps: ChapterActionsDependencies = defaultChapterActionsDependencies,
|
||||
) => {
|
||||
if (novel) {
|
||||
runAsyncAction(
|
||||
(async () => {
|
||||
await deps.deleteChapters(novel.pluginId, novel.id, _chapters);
|
||||
deps.showToast(
|
||||
deps.getString('updatesScreen.deletedChapters', {
|
||||
num: _chapters.length,
|
||||
}),
|
||||
);
|
||||
|
||||
mutateChapters(chs =>
|
||||
chs.map(chapter => {
|
||||
if (_chapters.some(_c => _c.id === chapter.id)) {
|
||||
return {
|
||||
...chapter,
|
||||
isDownloaded: false,
|
||||
};
|
||||
}
|
||||
return chapter;
|
||||
}),
|
||||
);
|
||||
})(),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export interface RefreshChaptersParams {
|
||||
novel: NovelInfo | undefined;
|
||||
fetching: boolean;
|
||||
settingsSort: ChapterOrderKey;
|
||||
settingsFilter: ChapterFilterKey[];
|
||||
currentPage: string;
|
||||
transformChapters: TransformChapters;
|
||||
setChapters: SetChapters;
|
||||
deps?: ChapterActionsDependencies;
|
||||
}
|
||||
|
||||
export const refreshChaptersAction = ({
|
||||
novel,
|
||||
fetching,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
currentPage,
|
||||
transformChapters,
|
||||
setChapters,
|
||||
deps = defaultChapterActionsDependencies,
|
||||
}: RefreshChaptersParams) => {
|
||||
if (novel?.id && !fetching) {
|
||||
runAsyncAction(
|
||||
deps
|
||||
.getPageChapters(novel.id, settingsSort, settingsFilter, currentPage)
|
||||
.then(chs => {
|
||||
setChapters(transformChapters(chs));
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { NovelSettings } from '@hooks/persisted/useNovel';
|
||||
import { novelPersistence } from '@hooks/persisted/useNovel/store-helper/persistence';
|
||||
import { createNovelSlice } from '@hooks/persisted/useNovel/store/novelStore';
|
||||
import { ChapterOrderKey } from '@database/constants';
|
||||
import {
|
||||
NovelStoreApi,
|
||||
NovelStoreDependencies,
|
||||
NovelStoreState,
|
||||
} from './novelStore.types';
|
||||
import { createStore as createZustandStore } from 'zustand';
|
||||
import { createNovelStoreActions } from './novelStore.actions';
|
||||
import { createInitialChapterSlice } from './novelStore.chapterState';
|
||||
import { createNovelStoreChapterActions } from './novelStore.chapterActions';
|
||||
import { createBootstrapService } from '../store-helper/bootstrapService';
|
||||
import { defaultChapterActionsDependencies } from './chapterActions';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
path: string;
|
||||
novel?: NovelInfo;
|
||||
defaultChapterSort: ChapterOrderKey;
|
||||
switchNovelToLibrary: (novelPath: string, pluginId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createStore({
|
||||
novel,
|
||||
defaultChapterSort,
|
||||
path,
|
||||
pluginId,
|
||||
switchNovelToLibrary,
|
||||
}: Props): NovelStoreApi {
|
||||
const persistenceInput = {
|
||||
pluginId,
|
||||
novelPath: path,
|
||||
};
|
||||
|
||||
const novelSettings: NovelSettings = {
|
||||
sort: defaultChapterSort,
|
||||
...novelPersistence.readSettings(persistenceInput),
|
||||
};
|
||||
|
||||
const bootstrapService = createBootstrapService();
|
||||
const deps: NovelStoreDependencies = {
|
||||
bootstrapService,
|
||||
chapterActionsDependencies: defaultChapterActionsDependencies,
|
||||
transformChapters: c => c,
|
||||
persistPageIndex: value =>
|
||||
novelPersistence.writePageIndex(persistenceInput, value),
|
||||
persistNovelSettings: value => {
|
||||
novelPersistence.writeSettings(persistenceInput, value);
|
||||
},
|
||||
persistLastRead: chapter =>
|
||||
novelPersistence.writeLastRead(persistenceInput, chapter),
|
||||
switchNovelToLibrary,
|
||||
};
|
||||
|
||||
const store = createZustandStore<NovelStoreState>()((set, get) => {
|
||||
const chapterSlice = createInitialChapterSlice();
|
||||
const actions = {
|
||||
...createNovelStoreActions({
|
||||
set,
|
||||
get,
|
||||
deps,
|
||||
defaultChapterSort: novelSettings.sort,
|
||||
}),
|
||||
...createNovelStoreChapterActions({
|
||||
set,
|
||||
get,
|
||||
bootstrapService: deps.bootstrapService,
|
||||
chapterActionsDependencies: deps.chapterActionsDependencies,
|
||||
transformChapters: deps.transformChapters,
|
||||
defaultChapterSort: novelSettings.sort,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...createNovelSlice({
|
||||
pluginId,
|
||||
novelPath: path,
|
||||
novel,
|
||||
defaultChapterSort,
|
||||
initialPageIndex: novelPersistence.readPageIndex({
|
||||
pluginId,
|
||||
novelPath: path,
|
||||
}),
|
||||
initialNovelSettings: novelSettings,
|
||||
initialLastRead: novelPersistence.readLastRead(persistenceInput),
|
||||
}),
|
||||
...chapterSlice,
|
||||
actions,
|
||||
};
|
||||
});
|
||||
|
||||
const success = store.getState().actions.bootstrapNovelSync();
|
||||
if (!success) {
|
||||
// If bootstrapNovelSync fails, it means the novel or chapters are not in the db
|
||||
store.getState().actions.bootstrapNovel();
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import { NovelSettings } from '../types';
|
||||
import {
|
||||
GetState,
|
||||
NovelStoreDependencies,
|
||||
NovelStoreNovelActions,
|
||||
SetState,
|
||||
} from './novelStore.types';
|
||||
|
||||
interface CreateNovelStoreActionsParams {
|
||||
set: SetState;
|
||||
get: GetState;
|
||||
deps: NovelStoreDependencies;
|
||||
defaultChapterSort: ChapterOrderKey;
|
||||
}
|
||||
|
||||
export const createNovelStoreActions = ({
|
||||
set,
|
||||
get,
|
||||
deps,
|
||||
defaultChapterSort,
|
||||
}: CreateNovelStoreActionsParams): NovelStoreNovelActions => {
|
||||
let inflightBootstrap: Promise<boolean> | null = null;
|
||||
|
||||
const getSettingsSort = (settings: NovelSettings): ChapterOrderKey =>
|
||||
settings.sort || defaultChapterSort;
|
||||
|
||||
const getSettingsFilter = (settings: NovelSettings): ChapterFilterKey[] =>
|
||||
settings.filter ?? [];
|
||||
|
||||
return {
|
||||
bootstrapNovel: async () => {
|
||||
if (inflightBootstrap) {
|
||||
return inflightBootstrap;
|
||||
}
|
||||
|
||||
inflightBootstrap = (async () => {
|
||||
set({ loading: true, fetching: true });
|
||||
|
||||
const state = get();
|
||||
const result = await deps.bootstrapService.bootstrapNovelAsync({
|
||||
novel: state.novel,
|
||||
novelPath: state.novelPath,
|
||||
pluginId: state.pluginId,
|
||||
pageIndex: state.pageIndex,
|
||||
settingsSort: getSettingsSort(state.novelSettings),
|
||||
settingsFilter: getSettingsFilter(state.novelSettings),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
set({
|
||||
loading: false,
|
||||
fetching: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
set({
|
||||
loading: false,
|
||||
fetching: false,
|
||||
novel: result.novel,
|
||||
pages: result.pages,
|
||||
chapters: deps.transformChapters(result.chapters),
|
||||
batchInformation: result.batchInformation,
|
||||
firstUnreadChapter: result.firstUnreadChapter,
|
||||
});
|
||||
|
||||
return true;
|
||||
})().finally(() => {
|
||||
inflightBootstrap = null;
|
||||
});
|
||||
|
||||
return inflightBootstrap;
|
||||
},
|
||||
bootstrapNovelSync: () => {
|
||||
const state = get();
|
||||
const result = deps.bootstrapService.bootstrapNovelSync({
|
||||
novel: state.novel,
|
||||
novelPath: state.novelPath,
|
||||
pluginId: state.pluginId,
|
||||
pageIndex: state.pageIndex,
|
||||
settingsSort: getSettingsSort(state.novelSettings),
|
||||
settingsFilter: getSettingsFilter(state.novelSettings),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
set({
|
||||
loading: false,
|
||||
fetching: false,
|
||||
novel: result.novel,
|
||||
pages: result.pages,
|
||||
chapters: deps.transformChapters(result.chapters),
|
||||
batchInformation: result.batchInformation,
|
||||
firstUnreadChapter: result.firstUnreadChapter,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
getChapters: async () => {
|
||||
const state = get();
|
||||
if (!state.novel || state.pages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ fetching: true });
|
||||
try {
|
||||
const result = await deps.bootstrapService.getChaptersForPage({
|
||||
novel: state.novel,
|
||||
novelPath: state.novelPath,
|
||||
pluginId: state.pluginId,
|
||||
pages: state.pages,
|
||||
pageIndex: state.pageIndex,
|
||||
settingsSort: getSettingsSort(state.novelSettings),
|
||||
settingsFilter: getSettingsFilter(state.novelSettings),
|
||||
});
|
||||
|
||||
set({
|
||||
chapters: deps.transformChapters(result.chapters),
|
||||
batchInformation: result.batchInformation,
|
||||
firstUnreadChapter: result.firstUnreadChapter,
|
||||
});
|
||||
} finally {
|
||||
set({ fetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshNovel: async () => {
|
||||
set({ loading: true, fetching: true });
|
||||
try {
|
||||
const state = get();
|
||||
const refreshed = await deps.bootstrapService.bootstrapNovelAsync({
|
||||
novel: undefined,
|
||||
novelPath: state.novelPath,
|
||||
pluginId: state.pluginId,
|
||||
pageIndex: state.pageIndex,
|
||||
settingsSort: getSettingsSort(state.novelSettings),
|
||||
settingsFilter: getSettingsFilter(state.novelSettings),
|
||||
});
|
||||
|
||||
if (!refreshed.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
novel: refreshed.novel,
|
||||
pages: refreshed.pages,
|
||||
chapters: deps.transformChapters(refreshed.chapters),
|
||||
batchInformation: refreshed.batchInformation,
|
||||
firstUnreadChapter: refreshed.firstUnreadChapter,
|
||||
});
|
||||
} finally {
|
||||
set({ loading: false, fetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
setNovel: novelState => set({ novel: novelState }),
|
||||
setPages: pagesState => set({ pages: pagesState }),
|
||||
setPageIndex: index => {
|
||||
set({ pageIndex: index });
|
||||
deps.persistPageIndex?.(index);
|
||||
},
|
||||
openPage: async index => {
|
||||
set({ pageIndex: index });
|
||||
deps.persistPageIndex?.(index);
|
||||
await get().actions.getChapters();
|
||||
},
|
||||
setNovelSettings: settings => {
|
||||
set({ novelSettings: settings });
|
||||
deps.persistNovelSettings?.(settings);
|
||||
|
||||
const state = get();
|
||||
if (state.novel && state.pages.length > 0) {
|
||||
state.actions.getChapters();
|
||||
}
|
||||
},
|
||||
setLastRead: chapter => {
|
||||
set({ lastRead: chapter });
|
||||
deps.persistLastRead?.(chapter);
|
||||
},
|
||||
followNovel: async () => {
|
||||
const state = get();
|
||||
const currentNovel = state.novel;
|
||||
if (!currentNovel || !deps.switchNovelToLibrary) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.switchNovelToLibrary(state.novelPath, state.pluginId);
|
||||
set(inner => {
|
||||
if (!inner.novel) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
novel: {
|
||||
...inner.novel,
|
||||
inLibrary: !inner.novel.inLibrary,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { createBootstrapService } from '../store-helper/bootstrapService';
|
||||
import {
|
||||
bookmarkChaptersAction,
|
||||
ChapterActionsDependencies,
|
||||
deleteChapterAction,
|
||||
deleteChaptersAction,
|
||||
markChapterReadAction,
|
||||
markChaptersReadAction,
|
||||
markChaptersUnreadAction,
|
||||
markPreviouschaptersReadAction,
|
||||
markPreviousChaptersUnreadAction,
|
||||
refreshChaptersAction,
|
||||
updateChapterProgressAction,
|
||||
} from './chapterActions';
|
||||
import { NovelSettings } from '../types';
|
||||
import {
|
||||
ChapterTextCacheApi,
|
||||
GetState,
|
||||
NovelStoreChapterActions,
|
||||
SetState,
|
||||
} from './novelStore.types';
|
||||
|
||||
interface CreateNovelStoreChapterActionsParams {
|
||||
set: SetState;
|
||||
get: GetState;
|
||||
bootstrapService: Pick<
|
||||
ReturnType<typeof createBootstrapService>,
|
||||
'getNextChapterBatch' | 'loadUpToBatch'
|
||||
>;
|
||||
chapterActionsDependencies: ChapterActionsDependencies;
|
||||
transformChapters: (chs: ChapterInfo[]) => ChapterInfo[];
|
||||
defaultChapterSort: ChapterOrderKey;
|
||||
}
|
||||
|
||||
export const createNovelStoreChapterActions = ({
|
||||
set,
|
||||
get,
|
||||
bootstrapService,
|
||||
chapterActionsDependencies,
|
||||
transformChapters,
|
||||
defaultChapterSort,
|
||||
}: CreateNovelStoreChapterActionsParams): NovelStoreChapterActions => {
|
||||
let inflightNextChapterBatch: Promise<void> | null = null;
|
||||
let inflightLoadUpToBatch: Promise<void> | null = null;
|
||||
let pendingTargetBatch: number | null = null;
|
||||
|
||||
const mutateChapters = (mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => {
|
||||
if (get().novel) {
|
||||
set(state => ({ chapters: mutation(state.chapters) }));
|
||||
}
|
||||
};
|
||||
|
||||
const setChapters = (chs: ChapterInfo[]) => {
|
||||
set({ chapters: transformChapters(chs) });
|
||||
};
|
||||
|
||||
const getSettingsSort = (settings: NovelSettings): ChapterOrderKey =>
|
||||
settings.sort || defaultChapterSort;
|
||||
|
||||
const getSettingsFilter = (settings: NovelSettings): ChapterFilterKey[] =>
|
||||
settings.filter ?? [];
|
||||
|
||||
const createChapterTextCache = (): ChapterTextCacheApi => {
|
||||
return {
|
||||
read: chapterId => get().chapterTextCache[chapterId],
|
||||
write: (chapterId, value) => {
|
||||
set({
|
||||
chapterTextCache: {
|
||||
...get().chapterTextCache,
|
||||
[chapterId]: value,
|
||||
},
|
||||
});
|
||||
},
|
||||
remove: chapterId => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [chapterId]: _ignored, ...rest } = get().chapterTextCache;
|
||||
set({
|
||||
chapterTextCache: rest,
|
||||
});
|
||||
},
|
||||
clear: () => {
|
||||
set({
|
||||
chapterTextCache: {},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const appendBatch = (batch: number, chapters: ChapterInfo[]) => {
|
||||
set(curr => {
|
||||
if (batch <= curr.batchInformation.batch) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
batchInformation: {
|
||||
...curr.batchInformation,
|
||||
batch,
|
||||
},
|
||||
chapters: curr.chapters.concat(chapters),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const queueLoadUpToBatch = (targetBatch: number): Promise<void> => {
|
||||
pendingTargetBatch = Math.max(
|
||||
pendingTargetBatch ?? targetBatch,
|
||||
targetBatch,
|
||||
);
|
||||
|
||||
if (inflightLoadUpToBatch) {
|
||||
return inflightLoadUpToBatch;
|
||||
}
|
||||
|
||||
inflightLoadUpToBatch = (async () => {
|
||||
while (pendingTargetBatch !== null) {
|
||||
const nextTarget = pendingTargetBatch;
|
||||
pendingTargetBatch = null;
|
||||
const state = get();
|
||||
|
||||
if (nextTarget <= state.batchInformation.batch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await bootstrapService.loadUpToBatch({
|
||||
targetBatch: nextTarget,
|
||||
novel: state.novel,
|
||||
pages: state.pages,
|
||||
pageIndex: state.pageIndex,
|
||||
settingsSort: getSettingsSort(state.novelSettings),
|
||||
settingsFilter: getSettingsFilter(state.novelSettings),
|
||||
batchInformation: state.batchInformation,
|
||||
onBatchLoaded: (batch, chapters) => {
|
||||
appendBatch(batch, transformChapters(chapters));
|
||||
},
|
||||
});
|
||||
}
|
||||
})().finally(() => {
|
||||
inflightLoadUpToBatch = null;
|
||||
pendingTargetBatch = null;
|
||||
});
|
||||
|
||||
return inflightLoadUpToBatch;
|
||||
};
|
||||
|
||||
return {
|
||||
chapterTextCache: createChapterTextCache(),
|
||||
getNextChapterBatch: async () => {
|
||||
if (inflightNextChapterBatch) {
|
||||
return inflightNextChapterBatch;
|
||||
}
|
||||
|
||||
const state = get();
|
||||
inflightNextChapterBatch = (async () => {
|
||||
const result = await bootstrapService.getNextChapterBatch({
|
||||
novel: state.novel,
|
||||
pages: state.pages,
|
||||
pageIndex: state.pageIndex,
|
||||
settingsSort: getSettingsSort(state.novelSettings),
|
||||
settingsFilter: getSettingsFilter(state.novelSettings),
|
||||
batchInformation: state.batchInformation,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
appendBatch(result.batch, transformChapters(result.chapters));
|
||||
})().finally(() => {
|
||||
inflightNextChapterBatch = null;
|
||||
});
|
||||
|
||||
return inflightNextChapterBatch;
|
||||
},
|
||||
|
||||
loadUpToBatch: async (targetBatch: number) => {
|
||||
await queueLoadUpToBatch(targetBatch);
|
||||
},
|
||||
|
||||
updateChapter: (index, update) => {
|
||||
if (get().novel) {
|
||||
set(state => {
|
||||
const next = [...state.chapters];
|
||||
next[index] = { ...next[index], ...update };
|
||||
return {
|
||||
chapters: next,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setChapters,
|
||||
|
||||
extendChapters: chs => {
|
||||
set(state => ({
|
||||
chapters: state.chapters.concat(transformChapters(chs)),
|
||||
}));
|
||||
},
|
||||
|
||||
bookmarkChapters: chaptersState => {
|
||||
bookmarkChaptersAction(
|
||||
chaptersState,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
markPreviouschaptersRead: chapterId => {
|
||||
markPreviouschaptersReadAction(
|
||||
chapterId,
|
||||
get().novel,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
markChapterRead: chapterId => {
|
||||
markChapterReadAction(
|
||||
chapterId,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
markChaptersRead: chaptersState => {
|
||||
markChaptersReadAction(
|
||||
chaptersState,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
markPreviousChaptersUnread: chapterId => {
|
||||
markPreviousChaptersUnreadAction(
|
||||
chapterId,
|
||||
get().novel,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
markChaptersUnread: chaptersState => {
|
||||
markChaptersUnreadAction(
|
||||
chaptersState,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
updateChapterProgress: (chapterId, progress) => {
|
||||
updateChapterProgressAction(
|
||||
chapterId,
|
||||
progress,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
deleteChapter: chapter => {
|
||||
deleteChapterAction(
|
||||
chapter,
|
||||
get().novel,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
deleteChapters: chaptersState => {
|
||||
deleteChaptersAction(
|
||||
chaptersState,
|
||||
get().novel,
|
||||
mutateChapters,
|
||||
chapterActionsDependencies,
|
||||
);
|
||||
},
|
||||
|
||||
refreshChapters: () => {
|
||||
const state = get();
|
||||
refreshChaptersAction({
|
||||
novel: state.novel,
|
||||
fetching: state.fetching,
|
||||
settingsSort: getSettingsSort(state.novelSettings),
|
||||
settingsFilter: getSettingsFilter(state.novelSettings),
|
||||
currentPage: state.pages[state.pageIndex] ?? '1',
|
||||
transformChapters,
|
||||
setChapters,
|
||||
deps: chapterActionsDependencies,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ChapterSliceState } from './novelStore.types';
|
||||
|
||||
export const createInitialChapterSlice = (): ChapterSliceState => ({
|
||||
chapters: [],
|
||||
firstUnreadChapter: undefined,
|
||||
chapterTextCache: {},
|
||||
batchInformation: {
|
||||
batch: 0,
|
||||
total: 0,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ChapterOrderKey } from '@database/constants';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { NovelSettings } from '../types';
|
||||
|
||||
export interface CreateNovelStoreParams {
|
||||
pluginId: string;
|
||||
novelPath: string;
|
||||
novel?: NovelInfo;
|
||||
defaultChapterSort?: ChapterOrderKey;
|
||||
initialPageIndex?: number;
|
||||
initialNovelSettings: NovelSettings;
|
||||
initialLastRead?: ChapterInfo;
|
||||
}
|
||||
|
||||
export const createNovelSlice = ({
|
||||
pluginId,
|
||||
novelPath,
|
||||
novel,
|
||||
initialPageIndex = 0,
|
||||
initialNovelSettings,
|
||||
initialLastRead,
|
||||
}: CreateNovelStoreParams) => {
|
||||
return {
|
||||
loading: false,
|
||||
fetching: false,
|
||||
pluginId,
|
||||
novelPath,
|
||||
novel,
|
||||
pageIndex: initialPageIndex,
|
||||
pages: [],
|
||||
novelSettings: initialNovelSettings,
|
||||
lastRead: initialLastRead,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { StoreApi } from 'zustand/vanilla';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { ChapterActionsDependencies } from './chapterActions';
|
||||
import { createBootstrapService } from '../store-helper/bootstrapService';
|
||||
import { BatchInfo, NovelSettings } from '../types';
|
||||
|
||||
type ChapterTextValue = string | Promise<string>;
|
||||
|
||||
export interface ChapterTextCacheApi {
|
||||
read: (chapterId: number) => ChapterTextValue | undefined;
|
||||
write: (chapterId: number, value: ChapterTextValue) => void;
|
||||
remove: (chapterId: number) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
export interface ChapterSliceState {
|
||||
chapters: ChapterInfo[];
|
||||
firstUnreadChapter: ChapterInfo | undefined;
|
||||
batchInformation: BatchInfo;
|
||||
chapterTextCache: Record<number, ChapterTextValue>;
|
||||
}
|
||||
|
||||
export interface NovelStoreData extends ChapterSliceState {
|
||||
loading: boolean;
|
||||
fetching: boolean;
|
||||
|
||||
pluginId: string;
|
||||
novelPath: string;
|
||||
novel: NovelInfo | undefined;
|
||||
|
||||
pageIndex: number;
|
||||
pages: string[];
|
||||
|
||||
novelSettings: NovelSettings;
|
||||
lastRead: ChapterInfo | undefined;
|
||||
}
|
||||
|
||||
export interface NovelStoreChapterActions {
|
||||
chapterTextCache: ChapterTextCacheApi;
|
||||
getNextChapterBatch: () => Promise<void>;
|
||||
loadUpToBatch: (targetBatch: number) => Promise<void>;
|
||||
updateChapter: (index: number, update: Partial<ChapterInfo>) => void;
|
||||
setChapters: (chs: ChapterInfo[]) => void;
|
||||
extendChapters: (chs: ChapterInfo[]) => void;
|
||||
bookmarkChapters: (chapters: ChapterInfo[]) => void;
|
||||
markPreviouschaptersRead: (chapterId: number) => void;
|
||||
markChapterRead: (chapterId: number) => void;
|
||||
markChaptersRead: (chapters: ChapterInfo[]) => void;
|
||||
markPreviousChaptersUnread: (chapterId: number) => void;
|
||||
markChaptersUnread: (chapters: ChapterInfo[]) => void;
|
||||
updateChapterProgress: (chapterId: number, progress: number) => void;
|
||||
deleteChapter: (chapter: ChapterInfo) => void;
|
||||
deleteChapters: (chapters: ChapterInfo[]) => void;
|
||||
refreshChapters: () => void;
|
||||
}
|
||||
|
||||
export interface NovelStoreNovelActions {
|
||||
bootstrapNovel: () => Promise<boolean>;
|
||||
bootstrapNovelSync: () => boolean;
|
||||
getChapters: () => Promise<void>;
|
||||
refreshNovel: () => Promise<void>;
|
||||
|
||||
setNovel: (novel: NovelInfo | undefined) => void;
|
||||
setPages: (pages: string[]) => void;
|
||||
setPageIndex: (index: number) => void;
|
||||
openPage: (index: number) => Promise<void>;
|
||||
setNovelSettings: (settings: NovelSettings) => void;
|
||||
setLastRead: (chapter: ChapterInfo) => void;
|
||||
followNovel: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type NovelStoreActions = NovelStoreNovelActions &
|
||||
NovelStoreChapterActions;
|
||||
|
||||
export interface NovelStoreState extends NovelStoreData {
|
||||
actions: NovelStoreActions;
|
||||
}
|
||||
|
||||
export type NovelStoreApi = StoreApi<NovelStoreState>;
|
||||
|
||||
export interface NovelStoreDependencies {
|
||||
bootstrapService: ReturnType<typeof createBootstrapService>;
|
||||
chapterActionsDependencies: ChapterActionsDependencies;
|
||||
transformChapters: (chs: ChapterInfo[]) => ChapterInfo[];
|
||||
persistPageIndex?: (value: number) => void;
|
||||
persistNovelSettings?: (value: NovelSettings) => void;
|
||||
persistLastRead?: (value: ChapterInfo) => void;
|
||||
switchNovelToLibrary?: (novelPath: string, pluginId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SetState = {
|
||||
(
|
||||
partial:
|
||||
| NovelStoreState
|
||||
| Partial<NovelStoreState>
|
||||
| ((
|
||||
state: NovelStoreState,
|
||||
) => NovelStoreState | Partial<NovelStoreState>),
|
||||
replace?: false,
|
||||
): void;
|
||||
(
|
||||
state: NovelStoreState | ((state: NovelStoreState) => NovelStoreState),
|
||||
replace: true,
|
||||
): void;
|
||||
};
|
||||
|
||||
export type GetState = () => NovelStoreState;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
|
||||
export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX';
|
||||
export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS';
|
||||
export const LAST_READ_PREFIX = 'LAST_READ_PREFIX';
|
||||
|
||||
export const defaultNovelSettings: NovelSettingsWithoutSort = {
|
||||
showChapterTitles: true,
|
||||
filter: [],
|
||||
};
|
||||
|
||||
export const defaultPageIndex = 0;
|
||||
|
||||
export interface NovelSettingsWithoutSort {
|
||||
filter: ChapterFilterKey[];
|
||||
showChapterTitles: boolean;
|
||||
sort?: ChapterOrderKey;
|
||||
}
|
||||
export interface NovelSettings extends NovelSettingsWithoutSort {
|
||||
sort: ChapterOrderKey;
|
||||
}
|
||||
|
||||
export interface BatchInfo {
|
||||
batch: number;
|
||||
total: number;
|
||||
totalChapters?: number;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import {
|
||||
bookmarkChaptersAction,
|
||||
deleteChapterAction,
|
||||
deleteChaptersAction,
|
||||
markChapterReadAction,
|
||||
markChaptersReadAction,
|
||||
markChaptersUnreadAction,
|
||||
markPreviouschaptersReadAction,
|
||||
markPreviousChaptersUnreadAction,
|
||||
refreshChaptersAction,
|
||||
updateChapterProgressAction,
|
||||
} from './store/chapterActions';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
|
||||
export interface UseChapterOperationsParams {
|
||||
novel: NovelInfo | undefined;
|
||||
chapters: ChapterInfo[];
|
||||
_setChapters: React.Dispatch<React.SetStateAction<ChapterInfo[]>>;
|
||||
transformChapters: (chs: ChapterInfo[]) => ChapterInfo[];
|
||||
settingsSort: ChapterOrderKey;
|
||||
settingsFilter: ChapterFilterKey[];
|
||||
currentPage: string;
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
export const useChapterOperations = ({
|
||||
novel,
|
||||
_setChapters,
|
||||
transformChapters,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
currentPage,
|
||||
fetching,
|
||||
}: UseChapterOperationsParams) => {
|
||||
const mutateChapters = useCallback(
|
||||
(mutation: (chs: ChapterInfo[]) => ChapterInfo[]) => {
|
||||
if (novel) {
|
||||
_setChapters(mutation);
|
||||
}
|
||||
},
|
||||
[novel, _setChapters],
|
||||
);
|
||||
|
||||
const updateChapter = useCallback(
|
||||
(index: number, update: Partial<ChapterInfo>) => {
|
||||
if (novel) {
|
||||
_setChapters(chs => {
|
||||
const next = [...chs];
|
||||
next[index] = { ...next[index], ...update };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[novel, _setChapters],
|
||||
);
|
||||
|
||||
const transformAndSetChapters = useCallback(
|
||||
async (chs: ChapterInfo[]) => {
|
||||
_setChapters(transformChapters(chs));
|
||||
},
|
||||
[transformChapters, _setChapters],
|
||||
);
|
||||
|
||||
const extendChapters = useCallback(
|
||||
async (chs: ChapterInfo[]) => {
|
||||
_setChapters(prev => prev.concat(transformChapters(chs)));
|
||||
},
|
||||
[transformChapters, _setChapters],
|
||||
);
|
||||
|
||||
const bookmarkChapters = useCallback(
|
||||
(_chapters: ChapterInfo[]) => {
|
||||
bookmarkChaptersAction(_chapters, mutateChapters);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const markPreviouschaptersRead = useCallback(
|
||||
(chapterId: number) => {
|
||||
markPreviouschaptersReadAction(chapterId, novel, mutateChapters);
|
||||
},
|
||||
[mutateChapters, novel],
|
||||
);
|
||||
|
||||
const markChapterRead = useCallback(
|
||||
(chapterId: number) => {
|
||||
markChapterReadAction(chapterId, mutateChapters);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const updateChapterProgress = useCallback(
|
||||
(chapterId: number, progress: number) => {
|
||||
updateChapterProgressAction(chapterId, progress, mutateChapters);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const markChaptersRead = useCallback(
|
||||
(_chapters: ChapterInfo[]) => {
|
||||
markChaptersReadAction(_chapters, mutateChapters);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const markPreviousChaptersUnread = useCallback(
|
||||
(chapterId: number) => {
|
||||
markPreviousChaptersUnreadAction(chapterId, novel, mutateChapters);
|
||||
},
|
||||
[mutateChapters, novel],
|
||||
);
|
||||
|
||||
const markChaptersUnread = useCallback(
|
||||
(_chapters: ChapterInfo[]) => {
|
||||
markChaptersUnreadAction(_chapters, mutateChapters);
|
||||
},
|
||||
[mutateChapters],
|
||||
);
|
||||
|
||||
const deleteChapter = useCallback(
|
||||
(_chapter: ChapterInfo) => {
|
||||
deleteChapterAction(_chapter, novel, mutateChapters);
|
||||
},
|
||||
[mutateChapters, novel],
|
||||
);
|
||||
|
||||
const deleteChapters = useCallback(
|
||||
(_chapters: ChapterInfo[]) => {
|
||||
deleteChaptersAction(_chapters, novel, mutateChapters);
|
||||
},
|
||||
[novel, mutateChapters],
|
||||
);
|
||||
|
||||
const refreshChapters = useCallback(() => {
|
||||
refreshChaptersAction({
|
||||
novel,
|
||||
fetching,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
currentPage,
|
||||
transformChapters,
|
||||
setChapters: _setChapters,
|
||||
});
|
||||
}, [
|
||||
novel,
|
||||
fetching,
|
||||
settingsSort,
|
||||
settingsFilter,
|
||||
currentPage,
|
||||
transformChapters,
|
||||
_setChapters,
|
||||
]);
|
||||
|
||||
return {
|
||||
mutateChapters,
|
||||
updateChapter,
|
||||
setChapters: transformAndSetChapters,
|
||||
extendChapters,
|
||||
bookmarkChapters,
|
||||
markPreviouschaptersRead,
|
||||
markChapterRead,
|
||||
markChaptersRead,
|
||||
markPreviousChaptersUnread,
|
||||
markChaptersUnread,
|
||||
updateChapterProgress,
|
||||
deleteChapter,
|
||||
deleteChapters,
|
||||
refreshChapters,
|
||||
};
|
||||
};
|
||||
@@ -1,89 +1,65 @@
|
||||
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
import {
|
||||
ChapterFilterKey,
|
||||
ChapterFilterPositiveKey,
|
||||
ChapterOrderKey,
|
||||
} from '@database/constants';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useAppSettings } from './useSettings';
|
||||
import { ChapterFilterObject, FilterStates } from '@database/utils/filter';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import {
|
||||
defaultNovelSettings,
|
||||
NOVEL_PAGE_INDEX_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
} from './useNovel/types';
|
||||
import { useNovelAction, useNovelValue } from '@screens/novel/NovelContext';
|
||||
|
||||
// #region constants
|
||||
|
||||
export const NOVEL_PAGE_INDEX_PREFIX = 'NOVEL_PAGE_INDEX_PREFIX';
|
||||
export const NOVEL_SETTINGS_PREFIX = 'NOVEL_SETTINGS';
|
||||
export const LAST_READ_PREFIX = 'LAST_READ_PREFIX';
|
||||
|
||||
const defaultNovelSettings: NovelSettings = {
|
||||
showChapterTitles: true,
|
||||
filter: [],
|
||||
};
|
||||
|
||||
// #endregion
|
||||
// #region types
|
||||
|
||||
export interface NovelSettings {
|
||||
sort?: ChapterOrderKey;
|
||||
filter: ChapterFilterKey[];
|
||||
showChapterTitles?: boolean;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
// #region definition useNovel
|
||||
export { NOVEL_PAGE_INDEX_PREFIX, NOVEL_SETTINGS_PREFIX };
|
||||
|
||||
export const useNovelSettings = () => {
|
||||
const { novel } = useNovelContext();
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
const novel = useNovelValue('novel');
|
||||
const domainNovelSettings = useNovelValue('novelSettings');
|
||||
const writeNovelSettings = useNovelAction('setNovelSettings');
|
||||
|
||||
const [ns, setNovelSettings] = useMMKVObject<NovelSettings>(
|
||||
`${NOVEL_SETTINGS_PREFIX}_${novel?.pluginId}_${novel?.path}`,
|
||||
);
|
||||
const novelSettings = useMemo(
|
||||
() => ({ ...defaultNovelSettings, ...ns }),
|
||||
[ns],
|
||||
() => ({ ...defaultNovelSettings, ...domainNovelSettings }),
|
||||
[domainNovelSettings],
|
||||
);
|
||||
|
||||
const _sort: ChapterOrderKey = novelSettings.sort ?? defaultChapterSort;
|
||||
const _filter: ChapterFilterKey[] = novelSettings.filter;
|
||||
const filterManager = useRef<ChapterFilterObject | null>(null);
|
||||
|
||||
// #endregion
|
||||
// #region setters
|
||||
|
||||
const setChapterSort = useCallback(
|
||||
async (sort?: ChapterOrderKey) => {
|
||||
async (sort: ChapterOrderKey) => {
|
||||
if (novel) {
|
||||
setNovelSettings({
|
||||
writeNovelSettings({
|
||||
showChapterTitles: novelSettings?.showChapterTitles,
|
||||
sort,
|
||||
filter: _filter,
|
||||
});
|
||||
}
|
||||
},
|
||||
[novel, setNovelSettings, novelSettings?.showChapterTitles, _filter],
|
||||
[novel, writeNovelSettings, novelSettings?.showChapterTitles, _filter],
|
||||
);
|
||||
const setChapterFilter = useCallback(
|
||||
async (filter?: ChapterFilterKey[]) => {
|
||||
if (novel) {
|
||||
setNovelSettings({
|
||||
writeNovelSettings({
|
||||
showChapterTitles: novelSettings?.showChapterTitles,
|
||||
sort: _sort,
|
||||
filter: filter ?? [],
|
||||
});
|
||||
}
|
||||
},
|
||||
[novel, setNovelSettings, novelSettings?.showChapterTitles, _sort],
|
||||
[novel, writeNovelSettings, novelSettings?.showChapterTitles, _sort],
|
||||
);
|
||||
|
||||
const filterManager = useRef<ChapterFilterObject>(
|
||||
new ChapterFilterObject(_filter, setChapterFilter),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!filterManager.current) {
|
||||
filterManager.current = new ChapterFilterObject(
|
||||
_filter,
|
||||
setChapterFilter,
|
||||
);
|
||||
}
|
||||
}, [_filter, setChapterFilter]);
|
||||
|
||||
const cycleChapterFilter = useCallback(
|
||||
(key: ChapterFilterPositiveKey) => {
|
||||
@@ -114,9 +90,9 @@ export const useNovelSettings = () => {
|
||||
|
||||
const setShowChapterTitles = useCallback(
|
||||
(v: boolean) => {
|
||||
setNovelSettings({ ...novelSettings, showChapterTitles: v });
|
||||
writeNovelSettings({ ...novelSettings, showChapterTitles: v });
|
||||
},
|
||||
[novelSettings, setNovelSettings],
|
||||
[novelSettings, writeNovelSettings],
|
||||
);
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -95,7 +95,7 @@ export const useTheme = (): ThemeColors => {
|
||||
const [customAccent] = useMMKVString('CUSTOM_ACCENT_COLOR');
|
||||
|
||||
const [systemColorScheme, setSystemColorScheme] = useState<ColorSchemeName>(
|
||||
Appearance.getColorScheme(),
|
||||
Appearance.getColorScheme() ?? 'light',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,73 +1,143 @@
|
||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||
import { useNovel } from '@hooks/persisted';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { useStore } from 'zustand';
|
||||
import { ReaderStackParamList } from '@navigators/types';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useDeviceOrientation } from '@hooks/index';
|
||||
import { useLibraryContext } from '@components/Context/LibraryContext';
|
||||
import { useAppSettings } from '@hooks/persisted';
|
||||
import {
|
||||
NovelStoreActions,
|
||||
NovelStoreApi,
|
||||
NovelStoreData,
|
||||
NovelStoreState,
|
||||
} from '@hooks/persisted/useNovel/store/novelStore.types';
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { createStore } from '@hooks/persisted/useNovel/store/createStore';
|
||||
|
||||
type NovelContextType = ReturnType<typeof useNovel> & {
|
||||
navigationBarHeight: number;
|
||||
statusBarHeight: number;
|
||||
chapterTextCache: Map<number, string | Promise<string>>;
|
||||
};
|
||||
|
||||
const defaultValue = {} as NovelContextType;
|
||||
|
||||
const NovelContext = createContext<NovelContextType>(defaultValue);
|
||||
|
||||
export function NovelContextProvider({
|
||||
children,
|
||||
|
||||
route,
|
||||
}: {
|
||||
children: React.JSX.Element;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
route:
|
||||
| RouteProp<ReaderStackParamList, 'Novel'>
|
||||
| RouteProp<ReaderStackParamList, 'Chapter'>;
|
||||
}) {
|
||||
};
|
||||
|
||||
type NovelLayout = {
|
||||
navigationBarHeight: number;
|
||||
statusBarHeight: number;
|
||||
};
|
||||
|
||||
const NovelStoreContext = createContext<NovelStoreApi | null>(null);
|
||||
const NovelLayoutContext = createContext<NovelLayout | null>(null);
|
||||
|
||||
export function NovelContextProvider({ children, route }: Props) {
|
||||
const initialNovel =
|
||||
'id' in route.params ? (route.params as NovelInfo) : undefined;
|
||||
|
||||
const { path, pluginId } =
|
||||
'novel' in route.params ? route.params.novel : route.params;
|
||||
const storeKey = `${pluginId}:${path}`;
|
||||
|
||||
const novelHookContent = useNovel(
|
||||
'id' in route.params ? (route.params as NovelInfo) : path,
|
||||
pluginId,
|
||||
);
|
||||
const { switchNovelToLibrary } = useLibraryContext();
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
|
||||
const switchNovelToLibraryRef = useRef(switchNovelToLibrary);
|
||||
|
||||
const storeRef = useRef<{
|
||||
key: string;
|
||||
store: NovelStoreApi;
|
||||
} | null>(null);
|
||||
const queriedNovelRef = useRef<boolean>(false);
|
||||
|
||||
if (storeRef.current?.key !== storeKey) {
|
||||
queriedNovelRef.current = false;
|
||||
|
||||
storeRef.current = {
|
||||
key: storeKey,
|
||||
store: createStore({
|
||||
path,
|
||||
pluginId,
|
||||
novel: initialNovel,
|
||||
defaultChapterSort,
|
||||
switchNovelToLibrary: switchNovelToLibraryRef.current,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const novelStore = storeRef.current.store;
|
||||
|
||||
const { bottom, top } = useSafeAreaInsets();
|
||||
const orientation = useDeviceOrientation();
|
||||
const NavigationBarHeight = useRef(bottom);
|
||||
const StatusBarHeight = useRef(top);
|
||||
const chapterTextCache = useRef<Map<number, string | Promise<string>>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
if (bottom < NavigationBarHeight.current && orientation === 'landscape') {
|
||||
NavigationBarHeight.current = bottom;
|
||||
} else if (bottom > NavigationBarHeight.current) {
|
||||
NavigationBarHeight.current = bottom;
|
||||
const navigationBarHeightRef = useRef(bottom);
|
||||
const statusBarHeightRef = useRef(top);
|
||||
|
||||
if (bottom < navigationBarHeightRef.current && orientation === 'landscape') {
|
||||
navigationBarHeightRef.current = bottom;
|
||||
} else if (bottom > navigationBarHeightRef.current) {
|
||||
navigationBarHeightRef.current = bottom;
|
||||
}
|
||||
if (top > StatusBarHeight.current) {
|
||||
StatusBarHeight.current = top;
|
||||
|
||||
if (top > statusBarHeightRef.current) {
|
||||
statusBarHeightRef.current = top;
|
||||
}
|
||||
const contextValue = useMemo(
|
||||
|
||||
const layoutValue = useMemo(
|
||||
() => ({
|
||||
...novelHookContent,
|
||||
navigationBarHeight: NavigationBarHeight.current,
|
||||
statusBarHeight: StatusBarHeight.current,
|
||||
chapterTextCache: chapterTextCache.current,
|
||||
navigationBarHeight: navigationBarHeightRef.current,
|
||||
statusBarHeight: statusBarHeightRef.current,
|
||||
}),
|
||||
[novelHookContent],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<NovelContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</NovelContext.Provider>
|
||||
<NovelStoreContext.Provider value={novelStore}>
|
||||
<NovelLayoutContext.Provider value={layoutValue}>
|
||||
{children}
|
||||
</NovelLayoutContext.Provider>
|
||||
</NovelStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useNovelContext = () => {
|
||||
const context = useContext(NovelContext);
|
||||
function useNovelStoreApi() {
|
||||
const store = useContext(NovelStoreContext);
|
||||
|
||||
if (!store) {
|
||||
throw new Error('useNovelStore must be used inside NovelContextProvider');
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
export function useNovelStore<T>(selector: (state: NovelStoreState) => T): T {
|
||||
const store = useNovelStoreApi();
|
||||
return useStore(store, selector);
|
||||
}
|
||||
|
||||
export function useNovelState<T>(selector: (state: NovelStoreData) => T): T {
|
||||
return useNovelStore(state => selector(state));
|
||||
}
|
||||
|
||||
export function useNovelValue<K extends keyof NovelStoreData>(
|
||||
key: K,
|
||||
): NovelStoreData[K] {
|
||||
return useNovelStore(state => state[key]);
|
||||
}
|
||||
|
||||
export function useNovelActions(): NovelStoreActions {
|
||||
return useNovelStore(state => state.actions);
|
||||
}
|
||||
|
||||
export function useNovelAction<K extends keyof NovelStoreActions>(
|
||||
key: K,
|
||||
): NovelStoreActions[K] {
|
||||
return useNovelStore(state => state.actions[key]);
|
||||
}
|
||||
|
||||
export function useNovelLayout() {
|
||||
const context = useContext(NovelLayoutContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useNovelLayout must be used inside NovelContextProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import NovelScreenLoading from './components/LoadingAnimation/NovelScreenLoading
|
||||
import { NovelScreenProps } from '@navigators/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import { getString } from '@strings/translations';
|
||||
import { isNumber, noop } from 'lodash-es';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import NovelAppbar from './components/NovelAppbar';
|
||||
import { resolveUrl } from '@services/plugin/fetch';
|
||||
import {
|
||||
@@ -30,17 +30,13 @@ import { MaterialDesignIconName } from '@type/icon';
|
||||
import NovelScreenList from './components/NovelScreenList';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { SafeAreaView } from '@components';
|
||||
import { useNovelContext } from './NovelContext';
|
||||
import { useNovelActions, useNovelValue } from './NovelContext';
|
||||
import { LegendListRef } from '@legendapp/list';
|
||||
|
||||
const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
const novel = useNovelValue('novel');
|
||||
const chapters = useNovelValue('chapters');
|
||||
const {
|
||||
novel,
|
||||
chapters,
|
||||
fetching,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
loadUpToBatch,
|
||||
setNovel,
|
||||
bookmarkChapters,
|
||||
markChaptersRead,
|
||||
@@ -49,7 +45,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
markPreviousChaptersUnread,
|
||||
refreshChapters,
|
||||
deleteChapters,
|
||||
} = useNovelContext();
|
||||
} = useNovelActions();
|
||||
|
||||
const theme = useTheme();
|
||||
const { downloadChapters } = useDownload();
|
||||
@@ -224,29 +220,15 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
}
|
||||
}, [novel, setNovel]);
|
||||
|
||||
const stableGetNextBatch = useMemo(
|
||||
() =>
|
||||
batchInformation.batch < batchInformation.total && !fetching
|
||||
? getNextChapterBatch
|
||||
: noop,
|
||||
[batchInformation.batch, batchInformation.total, fetching, getNextChapterBatch],
|
||||
);
|
||||
|
||||
const hideJumpToChapterModal = useCallback(
|
||||
() => showJumpToChapterModal(false),
|
||||
[],
|
||||
);
|
||||
const hideEditInfoModal = useCallback(
|
||||
() => showEditInfoModal(false),
|
||||
[],
|
||||
);
|
||||
const hideEditInfoModal = useCallback(() => showEditInfoModal(false), []);
|
||||
const clearSelection = useCallback(() => setSelected([]), []);
|
||||
const selectAll = useCallback(() => setSelected(chapters), [chapters]);
|
||||
|
||||
const snackbarTheme = useMemo(
|
||||
() => ({ colors: { primary: theme.primary } }),
|
||||
[theme.primary],
|
||||
);
|
||||
const snackbarTheme = useMemo(() => ({ colors: theme }), [theme]);
|
||||
const snackbarTextStyle = useMemo(
|
||||
() => ({ color: theme.onSurface }),
|
||||
[theme.onSurface],
|
||||
@@ -322,7 +304,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
routeBaseNovel={route.params}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
getNextChapterBatch={stableGetNextBatch}
|
||||
deleteDownloadSnackbar={deleteDownloadsSnackbar}
|
||||
/>
|
||||
</Suspense>
|
||||
</SafeAreaView>
|
||||
@@ -350,9 +332,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
novel={novel}
|
||||
chapterListRef={chapterListRef}
|
||||
navigation={navigation}
|
||||
loadUpToBatch={loadUpToBatch}
|
||||
totalChapters={batchInformation.totalChapters}
|
||||
chapters={chapters}
|
||||
/>
|
||||
<EditInfoModal
|
||||
modalVisible={editInfoModal}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import NovelScreen from '../NovelScreen';
|
||||
|
||||
const mockDownloadChapters = jest.fn();
|
||||
const mockUpdateChapterProgressByIds = jest.fn();
|
||||
const mockUseNovelValue = jest.fn();
|
||||
const mockUseNovelActions = jest.fn();
|
||||
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
useTheme: () => ({
|
||||
primary: '#111',
|
||||
onSurface: '#222',
|
||||
background: '#333',
|
||||
onBackground: '#444',
|
||||
surface: '#555',
|
||||
surface2: '#666',
|
||||
}),
|
||||
useDownload: () => ({
|
||||
downloadChapters: mockDownloadChapters,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks', () => ({
|
||||
useBoolean: () => ({
|
||||
value: false,
|
||||
setTrue: jest.fn(),
|
||||
setFalse: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../NovelContext', () => ({
|
||||
useNovelValue: (key: string) => mockUseNovelValue(key),
|
||||
useNovelActions: () => mockUseNovelActions(),
|
||||
}));
|
||||
|
||||
jest.mock('@services/plugin/fetch', () => ({
|
||||
resolveUrl: jest.fn(() => 'https://example.com'),
|
||||
}));
|
||||
|
||||
jest.mock('@strings/translations', () => ({
|
||||
getString: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@database/queries/ChapterQueries', () => ({
|
||||
getAllUndownloadedAndUnreadChapters: jest.fn().mockResolvedValue([]),
|
||||
getAllUndownloadedChapters: jest.fn().mockResolvedValue([]),
|
||||
updateChapterProgressByIds: (...args: unknown[]) =>
|
||||
mockUpdateChapterProgressByIds(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../../database/queries/NovelQueries', () => ({
|
||||
pickCustomNovelCover: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../components/NovelAppbar', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () => React.createElement(Text, { testID: 'novel-appbar' }, 'appbar');
|
||||
});
|
||||
|
||||
jest.mock('../components/NovelScreenList', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text, View } = require('react-native');
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ setSelected }: any) => {
|
||||
const base = {
|
||||
id: 1,
|
||||
novelId: 7,
|
||||
path: '/chapter/1',
|
||||
releaseTime: '2026-01-01',
|
||||
updatedTime: '2026-01-01',
|
||||
readTime: '2026-01-01',
|
||||
chapterNumber: 1,
|
||||
bookmark: false,
|
||||
progress: 0,
|
||||
page: '1',
|
||||
name: 'Chapter 1',
|
||||
};
|
||||
|
||||
return React.createElement(
|
||||
View,
|
||||
null,
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{
|
||||
testID: 'select-unread',
|
||||
onPress: () =>
|
||||
setSelected([
|
||||
{ ...base, id: 10, unread: true, isDownloaded: false },
|
||||
]),
|
||||
},
|
||||
React.createElement(Text, null, 'select-unread'),
|
||||
),
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{
|
||||
testID: 'select-read',
|
||||
onPress: () =>
|
||||
setSelected([
|
||||
{ ...base, id: 11, unread: false, isDownloaded: false },
|
||||
]),
|
||||
},
|
||||
React.createElement(Text, null, 'select-read'),
|
||||
),
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{
|
||||
testID: 'select-undownloaded',
|
||||
onPress: () =>
|
||||
setSelected([
|
||||
{ ...base, id: 12, unread: true, isDownloaded: false },
|
||||
]),
|
||||
},
|
||||
React.createElement(Text, null, 'select-undownloaded'),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../components/Actionbar/Actionbar', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text, View } = require('react-native');
|
||||
|
||||
return {
|
||||
Actionbar: ({ active, actions }: any) => {
|
||||
if (!active) return null;
|
||||
|
||||
return React.createElement(
|
||||
View,
|
||||
{ testID: 'actionbar' },
|
||||
...actions.map((action: any) =>
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{
|
||||
key: action.icon,
|
||||
testID: `action-${action.icon}`,
|
||||
onPress: action.onPress,
|
||||
},
|
||||
React.createElement(Text, null, action.icon),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@components', () => {
|
||||
const React = require('react');
|
||||
return {
|
||||
SafeAreaView: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-paper', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text } = require('react-native');
|
||||
|
||||
const Portal: any = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children);
|
||||
Portal.Host = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children);
|
||||
|
||||
return {
|
||||
Portal,
|
||||
Appbar: {
|
||||
Action: ({ icon, onPress }: any) =>
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{ testID: `appbar-action-${icon}`, onPress },
|
||||
React.createElement(Text, null, icon),
|
||||
),
|
||||
Content: ({ title }: any) => React.createElement(Text, null, title),
|
||||
},
|
||||
Snackbar: ({ visible, children }: any) =>
|
||||
visible ? React.createElement(React.Fragment, null, children) : null,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../components/JumpToChapterModal', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () =>
|
||||
React.createElement(Text, { testID: 'jump-to-chapter-modal' }, 'jump');
|
||||
});
|
||||
|
||||
jest.mock('../components/EditInfoModal', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () => React.createElement(Text, { testID: 'edit-info-modal' }, 'edit');
|
||||
});
|
||||
|
||||
jest.mock('../components/DownloadCustomChapterModal', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () =>
|
||||
React.createElement(
|
||||
Text,
|
||||
{ testID: 'download-custom-modal' },
|
||||
'download-custom',
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('../components/LoadingAnimation/NovelScreenLoading', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () =>
|
||||
React.createElement(Text, { testID: 'novel-screen-loading' }, 'loading');
|
||||
});
|
||||
|
||||
const baseNovel = {
|
||||
id: 7,
|
||||
path: '/novels/test',
|
||||
pluginId: 'plugin.test',
|
||||
name: 'Test Novel',
|
||||
inLibrary: false,
|
||||
totalPages: 1,
|
||||
isLocal: false,
|
||||
};
|
||||
|
||||
const createStore = (overrides: Record<string, unknown> = {}) => {
|
||||
const state = {
|
||||
novel: baseNovel,
|
||||
chapters: [],
|
||||
fetching: false,
|
||||
batchInformation: { batch: 0, total: 0, totalChapters: 0 },
|
||||
getNextChapterBatch: jest.fn(),
|
||||
loadUpToBatch: jest.fn(),
|
||||
setNovel: jest.fn(),
|
||||
bookmarkChapters: jest.fn(),
|
||||
markChaptersRead: jest.fn(),
|
||||
markChaptersUnread: jest.fn(),
|
||||
markPreviouschaptersRead: jest.fn(),
|
||||
markPreviousChaptersUnread: jest.fn(),
|
||||
refreshChapters: jest.fn(),
|
||||
deleteChapters: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
const wireStoreSelectors = (store: ReturnType<typeof createStore>) => {
|
||||
mockUseNovelValue.mockImplementation(
|
||||
(key: keyof typeof store.state) => store.state[key],
|
||||
);
|
||||
mockUseNovelActions.mockReturnValue({
|
||||
setNovel: store.state.setNovel,
|
||||
bookmarkChapters: store.state.bookmarkChapters,
|
||||
markChaptersRead: store.state.markChaptersRead,
|
||||
markChaptersUnread: store.state.markChaptersUnread,
|
||||
markPreviouschaptersRead: store.state.markPreviouschaptersRead,
|
||||
markPreviousChaptersUnread: store.state.markPreviousChaptersUnread,
|
||||
refreshChapters: store.state.refreshChapters,
|
||||
deleteChapters: store.state.deleteChapters,
|
||||
});
|
||||
};
|
||||
|
||||
const route = {
|
||||
params: {
|
||||
name: 'Route Novel',
|
||||
path: '/novels/test',
|
||||
pluginId: 'plugin.test',
|
||||
isLocal: false,
|
||||
},
|
||||
};
|
||||
|
||||
const navigation = {
|
||||
goBack: jest.fn(),
|
||||
navigate: jest.fn(),
|
||||
};
|
||||
|
||||
describe('NovelScreen (task 12 context boundary cutover)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses novelStore action selectors for selected unread workflow', () => {
|
||||
const store = createStore();
|
||||
wireStoreSelectors(store);
|
||||
|
||||
render(
|
||||
// @ts-expect-error narrowed test props
|
||||
<NovelScreen navigation={navigation} route={route} />,
|
||||
);
|
||||
|
||||
fireEvent.press(screen.getByTestId('select-unread'));
|
||||
fireEvent.press(screen.getByTestId('action-check'));
|
||||
|
||||
expect(store.state.markChaptersRead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves selected-read workflow parity (mark unread + reset progress + refresh)', () => {
|
||||
const store = createStore();
|
||||
wireStoreSelectors(store);
|
||||
|
||||
render(
|
||||
// @ts-expect-error narrowed test props
|
||||
<NovelScreen navigation={navigation} route={route} />,
|
||||
);
|
||||
|
||||
fireEvent.press(screen.getByTestId('select-read'));
|
||||
fireEvent.press(screen.getByTestId('action-check-outline'));
|
||||
|
||||
expect(store.state.markChaptersUnread).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateChapterProgressByIds).toHaveBeenCalledWith([11], 0);
|
||||
expect(store.state.refreshChapters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps undefined-novel safety path for download action and guarded modals', () => {
|
||||
const store = createStore({ novel: undefined });
|
||||
wireStoreSelectors(store);
|
||||
|
||||
render(
|
||||
// @ts-expect-error narrowed test props
|
||||
<NovelScreen navigation={navigation} route={route} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('jump-to-chapter-modal')).toBeNull();
|
||||
expect(screen.queryByTestId('edit-info-modal')).toBeNull();
|
||||
expect(screen.queryByTestId('download-custom-modal')).toBeNull();
|
||||
|
||||
fireEvent.press(screen.getByTestId('select-undownloaded'));
|
||||
fireEvent.press(screen.getByTestId('action-download-outline'));
|
||||
|
||||
expect(mockDownloadChapters).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { ThemeColors } from '@theme/types';
|
||||
import { ChapterInfo } from '@database/types';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { getString } from '@strings/translations';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface ChapterItemProps {
|
||||
chapter: ChapterInfo;
|
||||
@@ -97,6 +98,12 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
color: theme.outline,
|
||||
marginStart: chapter.releaseTime ? 5 : 0,
|
||||
} as const;
|
||||
function parseTime(time?: string | Date | null) {
|
||||
if (!time) return undefined;
|
||||
const parsedTime = dayjs(time);
|
||||
return parsedTime.isValid() ? parsedTime.format('LL') : (time as string);
|
||||
}
|
||||
const parsedTime = parseTime(releaseTime);
|
||||
|
||||
return (
|
||||
<View key={'chapterItem' + id}>
|
||||
@@ -148,12 +155,12 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
{releaseTime && !isUpdateCard ? (
|
||||
{parsedTime && !isUpdateCard ? (
|
||||
<Text
|
||||
style={[{ color: releaseColor }, styles.mt4, styles.text]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{releaseTime}
|
||||
{parsedTime}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isUpdateCard && progress && progress > 0 && chapter.unread ? (
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
NovelMetaSkeleton,
|
||||
VerticalBarSkeleton,
|
||||
} from '@components/Skeleton/Skeleton';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import Animated, {
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
@@ -50,10 +49,11 @@ import useLoadingColors from '@components/Skeleton/useLoadingColors';
|
||||
|
||||
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
||||
import { ChapterFilterKey } from '@database/constants';
|
||||
import { useNovelAction } from '@screens/novel/NovelContext';
|
||||
|
||||
interface NovelInfoHeaderProps {
|
||||
chapters: ChapterInfo[];
|
||||
deleteDownloadsSnackbar: UseBooleanReturnType;
|
||||
deleteDownloadSnackbar?: UseBooleanReturnType;
|
||||
fetching: boolean;
|
||||
filter?: ChapterFilterKey[];
|
||||
firstUnreadChapter?: ChapterInfo;
|
||||
@@ -234,7 +234,7 @@ const showNotAvailable = async () => {
|
||||
|
||||
const NovelInfoHeader = ({
|
||||
chapters,
|
||||
deleteDownloadsSnackbar,
|
||||
deleteDownloadSnackbar,
|
||||
fetching,
|
||||
filter = [],
|
||||
firstUnreadChapter,
|
||||
@@ -251,7 +251,7 @@ const NovelInfoHeader = ({
|
||||
trackerSheetRef,
|
||||
}: NovelInfoHeaderProps) => {
|
||||
const { hideBackdrop = false } = useAppSettings();
|
||||
const { followNovel } = useNovelContext();
|
||||
const followNovel = useNovelAction('followNovel');
|
||||
|
||||
const pluginName = useMemo(
|
||||
() =>
|
||||
@@ -261,7 +261,15 @@ const NovelInfoHeader = ({
|
||||
[novel.pluginId],
|
||||
);
|
||||
|
||||
const coverSource = useMemo(() => ({ uri: novel.cover }), [novel.cover]);
|
||||
const coverSource = useMemo(
|
||||
() => ({ uri: novel.cover ?? undefined }),
|
||||
[novel.cover],
|
||||
);
|
||||
|
||||
const novelStatus = useMemo(
|
||||
() => (novel.id !== 'NO_ID' ? novel.status ?? undefined : undefined),
|
||||
[novel.id, novel.status],
|
||||
);
|
||||
|
||||
const handleTitlePress = useCallback(
|
||||
() =>
|
||||
@@ -282,16 +290,20 @@ const NovelInfoHeader = ({
|
||||
showNotAvailable();
|
||||
return;
|
||||
}
|
||||
followNovel();
|
||||
followNovel().catch(error =>
|
||||
showToast('Failed updating: ' + (error as Error).message),
|
||||
);
|
||||
if (novel.inLibrary && chapters.some(chapter => chapter.isDownloaded)) {
|
||||
deleteDownloadsSnackbar.setTrue();
|
||||
deleteDownloadSnackbar?.setTrue();
|
||||
} else {
|
||||
deleteDownloadSnackbar?.setFalse();
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
followNovel,
|
||||
novel.inLibrary,
|
||||
chapters,
|
||||
deleteDownloadsSnackbar,
|
||||
deleteDownloadSnackbar,
|
||||
]);
|
||||
|
||||
const handleTrackerSheet = useCallback(
|
||||
@@ -363,16 +375,14 @@ const NovelInfoHeader = ({
|
||||
) : null}
|
||||
<Row style={styles.infoRow}>
|
||||
<MaterialCommunityIcons
|
||||
name={getStatusIcon(
|
||||
novel.id !== 'NO_ID' ? novel.status : undefined,
|
||||
)}
|
||||
name={getStatusIcon(novelStatus)}
|
||||
size={14}
|
||||
color={theme.onSurfaceVariant}
|
||||
style={styles.marginRight}
|
||||
/>
|
||||
<NovelInfo theme={theme}>
|
||||
{(novel.id !== 'NO_ID'
|
||||
? translateNovelStatus(novel.status)
|
||||
{(novelStatus
|
||||
? translateNovelStatus(novelStatus)
|
||||
: getString('novelScreen.unknownStatus')) +
|
||||
' • ' +
|
||||
pluginName}
|
||||
|
||||
@@ -21,30 +21,30 @@ import {
|
||||
LegendListRef,
|
||||
LegendListRenderItemProps,
|
||||
} from '@legendapp/list';
|
||||
import { useNovelAction, useNovelValue } from '../NovelContext';
|
||||
|
||||
interface JumpToChapterModalProps {
|
||||
hideModal: () => void;
|
||||
modalVisible: boolean;
|
||||
navigation: NovelScreenProps['navigation'];
|
||||
novel: NovelInfo;
|
||||
chapters: ChapterInfo[];
|
||||
chapterListRef: React.RefObject<LegendListRef | null>;
|
||||
loadUpToBatch: (batch: number) => Promise<void>;
|
||||
totalChapters?: number;
|
||||
}
|
||||
|
||||
const JumpToChapterModal = ({
|
||||
hideModal,
|
||||
modalVisible,
|
||||
chapters: loadedChapters,
|
||||
navigation,
|
||||
novel,
|
||||
chapterListRef,
|
||||
loadUpToBatch,
|
||||
totalChapters,
|
||||
}: JumpToChapterModalProps) => {
|
||||
const minNumber = 1;
|
||||
const maxNumber = totalChapters ?? -1;
|
||||
|
||||
const loadedChapters = useNovelValue('chapters');
|
||||
const batchInformation = useNovelValue('batchInformation');
|
||||
const loadUpToBatch = useNovelAction('loadUpToBatch');
|
||||
|
||||
const maxNumber = batchInformation.totalChapters ?? -1;
|
||||
const theme = useTheme();
|
||||
const [mode, setMode] = useState(false);
|
||||
const [openChapter, setOpenChapter] = useState(false);
|
||||
|
||||
@@ -28,12 +28,20 @@ const ChaptersSettingsSheet = ({
|
||||
setChapterSort,
|
||||
getChapterFilterState,
|
||||
cycleChapterFilter,
|
||||
setChapterFilterValue,
|
||||
setShowChapterTitles,
|
||||
sort,
|
||||
showChapterTitles,
|
||||
} = useNovelSettings();
|
||||
|
||||
const { left, right } = useSafeAreaInsets();
|
||||
const readStatus = getChapterFilterState('read');
|
||||
const unreadStatus =
|
||||
readStatus === 'indeterminate'
|
||||
? true
|
||||
: readStatus
|
||||
? 'indeterminate'
|
||||
: false;
|
||||
|
||||
const FirstRoute = useCallback(
|
||||
() => (
|
||||
@@ -49,9 +57,18 @@ const ChaptersSettingsSheet = ({
|
||||
<Checkbox
|
||||
theme={theme}
|
||||
label={getString('novelScreen.bottomSheet.filters.unread')}
|
||||
status={getChapterFilterState('read')}
|
||||
status={unreadStatus}
|
||||
onPress={() => {
|
||||
cycleChapterFilter('read');
|
||||
switch (readStatus) {
|
||||
case 'indeterminate':
|
||||
setChapterFilterValue('read', 'ON');
|
||||
break;
|
||||
case true:
|
||||
setChapterFilterValue('read', 'OFF');
|
||||
break;
|
||||
default:
|
||||
setChapterFilterValue('read', 'INDETERMINATE');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
@@ -64,7 +81,14 @@ const ChaptersSettingsSheet = ({
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[cycleChapterFilter, getChapterFilterState, theme],
|
||||
[
|
||||
cycleChapterFilter,
|
||||
getChapterFilterState,
|
||||
readStatus,
|
||||
setChapterFilterValue,
|
||||
theme,
|
||||
unreadStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const SecondRoute = useCallback(
|
||||
|
||||
@@ -4,7 +4,6 @@ import NovelInfoHeader from './Info/NovelInfoHeader';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { pickCustomNovelCover } from '@database/queries/NovelQueries';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { useBoolean } from '@hooks/index';
|
||||
import { useAppSettings, useDownload, useTheme } from '@hooks/persisted';
|
||||
import {
|
||||
updateNovel,
|
||||
@@ -29,12 +28,13 @@ import * as Haptics from 'expo-haptics';
|
||||
import { AnimatedFAB } from 'react-native-paper';
|
||||
import { ChapterListSkeleton } from '@components/Skeleton/Skeleton';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
import { useNovelContext } from '../NovelContext';
|
||||
import { LegendList, LegendListRef } from '@legendapp/list';
|
||||
import FileManager from '@specs/NativeFile';
|
||||
import { downloadFile } from '@plugins/helpers/fetch';
|
||||
import { StorageAccessFramework } from 'expo-file-system/legacy';
|
||||
import PagePaginationControl from './PagePaginationControl';
|
||||
import { useNovelActions, useNovelValue } from '../NovelContext';
|
||||
import { UseBooleanReturnType } from '@hooks/index';
|
||||
|
||||
type NovelScreenListProps = {
|
||||
headerOpacity: SharedValue<number>;
|
||||
@@ -42,13 +42,13 @@ type NovelScreenListProps = {
|
||||
navigation: any;
|
||||
selected: ChapterInfo[];
|
||||
setSelected: React.Dispatch<React.SetStateAction<ChapterInfo[]>>;
|
||||
getNextChapterBatch: () => void;
|
||||
routeBaseNovel: {
|
||||
name: string;
|
||||
path: string;
|
||||
pluginId: string;
|
||||
cover?: string | null;
|
||||
};
|
||||
deleteDownloadSnackbar?: UseBooleanReturnType;
|
||||
};
|
||||
|
||||
const chapterKeyExtractor = (item: ChapterInfo) => 'c' + item.id;
|
||||
@@ -60,27 +60,26 @@ const NovelScreenList = ({
|
||||
routeBaseNovel,
|
||||
selected,
|
||||
setSelected,
|
||||
getNextChapterBatch,
|
||||
deleteDownloadSnackbar,
|
||||
}: NovelScreenListProps) => {
|
||||
const chapters = useNovelValue('chapters');
|
||||
const fetching = useNovelValue('fetching');
|
||||
const firstUnreadChapter = useNovelValue('firstUnreadChapter');
|
||||
const loading = useNovelValue('loading');
|
||||
const pages = useNovelValue('pages');
|
||||
const fetchedNovel = useNovelValue('novel');
|
||||
const batchInformation = useNovelValue('batchInformation');
|
||||
const novelSettings = useNovelValue('novelSettings');
|
||||
const pageIndex = useNovelValue('pageIndex');
|
||||
const lastRead = useNovelValue('lastRead');
|
||||
const {
|
||||
chapters,
|
||||
deleteChapter,
|
||||
fetching,
|
||||
firstUnreadChapter,
|
||||
getNovel,
|
||||
lastRead,
|
||||
loading,
|
||||
novelSettings,
|
||||
pages,
|
||||
setNovel,
|
||||
sortAndFilterChapters,
|
||||
setShowChapterTitles,
|
||||
novel: fetchedNovel,
|
||||
batchInformation,
|
||||
pageIndex,
|
||||
getNextChapterBatch,
|
||||
openPage,
|
||||
updateChapter,
|
||||
} = useNovelContext();
|
||||
refreshNovel,
|
||||
} = useNovelActions();
|
||||
|
||||
const { pluginId } = routeBaseNovel;
|
||||
const routeNovel: Omit<NovelInfo, 'id'> & { id: 'NO_ID' } = {
|
||||
@@ -94,17 +93,12 @@ const NovelScreenList = ({
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const {
|
||||
useFabForContinueReading,
|
||||
defaultChapterSort,
|
||||
disableHapticFeedback,
|
||||
downloadNewChapters,
|
||||
refreshNovelMetadata,
|
||||
} = useAppSettings();
|
||||
|
||||
const {
|
||||
sort = defaultChapterSort,
|
||||
filter,
|
||||
showChapterTitles = false,
|
||||
} = novelSettings;
|
||||
const { filter, showChapterTitles = false } = novelSettings;
|
||||
|
||||
const theme = useTheme();
|
||||
const { top: topInset, bottom: bottomInset } = useSafeAreaInsets();
|
||||
@@ -136,8 +130,6 @@ const NovelScreenList = ({
|
||||
const trackerSheetRef = useRef<BottomSheetModalMethods>(null);
|
||||
const pageNavigationSheetRef = useRef<BottomSheetModalMethods>(null);
|
||||
|
||||
const deleteDownloadsSnackbar = useBoolean();
|
||||
|
||||
// Derive selectedIds Set for O(1) lookups
|
||||
const selectedIds = useMemo(
|
||||
() => new Set(selected.map(s => s.id)),
|
||||
@@ -254,7 +246,7 @@ const NovelScreenList = ({
|
||||
downloadNewChapters,
|
||||
refreshNovelMetadata,
|
||||
})
|
||||
.then(() => getNovel())
|
||||
.then(() => refreshNovel())
|
||||
.then(() =>
|
||||
showToast(
|
||||
getString('novelScreen.updatedToast', { name: novel.name }),
|
||||
@@ -263,7 +255,13 @@ const NovelScreenList = ({
|
||||
.catch(error => showToast('Failed updating: ' + error.message))
|
||||
.finally(() => setUpdating(false));
|
||||
}
|
||||
}, [novel, pluginId, downloadNewChapters, refreshNovelMetadata, getNovel]);
|
||||
}, [
|
||||
novel,
|
||||
pluginId,
|
||||
downloadNewChapters,
|
||||
refreshNovelMetadata,
|
||||
refreshNovel,
|
||||
]);
|
||||
|
||||
const onRefreshPage = useCallback(
|
||||
async (page: string) => {
|
||||
@@ -272,13 +270,13 @@ const NovelScreenList = ({
|
||||
updateNovelPage(pluginId, novel.path, novel.path, novel.id, page, {
|
||||
downloadNewChapters,
|
||||
})
|
||||
.then(() => getNovel())
|
||||
.then(() => refreshNovel())
|
||||
.then(() => showToast(`Updated page: ${page}`))
|
||||
.catch(e => showToast('Failed updating: ' + e.message))
|
||||
.finally(() => setUpdating(false));
|
||||
}
|
||||
},
|
||||
[novel, pluginId, downloadNewChapters, getNovel],
|
||||
[novel, pluginId, downloadNewChapters, refreshNovel],
|
||||
);
|
||||
|
||||
const refreshControlElement = useMemo(
|
||||
@@ -410,7 +408,7 @@ const NovelScreenList = ({
|
||||
<>
|
||||
<NovelInfoHeader
|
||||
chapters={chapters}
|
||||
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
|
||||
deleteDownloadSnackbar={deleteDownloadSnackbar}
|
||||
fetching={fetching}
|
||||
filter={filter}
|
||||
firstUnreadChapter={firstUnreadChapter}
|
||||
@@ -436,7 +434,7 @@ const NovelScreenList = ({
|
||||
),
|
||||
[
|
||||
chapters,
|
||||
deleteDownloadsSnackbar,
|
||||
deleteDownloadSnackbar,
|
||||
fetching,
|
||||
filter,
|
||||
firstUnreadChapter,
|
||||
@@ -517,19 +515,14 @@ const NovelScreenList = ({
|
||||
onEndReachedThreshold={6}
|
||||
onScroll={onPageScroll}
|
||||
scrollEventThrottle={16}
|
||||
drawDistance={1000}
|
||||
//drawDistance={1000}
|
||||
ListHeaderComponent={listHeader}
|
||||
/>
|
||||
{novel.id !== 'NO_ID' ? (
|
||||
<>
|
||||
<NovelBottomSheet
|
||||
bottomSheetRef={novelBottomSheetRef}
|
||||
sortAndFilterChapters={sortAndFilterChapters}
|
||||
setShowChapterTitles={setShowChapterTitles}
|
||||
sort={sort}
|
||||
theme={theme}
|
||||
filter={filter}
|
||||
showChapterTitles={showChapterTitles}
|
||||
/>
|
||||
<TrackSheet bottomSheetRef={trackerSheetRef} novel={novel} />
|
||||
{(novel.totalPages ?? 0) > 1 || pages.length > 1 ? (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Pressable, Text } from 'react-native';
|
||||
import { BottomSheetScrollView, BottomSheetView } from '@gorhom/bottom-sheet';
|
||||
import {
|
||||
BottomSheetView,
|
||||
useBottomSheetScrollableCreator,
|
||||
} from '@gorhom/bottom-sheet';
|
||||
import { LegendList, LegendListRenderItemProps } from '@legendapp/list';
|
||||
import color from 'color';
|
||||
|
||||
@@ -25,6 +28,7 @@ export default function PageNavigationBottomSheet({
|
||||
pageIndex,
|
||||
openPage,
|
||||
}: PageNavigationBottomSheetProps) {
|
||||
const BottomSheetLegendListScrollable = useBottomSheetScrollableCreator();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { left, right } = insets;
|
||||
|
||||
@@ -87,17 +91,16 @@ export default function PageNavigationBottomSheet({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<LegendList
|
||||
data={pages}
|
||||
recycleItems
|
||||
extraData={pageIndex}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `page_${index}_${item}`}
|
||||
estimatedItemSize={56}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
<LegendList
|
||||
data={pages}
|
||||
recycleItems
|
||||
extraData={pageIndex}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `page_${index}_${item}`}
|
||||
estimatedItemSize={56}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderScrollComponent={BottomSheetLegendListScrollable}
|
||||
/>
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import NovelScreenList from '../NovelScreenList';
|
||||
|
||||
const mockUseNovelValue = jest.fn();
|
||||
const mockUseNovelActions = jest.fn();
|
||||
const mockDownloadChapter = jest.fn();
|
||||
let mockDownloadingChapterIds = new Set<number>();
|
||||
|
||||
jest.mock('../../NovelContext', () => ({
|
||||
useNovelValue: (key: string) => mockUseNovelValue(key),
|
||||
useNovelActions: () => mockUseNovelActions(),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
useAppSettings: () => ({
|
||||
useFabForContinueReading: true,
|
||||
disableHapticFeedback: true,
|
||||
downloadNewChapters: false,
|
||||
refreshNovelMetadata: false,
|
||||
}),
|
||||
useDownload: () => ({
|
||||
downloadingChapterIds: mockDownloadingChapterIds,
|
||||
downloadChapter: mockDownloadChapter,
|
||||
}),
|
||||
useTheme: () => ({
|
||||
primary: '#111',
|
||||
onPrimary: '#fff',
|
||||
surface2: '#222',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/index', () => ({
|
||||
useBoolean: () => ({
|
||||
value: false,
|
||||
setTrue: jest.fn(),
|
||||
setFalse: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0 }),
|
||||
}));
|
||||
|
||||
jest.mock('@legendapp/list', () => {
|
||||
const React = require('react');
|
||||
const { View } = require('react-native');
|
||||
|
||||
return {
|
||||
LegendList: ({ data, renderItem, ListHeaderComponent }: any) =>
|
||||
React.createElement(
|
||||
View,
|
||||
null,
|
||||
ListHeaderComponent,
|
||||
...(data || []).map((item: any, index: number) =>
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
{ key: `row-${item.id ?? index}` },
|
||||
renderItem({ item, index }),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../ChapterItem', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text, View } = require('react-native');
|
||||
|
||||
return ({ chapter, onDeleteChapter }: any) =>
|
||||
React.createElement(
|
||||
View,
|
||||
{ testID: `chapter-item-${chapter.id}` },
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{
|
||||
testID: `delete-chapter-${chapter.id}`,
|
||||
onPress: () => onDeleteChapter(chapter),
|
||||
},
|
||||
React.createElement(Text, null, 'delete'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('../Info/NovelInfoHeader', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () =>
|
||||
React.createElement(Text, { testID: 'novel-info-header' }, 'hdr');
|
||||
});
|
||||
|
||||
jest.mock('../PagePaginationControl', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text, View } = require('react-native');
|
||||
|
||||
return ({ onPageChange }: any) =>
|
||||
React.createElement(
|
||||
View,
|
||||
null,
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{ testID: 'pagination-change-page', onPress: () => onPageChange(1) },
|
||||
React.createElement(Text, null, 'change-page'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('../NovelBottomSheet', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () =>
|
||||
React.createElement(Text, { testID: 'novel-bottom-sheet' }, 'nbs');
|
||||
});
|
||||
|
||||
jest.mock('../Tracker/TrackSheet', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () => React.createElement(Text, { testID: 'track-sheet' }, 'track');
|
||||
});
|
||||
|
||||
jest.mock('../PageNavigationBottomSheet', () => {
|
||||
const React = require('react');
|
||||
const { Text } = require('react-native');
|
||||
return () =>
|
||||
React.createElement(Text, { testID: 'page-navigation-sheet' }, 'navsheet');
|
||||
});
|
||||
|
||||
jest.mock('react-native-paper', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text } = require('react-native');
|
||||
|
||||
return {
|
||||
AnimatedFAB: ({ onPress, label }: any) =>
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{
|
||||
testID: label ? 'continue-reading-fab' : 'scroll-to-top-fab',
|
||||
onPress,
|
||||
},
|
||||
React.createElement(Text, null, label || 'fab'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@components/Skeleton/Skeleton', () => ({
|
||||
ChapterListSkeleton: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@database/queries/NovelQueries', () => ({
|
||||
pickCustomNovelCover: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@services/updates/LibraryUpdateQueries', () => ({
|
||||
updateNovel: jest.fn(),
|
||||
updateNovelPage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@strings/translations', () => ({
|
||||
getString: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@utils/showToast', () => ({
|
||||
showToast: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@specs/NativeFile', () => ({
|
||||
getConstants: () => ({ ExternalCachesDirectoryPath: '/tmp' }),
|
||||
copyFile: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@plugins/helpers/fetch', () => ({
|
||||
downloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('expo-file-system/legacy', () => ({
|
||||
StorageAccessFramework: {
|
||||
requestDirectoryPermissionsAsync: jest.fn(),
|
||||
createFileAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('expo-haptics', () => ({
|
||||
impactAsync: jest.fn(),
|
||||
ImpactFeedbackStyle: { Medium: 'medium' },
|
||||
}));
|
||||
|
||||
const baseChapter = {
|
||||
id: 1,
|
||||
novelId: 7,
|
||||
path: '/chapter/1',
|
||||
name: 'Chapter 1',
|
||||
releaseTime: '2026-01-01',
|
||||
updatedTime: '2026-01-01',
|
||||
readTime: '2026-01-01',
|
||||
chapterNumber: 1,
|
||||
bookmark: false,
|
||||
progress: 0,
|
||||
page: '1',
|
||||
unread: true,
|
||||
isDownloaded: false,
|
||||
};
|
||||
|
||||
const baseNovel = {
|
||||
id: 7,
|
||||
name: 'Test Novel',
|
||||
path: '/novel/test',
|
||||
pluginId: 'plugin.test',
|
||||
cover: null,
|
||||
inLibrary: false,
|
||||
isLocal: false,
|
||||
totalPages: 2,
|
||||
};
|
||||
|
||||
const createStore = (overrides: Record<string, unknown> = {}) => {
|
||||
const state = {
|
||||
chapters: [baseChapter],
|
||||
deleteChapter: jest.fn(),
|
||||
fetching: false,
|
||||
firstUnreadChapter: { ...baseChapter, id: 2 },
|
||||
loading: false,
|
||||
novelSettings: { filter: [], showChapterTitles: false },
|
||||
pages: ['1', '2'],
|
||||
setNovel: jest.fn(),
|
||||
novel: baseNovel,
|
||||
batchInformation: { batch: 0, total: 1, totalChapters: 2 },
|
||||
pageIndex: 0,
|
||||
openPage: jest.fn(),
|
||||
updateChapter: jest.fn(),
|
||||
refreshNovel: jest.fn(),
|
||||
lastRead: undefined,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
const wireStoreSelectors = (store: ReturnType<typeof createStore>) => {
|
||||
mockUseNovelValue.mockImplementation(
|
||||
(key: keyof typeof store.state) => store.state[key],
|
||||
);
|
||||
mockUseNovelActions.mockReturnValue({
|
||||
deleteChapter: store.state.deleteChapter,
|
||||
setNovel: store.state.setNovel,
|
||||
openPage: store.state.openPage,
|
||||
updateChapter: store.state.updateChapter,
|
||||
refreshNovel: store.state.refreshNovel,
|
||||
});
|
||||
};
|
||||
|
||||
const navigation = { navigate: jest.fn() };
|
||||
const listRef = { current: { scrollToOffset: jest.fn() } };
|
||||
const headerOpacity = { set: jest.fn() };
|
||||
|
||||
const renderList = () =>
|
||||
render(
|
||||
<NovelScreenList
|
||||
headerOpacity={headerOpacity as any}
|
||||
listRef={listRef as any}
|
||||
navigation={navigation}
|
||||
routeBaseNovel={{
|
||||
name: 'Route Novel',
|
||||
path: '/novel/test',
|
||||
pluginId: 'plugin.test',
|
||||
}}
|
||||
selected={[]}
|
||||
setSelected={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('NovelScreenList (task 12 context boundary cutover)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDownloadingChapterIds = new Set<number>();
|
||||
});
|
||||
|
||||
it('uses novelStore selector actions', () => {
|
||||
const store = createStore();
|
||||
wireStoreSelectors(store);
|
||||
|
||||
renderList();
|
||||
|
||||
fireEvent.press(screen.getByTestId('delete-chapter-1'));
|
||||
|
||||
expect(store.state.deleteChapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks downloaded chapter when an id leaves downloading set', () => {
|
||||
const store = createStore();
|
||||
|
||||
mockDownloadingChapterIds = new Set<number>([1]);
|
||||
wireStoreSelectors(store);
|
||||
|
||||
const view = renderList();
|
||||
|
||||
mockDownloadingChapterIds = new Set<number>();
|
||||
view.rerender(
|
||||
<NovelScreenList
|
||||
headerOpacity={headerOpacity as any}
|
||||
listRef={listRef as any}
|
||||
navigation={navigation}
|
||||
routeBaseNovel={{
|
||||
name: 'Route Novel',
|
||||
path: '/novel/test',
|
||||
pluginId: 'plugin.test',
|
||||
}}
|
||||
selected={[]}
|
||||
setSelected={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(store.state.updateChapter).toHaveBeenCalledWith(0, {
|
||||
isDownloaded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses selector-backed page navigation action from novelStore', () => {
|
||||
const store = createStore();
|
||||
wireStoreSelectors(store);
|
||||
|
||||
renderList();
|
||||
|
||||
fireEvent.press(screen.getByTestId('pagination-change-page'));
|
||||
|
||||
expect(store.state.openPage).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('keeps continue-reading FAB navigation parity with lastRead fallback chain', () => {
|
||||
const lastRead = { ...baseChapter, id: 42 };
|
||||
const store = createStore({
|
||||
firstUnreadChapter: { ...baseChapter, id: 99 },
|
||||
lastRead,
|
||||
});
|
||||
|
||||
wireStoreSelectors(store);
|
||||
|
||||
renderList();
|
||||
|
||||
fireEvent.press(screen.getByTestId('continue-reading-fab'));
|
||||
|
||||
expect(navigation.navigate).toHaveBeenCalledWith('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: { novel: baseNovel, chapter: lastRead },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react-native';
|
||||
import ChapterDrawer from '..';
|
||||
|
||||
const mockUseNovelValue = jest.fn();
|
||||
const mockUseNovelActions = jest.fn();
|
||||
const mockUseChapterContext = jest.fn();
|
||||
|
||||
jest.mock('@screens/novel/NovelContext', () => ({
|
||||
useNovelValue: (key: string) => mockUseNovelValue(key),
|
||||
useNovelActions: () => mockUseNovelActions(),
|
||||
}));
|
||||
|
||||
jest.mock('@screens/reader/ChapterContext', () => ({
|
||||
useChapterContext: () => mockUseChapterContext(),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
useTheme: () => ({
|
||||
surface: '#111',
|
||||
outline: '#222',
|
||||
onSurface: '#333',
|
||||
onSurfaceVariant: '#444',
|
||||
}),
|
||||
useAppSettings: () => ({
|
||||
defaultChapterSort: 'positionAsc',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 0 }),
|
||||
}));
|
||||
|
||||
jest.mock('@strings/translations', () => ({
|
||||
getString: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@components/index', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text, View } = require('react-native');
|
||||
|
||||
return {
|
||||
Button: ({ title, onPress }: any) =>
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{ testID: `btn-${title}`, onPress },
|
||||
React.createElement(Text, null, title),
|
||||
),
|
||||
LoadingScreenV2: () => React.createElement(View, { testID: 'loading' }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../RenderListChapter', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text } = require('react-native');
|
||||
|
||||
return ({ item, onPress }: any) =>
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{ testID: `chapter-${item.id}`, onPress },
|
||||
React.createElement(Text, null, item.name),
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('@legendapp/list', () => {
|
||||
const React = require('react');
|
||||
const { Pressable, Text, View } = require('react-native');
|
||||
|
||||
return {
|
||||
LegendList: ({ data = [], renderItem, onEndReached }: any) =>
|
||||
React.createElement(
|
||||
View,
|
||||
null,
|
||||
...data.map((item: any, index: number) =>
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
{ key: item.id ?? index },
|
||||
renderItem({ item, index }),
|
||||
),
|
||||
),
|
||||
React.createElement(
|
||||
Pressable,
|
||||
{ testID: 'legend-end-reached', onPress: () => onEndReached?.() },
|
||||
React.createElement(Text, null, 'end'),
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const makeChapter = (id: number, page = '1') => ({
|
||||
id,
|
||||
novelId: 7,
|
||||
name: `Chapter ${id}`,
|
||||
path: `/chapter/${id}`,
|
||||
page,
|
||||
position: id,
|
||||
unread: true,
|
||||
isDownloaded: false,
|
||||
bookmark: false,
|
||||
progress: 0,
|
||||
releaseTime: '2026-01-01',
|
||||
updatedTime: '2026-01-01',
|
||||
readTime: '2026-01-01',
|
||||
});
|
||||
|
||||
const createStore = (overrides: Record<string, unknown> = {}) => {
|
||||
const state = {
|
||||
chapters: [makeChapter(1, '1'), makeChapter(2, '2')],
|
||||
novelSettings: { sort: 'positionAsc', filter: [] },
|
||||
pages: ['1', '2'],
|
||||
fetching: false,
|
||||
batchInformation: { batch: 0, total: 1, totalChapters: 2 },
|
||||
getNextChapterBatch: jest.fn(),
|
||||
setPageIndex: jest.fn(),
|
||||
openPage: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ChapterDrawer (task 12 context boundary cutover)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses novelStore selector-backed page index and pagination batch actions', async () => {
|
||||
const store = createStore();
|
||||
mockUseChapterContext.mockReturnValue({
|
||||
chapter: makeChapter(10, '2'),
|
||||
getChapter: jest.fn(),
|
||||
setLoading: jest.fn(),
|
||||
});
|
||||
mockUseNovelValue.mockImplementation(
|
||||
(key: keyof typeof store.state) => store.state[key],
|
||||
);
|
||||
mockUseNovelActions.mockReturnValue({
|
||||
getNextChapterBatch: store.state.getNextChapterBatch,
|
||||
openPage: store.state.openPage,
|
||||
});
|
||||
|
||||
render(<ChapterDrawer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.state.openPage).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
fireEvent.press(screen.getByTestId('legend-end-reached'));
|
||||
|
||||
expect(store.state.getNextChapterBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,9 @@ import { getString } from '@strings/translations';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import renderListChapter from './RenderListChapter';
|
||||
import { useChapterContext } from '@screens/reader/ChapterContext';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import { LegendList, LegendListRef, ViewToken } from '@legendapp/list';
|
||||
import { noop } from 'lodash-es';
|
||||
import { useNovelActions, useNovelValue } from '@screens/novel/NovelContext';
|
||||
|
||||
type ButtonProperties = {
|
||||
text: string;
|
||||
@@ -30,15 +30,13 @@ type ButtonsProperties = {
|
||||
|
||||
const ChapterDrawer = () => {
|
||||
const { chapter, getChapter, setLoading } = useChapterContext();
|
||||
const {
|
||||
chapters,
|
||||
novelSettings,
|
||||
pages,
|
||||
fetching,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
setPageIndex,
|
||||
} = useNovelContext();
|
||||
const chapters = useNovelValue('chapters');
|
||||
const novelSettings = useNovelValue('novelSettings');
|
||||
const pages = useNovelValue('pages');
|
||||
const fetching = useNovelValue('fetching');
|
||||
const batchInformation = useNovelValue('batchInformation');
|
||||
const { getNextChapterBatch, openPage } = useNovelActions();
|
||||
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
@@ -69,8 +67,8 @@ const ChapterDrawer = () => {
|
||||
if (pageIndex === -1) {
|
||||
pageIndex = 0;
|
||||
}
|
||||
setPageIndex(pageIndex);
|
||||
}, [chapter, pages, setPageIndex]);
|
||||
openPage(pageIndex);
|
||||
}, [chapter, pages, openPage]);
|
||||
|
||||
const calculateScrollToIndex = useCallback(() => {
|
||||
if (chapters.length < 1) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import Animated, {
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { bookmarkChapter } from '@database/queries/ChapterQueries';
|
||||
import { useChapterContext } from '../ChapterContext';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import { useNovelLayout } from '@screens/novel/NovelContext';
|
||||
|
||||
interface ReaderAppbarProps {
|
||||
theme: ThemeColors;
|
||||
@@ -30,7 +30,7 @@ const ReaderAppbar = ({
|
||||
setBookmarked,
|
||||
}: ReaderAppbarProps) => {
|
||||
const { chapter, novel } = useChapterContext();
|
||||
const { statusBarHeight } = useNovelContext();
|
||||
const { statusBarHeight } = useNovelLayout();
|
||||
|
||||
const entering = () => {
|
||||
'worklet';
|
||||
|
||||
@@ -11,8 +11,8 @@ import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/typ
|
||||
import { ChapterScreenProps } from '@navigators/types';
|
||||
import { useChapterContext } from '../ChapterContext';
|
||||
import { SCREEN_HEIGHT } from '@gorhom/bottom-sheet';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { useNovelLayout } from '@screens/novel/NovelContext';
|
||||
|
||||
interface ChapterFooterProps {
|
||||
readerSheetRef: React.RefObject<BottomSheetModalMethods | null>;
|
||||
@@ -37,7 +37,7 @@ const ChapterFooter = ({
|
||||
borderless: true,
|
||||
radius: 50,
|
||||
};
|
||||
const { navigationBarHeight } = useNovelContext();
|
||||
const { navigationBarHeight } = useNovelLayout();
|
||||
|
||||
const entering = () => {
|
||||
'worklet';
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react-native';
|
||||
import useChapter from '../useChapter';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
|
||||
const mockUseNovelActions = jest.fn();
|
||||
const mockUseChapterGeneralSettings = jest.fn();
|
||||
const mockUseLibrarySettings = jest.fn();
|
||||
const mockUseTracker = jest.fn();
|
||||
const mockUseTrackedNovel = jest.fn();
|
||||
const mockUseFullscreenMode = jest.fn();
|
||||
|
||||
const mockGetDbChapter = jest.fn();
|
||||
const mockGetChapterCount = jest.fn();
|
||||
const mockGetNextChapter = jest.fn();
|
||||
const mockGetPrevChapter = jest.fn();
|
||||
const mockInsertChapters = jest.fn();
|
||||
const mockInsertHistory = jest.fn();
|
||||
const mockFetchChapter = jest.fn();
|
||||
const mockFetchPage = jest.fn();
|
||||
const mockSanitizeChapterText = jest.fn();
|
||||
const mockParseChapterNumber = jest.fn();
|
||||
|
||||
jest.mock('@screens/novel/NovelContext', () => ({
|
||||
useNovelActions: () => mockUseNovelActions(),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
useChapterGeneralSettings: () => mockUseChapterGeneralSettings(),
|
||||
useLibrarySettings: () => mockUseLibrarySettings(),
|
||||
useTracker: () => mockUseTracker(),
|
||||
useTrackedNovel: (...args: unknown[]) => mockUseTrackedNovel(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@hooks', () => ({
|
||||
useFullscreenMode: () => mockUseFullscreenMode(),
|
||||
}));
|
||||
|
||||
jest.mock('@database/queries/ChapterQueries', () => ({
|
||||
getChapter: (...args: unknown[]) => mockGetDbChapter(...args),
|
||||
getChapterCount: (...args: unknown[]) => mockGetChapterCount(...args),
|
||||
getNextChapter: (...args: unknown[]) => mockGetNextChapter(...args),
|
||||
getPrevChapter: (...args: unknown[]) => mockGetPrevChapter(...args),
|
||||
insertChapters: (...args: unknown[]) => mockInsertChapters(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@database/queries/HistoryQueries', () => ({
|
||||
insertHistory: (...args: unknown[]) => mockInsertHistory(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@services/plugin/fetch', () => ({
|
||||
fetchChapter: (...args: unknown[]) => mockFetchChapter(...args),
|
||||
fetchPage: (...args: unknown[]) => mockFetchPage(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/sanitizeChapterText', () => ({
|
||||
sanitizeChapterText: (...args: unknown[]) => mockSanitizeChapterText(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/parseChapterNumber', () => ({
|
||||
parseChapterNumber: (...args: unknown[]) => mockParseChapterNumber(...args),
|
||||
}));
|
||||
|
||||
jest.mock('expo-speech', () => ({
|
||||
stop: jest.fn(),
|
||||
}));
|
||||
|
||||
const makeChapter = (id: number, page = '1') => ({
|
||||
id,
|
||||
novelId: 7,
|
||||
name: `Chapter ${id}`,
|
||||
path: `/chapter/${id}`,
|
||||
page,
|
||||
position: id,
|
||||
unread: true,
|
||||
isDownloaded: false,
|
||||
bookmark: false,
|
||||
progress: 0,
|
||||
releaseTime: '2026-01-01',
|
||||
updatedTime: '2026-01-01',
|
||||
readTime: '2026-01-01',
|
||||
});
|
||||
|
||||
const makeNovel = () => ({
|
||||
id: 7,
|
||||
pluginId: 'plugin.reader',
|
||||
path: '/novel/test',
|
||||
name: 'Novel Test',
|
||||
totalPages: 3,
|
||||
inLibrary: true,
|
||||
});
|
||||
|
||||
const createDeferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const createStore = (
|
||||
cacheSeed: Record<number, string | Promise<string>> = {},
|
||||
) => {
|
||||
const cache = new Map<number, string | Promise<string>>(
|
||||
Object.entries(cacheSeed).map(([k, v]) => [Number(k), v]),
|
||||
);
|
||||
const chapterTextCache = {
|
||||
read: jest.fn((chapterId: number) => cache.get(chapterId)),
|
||||
write: jest.fn((chapterId: number, value: string | Promise<string>) => {
|
||||
cache.set(chapterId, value);
|
||||
}),
|
||||
remove: jest.fn((chapterId: number) => {
|
||||
cache.delete(chapterId);
|
||||
}),
|
||||
clear: jest.fn(() => cache.clear()),
|
||||
};
|
||||
const state = {
|
||||
markChapterRead: jest.fn(),
|
||||
updateChapterProgress: jest.fn(),
|
||||
chapterTextCache,
|
||||
setLastRead: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
state,
|
||||
chapterTextCache,
|
||||
};
|
||||
};
|
||||
|
||||
describe('useChapter', () => {
|
||||
const initialChapter = makeChapter(1, '1');
|
||||
const nextChapter = makeChapter(2, '1');
|
||||
const novel = makeNovel();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(NativeFile.exists as jest.Mock).mockReturnValue(false);
|
||||
(NativeFile.readFile as jest.Mock).mockReturnValue('');
|
||||
|
||||
mockUseChapterGeneralSettings.mockReturnValue({
|
||||
autoScroll: false,
|
||||
autoScrollInterval: 1,
|
||||
autoScrollOffset: 100,
|
||||
useVolumeButtons: false,
|
||||
volumeButtonsOffset: 100,
|
||||
});
|
||||
mockUseLibrarySettings.mockReturnValue({ incognitoMode: false });
|
||||
mockUseTracker.mockReturnValue({ tracker: { id: 'tracker' } });
|
||||
mockUseTrackedNovel.mockReturnValue({
|
||||
trackedNovel: { progress: 1 },
|
||||
updateAllTrackedNovels: jest.fn(),
|
||||
});
|
||||
mockUseFullscreenMode.mockReturnValue({
|
||||
setImmersiveMode: jest.fn(),
|
||||
showStatusAndNavBar: jest.fn(),
|
||||
});
|
||||
|
||||
mockGetDbChapter.mockResolvedValue(initialChapter);
|
||||
mockGetChapterCount.mockResolvedValue(1);
|
||||
mockGetNextChapter.mockResolvedValue(undefined);
|
||||
mockGetPrevChapter.mockResolvedValue(undefined);
|
||||
mockInsertChapters.mockResolvedValue(undefined);
|
||||
mockInsertHistory.mockResolvedValue(undefined);
|
||||
mockFetchChapter.mockResolvedValue('chapter body');
|
||||
mockFetchPage.mockResolvedValue({ chapters: [] });
|
||||
mockSanitizeChapterText.mockImplementation(
|
||||
(
|
||||
_pluginId: string,
|
||||
_novelName: string,
|
||||
_chapterName: string,
|
||||
text: string,
|
||||
) => `SANITIZED:${text}`,
|
||||
);
|
||||
mockParseChapterNumber.mockReturnValue(5);
|
||||
});
|
||||
|
||||
it('uses chapterTextCache on initial load and avoids duplicate fetch for cached chapter text', async () => {
|
||||
const store = createStore({ [initialChapter.id]: 'cached chapter body' });
|
||||
mockUseNovelActions.mockReturnValue(store.state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChapter({ current: null }, initialChapter, novel),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(mockFetchChapter).not.toHaveBeenCalled();
|
||||
expect(result.current.chapterText).toBe('SANITIZED:cached chapter body');
|
||||
expect(store.chapterTextCache.write).not.toHaveBeenCalledWith(
|
||||
initialChapter.id,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates chapter progress, caps at 100, and marks chapter read/tracker progress near completion', async () => {
|
||||
const store = createStore();
|
||||
const updateAllTrackedNovels = jest.fn();
|
||||
mockUseTrackedNovel.mockReturnValue({
|
||||
trackedNovel: { progress: 2 },
|
||||
updateAllTrackedNovels,
|
||||
});
|
||||
mockUseNovelActions.mockReturnValue(store.state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChapter({ current: null }, initialChapter, novel),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.saveProgress(40);
|
||||
result.current.saveProgress(130);
|
||||
});
|
||||
|
||||
expect(store.state.updateChapterProgress).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
initialChapter.id,
|
||||
40,
|
||||
);
|
||||
expect(store.state.updateChapterProgress).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
initialChapter.id,
|
||||
100,
|
||||
);
|
||||
expect(store.state.markChapterRead).toHaveBeenCalledTimes(1);
|
||||
expect(store.state.markChapterRead).toHaveBeenCalledWith(initialChapter.id);
|
||||
expect(mockParseChapterNumber).toHaveBeenCalledWith(
|
||||
novel.name,
|
||||
initialChapter.name,
|
||||
);
|
||||
expect(updateAllTrackedNovels).toHaveBeenCalledWith({ progress: 5 });
|
||||
});
|
||||
|
||||
it('sets error and remains stable when chapter fetch fails', async () => {
|
||||
const store = createStore();
|
||||
mockUseNovelActions.mockReturnValue(store.state);
|
||||
mockFetchChapter.mockRejectedValueOnce(new Error('network failed'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChapter({ current: null }, initialChapter, novel),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe('network failed'));
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.chapterText).toBe('SANITIZED:');
|
||||
});
|
||||
|
||||
it('reuses prefetched promise cache to avoid duplicate concurrent fetches for same chapter', async () => {
|
||||
const store = createStore();
|
||||
mockUseNovelActions.mockReturnValue(store.state);
|
||||
|
||||
const deferredNext = createDeferred<string>();
|
||||
|
||||
mockGetNextChapter.mockImplementation(
|
||||
async (_novelId: number, position: number) =>
|
||||
position === initialChapter.position ? nextChapter : undefined,
|
||||
);
|
||||
mockFetchChapter.mockImplementation(
|
||||
async (_pluginId: string, path: string) => {
|
||||
if (path === nextChapter.path) {
|
||||
return deferredNext.promise;
|
||||
}
|
||||
|
||||
return 'initial body';
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChapter({ current: null }, initialChapter, novel),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const navPromise = result.current.getChapter(nextChapter);
|
||||
|
||||
expect(
|
||||
mockFetchChapter.mock.calls.filter(
|
||||
([, path]) => path === nextChapter.path,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
deferredNext.resolve('next body');
|
||||
await navPromise;
|
||||
});
|
||||
|
||||
expect(result.current.chapter.id).toBe(nextChapter.id);
|
||||
expect(result.current.chapterText).toBe('SANITIZED:next body');
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ import { showToast } from '@utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
import NativeVolumeButtonListener from '@specs/NativeVolumeButtonListener';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import { useNovelActions } from '@screens/novel/NovelContext';
|
||||
|
||||
const emmiter = new NativeEventEmitter(NativeVolumeButtonListener);
|
||||
|
||||
@@ -48,7 +48,8 @@ export default function useChapter(
|
||||
markChapterRead,
|
||||
updateChapterProgress,
|
||||
chapterTextCache,
|
||||
} = useNovelContext();
|
||||
} = useNovelActions();
|
||||
|
||||
const [hidden, setHidden] = useState(true);
|
||||
const [chapter, setChapter] = useState(initialChapter);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -125,7 +126,7 @@ export default function useChapter(
|
||||
async (navChapter?: ChapterInfo) => {
|
||||
try {
|
||||
const chap = navChapter ?? chapter;
|
||||
const cachedText = chapterTextCache.get(chap.id);
|
||||
const cachedText = chapterTextCache.read(chap.id);
|
||||
const text = cachedText ?? loadChapterText(chap.id, chap.path);
|
||||
const [nextChapResult, prevChapResult, awaitedText] = await Promise.all(
|
||||
[
|
||||
@@ -137,14 +138,11 @@ export default function useChapter(
|
||||
|
||||
let nextChap = nextChapResult;
|
||||
let prevChap = prevChapResult;
|
||||
const totalPages = novel.totalPages ?? 0;
|
||||
|
||||
// Pre-fetch adjacent page chapters if at a page boundary
|
||||
const currentPage = Number(chap.page);
|
||||
if (
|
||||
!nextChap &&
|
||||
novel.totalPages > 0 &&
|
||||
currentPage < novel.totalPages
|
||||
) {
|
||||
if (!nextChap && totalPages > 0 && currentPage < totalPages) {
|
||||
const nextPage = String(currentPage + 1);
|
||||
try {
|
||||
const count = await getChapterCount(chap.novelId, nextPage);
|
||||
@@ -189,14 +187,14 @@ export default function useChapter(
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (nextChap && !chapterTextCache.get(nextChap.id)) {
|
||||
chapterTextCache.set(
|
||||
if (nextChap && !chapterTextCache.read(nextChap.id)) {
|
||||
chapterTextCache.write(
|
||||
nextChap.id,
|
||||
loadChapterText(nextChap.id, nextChap.path),
|
||||
);
|
||||
}
|
||||
if (!cachedText) {
|
||||
chapterTextCache.set(chap.id, text);
|
||||
chapterTextCache.write(chap.id, text);
|
||||
}
|
||||
setChapter(chap);
|
||||
setChapterText(
|
||||
|
||||
@@ -2,10 +2,9 @@ import React, { useState } from 'react';
|
||||
|
||||
import { Portal, Text, TextInput } from 'react-native-paper';
|
||||
|
||||
import { useTheme, useUserAgent } from '@hooks/persisted';
|
||||
import { deleteCachedNovels, useTheme, useUserAgent } from '@hooks/persisted';
|
||||
import { showToast } from '@utils/showToast';
|
||||
|
||||
import { deleteCachedNovels } from '@hooks/persisted/useNovel';
|
||||
import { getString } from '@strings/translations';
|
||||
import { useBoolean } from '@hooks';
|
||||
import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog';
|
||||
|
||||
@@ -47,17 +47,21 @@ const NavigationTab: React.FC = () => {
|
||||
{useVolumeButtons && (
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
label={getString('readerSettings.volumeButtonsOffset')}
|
||||
label={getString('readerSettings.volumeButtonOffset')}
|
||||
mode="outlined"
|
||||
keyboardType="numeric"
|
||||
defaultValue={defaultTo(
|
||||
volumeButtonsOffset,
|
||||
Math.round(screenHeight * 0.75),
|
||||
volumeButtonsOffset
|
||||
? Math.round(volumeButtonsOffset / screenHeight)
|
||||
: null,
|
||||
0.75,
|
||||
).toString()}
|
||||
onChangeText={text => {
|
||||
if (text) {
|
||||
if (!isNaN(Number(text))) {
|
||||
setChapterGeneralSettings({
|
||||
volumeButtonsOffset: Number(text),
|
||||
volumeButtonsOffset: Math.round(
|
||||
Number(text) * screenHeight,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -94,11 +94,14 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
|
||||
acc: { data: UpdateOverview[]; date: string }[],
|
||||
cur: UpdateOverview,
|
||||
) => {
|
||||
if (acc.length === 0 || acc.at(-1)?.date !== cur.updateDate) {
|
||||
if (
|
||||
acc.length === 0 ||
|
||||
acc[acc.length - 1]?.date !== cur.updateDate
|
||||
) {
|
||||
acc.push({ data: [cur], date: cur.updateDate });
|
||||
return acc;
|
||||
}
|
||||
acc.at(-1)?.data.push(cur);
|
||||
acc[acc.length - 1]?.data.push(cur);
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -49,11 +49,11 @@ export const myAnimeListTracker: Tracker = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}, // @ts-expect-error - If no clientId is set, which will only be set in production builds, this will error out.
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code: code,
|
||||
code_verifier: challenge,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
@@ -8,11 +8,10 @@ import { getNovelChapters } from '@database/queries/ChapterQueries';
|
||||
import { fetchNovel } from '@services/plugin/fetch';
|
||||
import { parseChapterNumber } from '@utils/parseChapterNumber';
|
||||
|
||||
import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
|
||||
import {
|
||||
LAST_READ_PREFIX,
|
||||
NOVEL_SETTINGS_PREFIX,
|
||||
} from '@hooks/persisted/useNovel';
|
||||
novelPersistence,
|
||||
type NovelPersistenceInput,
|
||||
} from '@hooks/persisted/useNovel/store-helper/contracts';
|
||||
import { sleep } from '@utils/sleep';
|
||||
import ServiceManager, {
|
||||
BackgroundTaskMetadata,
|
||||
@@ -96,22 +95,21 @@ export const migrateNovel = async (
|
||||
await tx.delete(novelSchema).where(eq(novelSchema.id, fromNovel.id));
|
||||
});
|
||||
|
||||
setMMKVObject(
|
||||
`${NOVEL_SETTINGS_PREFIX}_${toNovel!.pluginId}_${toNovel!.path}`,
|
||||
getMMKVObject(
|
||||
`${NOVEL_SETTINGS_PREFIX}_${fromNovel.pluginId}_${fromNovel.path}`,
|
||||
),
|
||||
);
|
||||
const fromPersistenceInput: NovelPersistenceInput = {
|
||||
pluginId: fromNovel.pluginId,
|
||||
novelPath: fromNovel.path,
|
||||
};
|
||||
const toPersistenceInput: NovelPersistenceInput = {
|
||||
pluginId: toNovel!.pluginId,
|
||||
novelPath: toNovel!.path,
|
||||
};
|
||||
|
||||
const lastRead = getMMKVObject<NovelInfo>(
|
||||
`${LAST_READ_PREFIX}_${fromNovel.pluginId}_${fromNovel.path}`,
|
||||
);
|
||||
novelPersistence.copySettings(fromPersistenceInput, toPersistenceInput);
|
||||
|
||||
const lastRead = novelPersistence.readLastRead(fromPersistenceInput);
|
||||
|
||||
const setLastRead = (chapter: ChapterInfo) => {
|
||||
setMMKVObject(
|
||||
`${LAST_READ_PREFIX}_${toNovel!.pluginId}_${toNovel!.path}`,
|
||||
chapter,
|
||||
);
|
||||
novelPersistence.writeLastRead(toPersistenceInput, chapter);
|
||||
};
|
||||
|
||||
fromChapters = sortChaptersByNumber(fromNovel.name, fromChapters);
|
||||
|
||||
@@ -6,8 +6,9 @@ import { downloadFile } from '@plugins/helpers/fetch';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
import { dbManager } from '@database/db';
|
||||
import { novelSchema, chapterSchema } from '@database/schema';
|
||||
import { eq, and, ne, or, sql } from 'drizzle-orm';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
import { insertChapters } from '@database/queries/ChapterQueries';
|
||||
|
||||
/**
|
||||
* Update novel metadata in the database including cover image.
|
||||
@@ -81,80 +82,71 @@ const updateNovelChapters = async (
|
||||
downloadNewChapters?: boolean,
|
||||
page?: string,
|
||||
) => {
|
||||
await dbManager.write(async tx => {
|
||||
for (let position = 0; position < chapters.length; position++) {
|
||||
const chapter = chapters[position];
|
||||
const {
|
||||
name,
|
||||
path,
|
||||
releaseTime,
|
||||
page: customPage,
|
||||
chapterNumber,
|
||||
} = chapter;
|
||||
const chapterPage = page || customPage || '1';
|
||||
if (!chapters.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if chapter already exists
|
||||
const existing = await tx
|
||||
.select({ id: chapterSchema.id })
|
||||
const incomingPaths = Array.from(
|
||||
new Set(chapters.map(chapter => chapter.path)),
|
||||
);
|
||||
const existingChapters = incomingPaths.length
|
||||
? await dbManager
|
||||
.select({ path: chapterSchema.path })
|
||||
.from(chapterSchema)
|
||||
.where(
|
||||
and(eq(chapterSchema.novelId, novelId), eq(chapterSchema.path, path)),
|
||||
and(
|
||||
eq(chapterSchema.novelId, novelId),
|
||||
inArray(chapterSchema.path, incomingPaths),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
.all()
|
||||
: [];
|
||||
|
||||
if (!existing) {
|
||||
// Insert new chapter
|
||||
const newChapter = await tx
|
||||
.insert(chapterSchema)
|
||||
.values({
|
||||
path,
|
||||
name,
|
||||
releaseTime: releaseTime || null,
|
||||
novelId,
|
||||
updatedTime: sql`datetime('now','localtime')`,
|
||||
chapterNumber: chapterNumber || null,
|
||||
page: chapterPage,
|
||||
position: position,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
const existingPathSet = new Set(
|
||||
existingChapters.map(chapter => chapter.path),
|
||||
);
|
||||
const newPaths = incomingPaths.filter(path => !existingPathSet.has(path));
|
||||
|
||||
if (newChapter && downloadNewChapters) {
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'DOWNLOAD_CHAPTER',
|
||||
data: {
|
||||
chapterId: newChapter.id,
|
||||
novelName: novelName,
|
||||
chapterName: name,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update existing chapter if metadata changed
|
||||
tx.update(chapterSchema)
|
||||
.set({
|
||||
name,
|
||||
releaseTime: releaseTime || null,
|
||||
updatedTime: sql`datetime('now','localtime')`,
|
||||
page: chapterPage,
|
||||
position: position,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chapterSchema.id, existing.id),
|
||||
eq(chapterSchema.novelId, novelId),
|
||||
or(
|
||||
ne(chapterSchema.name, name),
|
||||
ne(chapterSchema.releaseTime, releaseTime!),
|
||||
ne(chapterSchema.page, chapterPage),
|
||||
ne(chapterSchema.position, position),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
await insertChapters(novelId, chapters, {
|
||||
page,
|
||||
touchUpdatedTime: true,
|
||||
});
|
||||
|
||||
if (downloadNewChapters && newPaths.length) {
|
||||
const insertedNewChapters = await dbManager
|
||||
.select({
|
||||
id: chapterSchema.id,
|
||||
path: chapterSchema.path,
|
||||
name: chapterSchema.name,
|
||||
})
|
||||
.from(chapterSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(chapterSchema.novelId, novelId),
|
||||
inArray(chapterSchema.path, newPaths),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const chapterNameByPath = new Map(
|
||||
chapters.map((chapter, index) => [
|
||||
chapter.path,
|
||||
chapter.name || `Chapter ${index + 1}`,
|
||||
]),
|
||||
);
|
||||
|
||||
for (const insertedChapter of insertedNewChapters) {
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'DOWNLOAD_CHAPTER',
|
||||
data: {
|
||||
chapterId: insertedChapter.id,
|
||||
novelName,
|
||||
chapterName:
|
||||
chapterNameByPath.get(insertedChapter.path) || insertedChapter.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface UpdateNovelOptions {
|
||||
@@ -194,9 +186,7 @@ const updateNovel = async (
|
||||
await updateNovelMetadata(pluginId, novelId, novel);
|
||||
} else if (novel.totalPages) {
|
||||
await updateNovelTotalPages(novelId, novel.totalPages);
|
||||
await updateNovelTotalPages(novelId, novel.totalPages);
|
||||
}
|
||||
|
||||
await updateNovelChapters(
|
||||
novel.name,
|
||||
novelId,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MMKVStorage } from './mmkv';
|
||||
|
||||
/**
|
||||
* Zustand persist storage adapter for MMKV.
|
||||
* Implements the storage contract required by zustand's persist middleware.
|
||||
*
|
||||
* This adapter bridges zustand's storage interface (getItem, setItem, removeItem)
|
||||
* with the MMKV native storage backend used in react-native-mmkv.
|
||||
*/
|
||||
export const mmkvZustandAdapter = {
|
||||
/**
|
||||
* Get a stored value from MMKV by key.
|
||||
* Returns JSON string for zustand to parse, or null if not found.
|
||||
*/
|
||||
getItem: (key: string): string | null => {
|
||||
try {
|
||||
const value = MMKVStorage.getString(key);
|
||||
return value ?? null;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[mmkvZustandAdapter] Error getting item for key "${key}":`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a value in MMKV storage.
|
||||
* Zustand passes a JSON string; we store it directly.
|
||||
*/
|
||||
setItem: (key: string, value: string): void => {
|
||||
try {
|
||||
MMKVStorage.set(key, value);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[mmkvZustandAdapter] Error setting item for key "${key}":`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a value from MMKV storage.
|
||||
*/
|
||||
removeItem: (key: string): void => {
|
||||
try {
|
||||
MMKVStorage.remove(key);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[mmkvZustandAdapter] Error removing item for key "${key}":`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -516,7 +516,8 @@
|
||||
"textColor": "Text color",
|
||||
"textColorModal": "Text color",
|
||||
"title": "Reader",
|
||||
"verticalSeekbarDesc": "Use vertical seekbar"
|
||||
"verticalSeekbarDesc": "Use vertical seekbar",
|
||||
"volumeButtonOffset": "Volume button scroll offset (screen heights)"
|
||||
},
|
||||
"sourceScreen": {
|
||||
"noResultsFound": "No results found"
|
||||
|
||||
@@ -430,6 +430,7 @@ export interface StringMap {
|
||||
'readerSettings.textColorModal': 'string';
|
||||
'readerSettings.title': 'string';
|
||||
'readerSettings.verticalSeekbarDesc': 'string';
|
||||
'readerSettings.volumeButtonOffset': 'string';
|
||||
'sourceScreen.noResultsFound': 'string';
|
||||
'statsScreen.downloadedChapters': 'string';
|
||||
'statsScreen.genreDistribution': 'string';
|
||||
|
||||
Reference in New Issue
Block a user