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:
Patrick Loser
2026-05-06 16:55:55 +02:00
committed by GitHub
parent b680a07750
commit eafa545b2a
75 changed files with 6238 additions and 2020 deletions
+3
View File
@@ -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
View File
@@ -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",
+35 -8
View File
@@ -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)
+36 -103
View File
@@ -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([
+4
View File
@@ -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);
+18 -1
View File
@@ -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.
*
+49 -34
View File
@@ -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;
+140 -58
View File
@@ -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,
+15 -12
View File
@@ -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]);
+5 -38
View File
@@ -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');
+10 -12
View File
@@ -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()
+31 -21
View File
@@ -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;
}
+11 -6
View File
@@ -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;
`;
`;
+26
View File
@@ -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()]);
+2 -1
View File
@@ -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);
}
+120
View File
@@ -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),
}));
+166
View File
@@ -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),
}),
);
});
});
+74 -472
View File
@@ -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
+137
View File
@@ -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,
+31
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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;
+27
View File
@@ -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,
};
};
+24 -48
View File
@@ -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
+1 -1
View File
@@ -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(() => {
+116 -46
View File
@@ -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;
};
}
+8 -29
View File
@@ -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();
});
});
+9 -2
View File
@@ -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');
});
});
+9 -11
View File
@@ -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,
),
});
}
}}
+5 -2
View File
@@ -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;
},
[],
+2 -2
View File
@@ -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(),
});
+15 -17
View File
@@ -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);
+60 -70
View File
@@ -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,
+59
View File
@@ -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,
);
}
},
};
+2 -1
View File
@@ -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"
+1
View File
@@ -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';