diff --git a/App.tsx b/App.tsx
index 0fcfb58fe5..9800c978cb 100644
--- a/App.tsx
+++ b/App.tsx
@@ -18,6 +18,7 @@ import AppErrorBoundary, {
import Main from './src/navigators/Main';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { useInitDatabase } from '@database/db';
+import { ThemeProvider } from '@hooks/persisted/useTheme';
Notifications.setNotificationHandler({
handleNotification: async () => {
@@ -49,12 +50,14 @@ const App = () => {
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/__mocks__/database.js b/__mocks__/database.js
index f023116ec2..103783474c 100644
--- a/__mocks__/database.js
+++ b/__mocks__/database.js
@@ -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(),
}));
diff --git a/__tests-modules__/test-utils.tsx b/__tests-modules__/test-utils.tsx
index 9e16ced519..ce0305fa04 100644
--- a/__tests-modules__/test-utils.tsx
+++ b/__tests-modules__/test-utils.tsx
@@ -4,6 +4,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider as PaperProvider } from 'react-native-paper';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
+import { ThemeProvider } from '@hooks/persisted/useTheme';
import AppErrorBoundary from '@components/AppErrorBoundary/AppErrorBoundary';
import { NovelContextProvider } from '@screens/novel/NovelContext';
@@ -13,11 +14,13 @@ const AllTheProviders = ({ children }: { children: React.ReactElement }) => {
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
diff --git a/package.json b/package.json
index 7b6e98cc11..f5ca6e0fe1 100644
--- a/package.json
+++ b/package.json
@@ -113,12 +113,14 @@
"react-native-screens": "^4.24.0",
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-tab-view": "^4.3.0",
+ "react-native-theme-switch-animation": "^0.8.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-webview": "^13.16.1",
"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 +147,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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 01635ce04c..720c12ba6f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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)
@@ -200,6 +200,9 @@ importers:
react-native-tab-view:
specifier: ^4.3.0
version: 4.3.0(react-native-pager-view@8.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@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)
+ react-native-theme-switch-animation:
+ specifier: ^0.8.0
+ version: 0.8.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
react-native-url-polyfill:
specifier: ^3.0.0
version: 3.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))
@@ -218,6 +221,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 +298,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 +2416,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:
@@ -5357,6 +5363,12 @@ packages:
react-native: '*'
react-native-pager-view: '>= 6.0.0'
+ react-native-theme-switch-animation@0.8.0:
+ resolution: {integrity: sha512-z4f3QGSuUP4tagycls2mekng/7uxAbr75Gn0GGm7JRkrviyao++V2CtJ8VUDx+hSOsgfjEhD9D5JubsGbbHB5w==}
+ peerDependencies:
+ react: '*'
+ react-native: '*'
+
react-native-url-polyfill@3.0.0:
resolution: {integrity: sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==}
peerDependencies:
@@ -6320,6 +6332,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 +9219,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 +9766,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 +9774,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
@@ -12704,6 +12734,11 @@ snapshots:
react-native-pager-view: 8.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
use-latest-callback: 0.2.6(react@19.2.4)
+ react-native-theme-switch-animation@0.8.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4):
+ dependencies:
+ 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-native-url-polyfill@3.0.0(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)):
dependencies:
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)
@@ -13786,3 +13821,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)
diff --git a/src/components/BottomTabBar/index.tsx b/src/components/BottomTabBar/index.tsx
index 1cea197898..6a1467ffb9 100644
--- a/src/components/BottomTabBar/index.tsx
+++ b/src/components/BottomTabBar/index.tsx
@@ -5,6 +5,7 @@ import { Pressable, View, StyleSheet } from 'react-native';
import { Text } from 'react-native-paper';
import { ThemeColors } from '@theme/types';
import Animated from 'react-native-reanimated';
+import Color from 'color';
interface CustomBottomTabBarProps extends BottomTabBarProps {
theme: ThemeColors;
@@ -27,6 +28,7 @@ function CustomBottomTabBar({
showLabelsInNav,
renderIcon,
}: CustomBottomTabBarProps) {
+ const transparentBg = Color(theme.primaryContainer).fade(1).rgb().toString();
const getLabelText = useCallback(
(route: any) => {
if (!showLabelsInNav && route.name !== state.routeNames[state.index]) {
@@ -103,7 +105,7 @@ function CustomBottomTabBar({
width: isFocused ? 64 : 32,
backgroundColor: isFocused
? theme.primaryContainer
- : 'transparent',
+ : transparentBg,
},
]}
>
diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx
index a59c7d1cc5..51723c3441 100644
--- a/src/components/ColorPickerModal/ColorPickerModal.tsx
+++ b/src/components/ColorPickerModal/ColorPickerModal.tsx
@@ -2,14 +2,16 @@ import React, { useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { Portal, TextInput } from 'react-native-paper';
-import { Modal } from '@components';
+import { Button, Modal } from '@components';
import { ThemeColors } from '../../theme/types';
+import { Row } from '@components/Common';
+import { getString } from '@strings/translations';
interface ColorPickerModalProps {
visible: boolean;
title: string;
color: string;
- onSubmit: (val: string) => void;
+ onSubmit: (val: string | undefined) => void;
closeModal: () => void;
theme: ThemeColors;
showAccentColors?: boolean;
@@ -47,6 +49,10 @@ const ColorPickerModal: React.FC = ({
setError('Enter a valid hex color code');
}
};
+ const onReset = () => {
+ onSubmit(undefined);
+ closeModal();
+ };
const accentColors = [
'#EF5350',
@@ -112,6 +118,10 @@ const ColorPickerModal: React.FC = ({
error={Boolean(error)}
/>
{error}
+
+
+
+
);
@@ -139,4 +149,7 @@ const styles = StyleSheet.create({
},
flex: { flex: 1 },
marginBottom: { marginBottom: 8 },
+ row: {
+ justifyContent: 'flex-end',
+ },
});
diff --git a/src/components/List/List.tsx b/src/components/List/List.tsx
index 6b0c8bf7b8..8e8b13c095 100644
--- a/src/components/List/List.tsx
+++ b/src/components/List/List.tsx
@@ -11,6 +11,7 @@ import MaterialIcon from '@react-native-vector-icons/material-design-icons';
import { List as PaperList, Divider as PaperDivider } from 'react-native-paper';
import { ThemeColors } from '../../theme/types';
+import { ColorInstance } from 'color';
interface ListItemProps {
title: string;
@@ -126,12 +127,12 @@ const Icon = ({ icon, theme }: { icon: string; theme: ThemeColors }) => (
interface ColorItemProps {
title: string;
- description: string;
+ color: ColorInstance;
theme: ThemeColors;
onPress: () => void;
}
-const ColorItem = ({ title, description, theme, onPress }: ColorItemProps) => (
+const ColorItem = ({ title, color, theme, onPress }: ColorItemProps) => (
(
{title}
- {description}
+
+ {color.rgb().toString().toUpperCase()}
+
{
export interface SegmentedControlProps {
options: SegmentedControlOption[];
value: T;
- onChange: (value: T) => void;
+ onChange: (value: T, event: GestureResponderEvent) => void;
theme: ThemeColors;
showCheckIcon?: boolean;
}
@@ -52,7 +58,7 @@ export function SegmentedControl({
onChange(option.value)}
+ onPress={e => onChange(option.value, e)}
android_ripple={{
color: theme.rippleColor,
borderless: false,
diff --git a/src/components/ThemePicker/ThemePicker.tsx b/src/components/ThemePicker/ThemePicker.tsx
index 82cfaf8966..8a64a5c4f5 100644
--- a/src/components/ThemePicker/ThemePicker.tsx
+++ b/src/components/ThemePicker/ThemePicker.tsx
@@ -1,5 +1,11 @@
import React from 'react';
-import { View, Text, StyleSheet, Pressable } from 'react-native';
+import {
+ View,
+ Text,
+ StyleSheet,
+ Pressable,
+ GestureResponderEvent,
+} from 'react-native';
import { overlay } from 'react-native-paper';
import color from 'color';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
@@ -8,8 +14,9 @@ import { ThemeColors } from '@theme/types';
interface ThemePickerProps {
theme: ThemeColors;
currentTheme: ThemeColors;
- onPress: () => void;
+ onPress: (event: GestureResponderEvent) => void;
horizontal?: boolean;
+ isDark?: boolean;
}
export const ThemePicker = ({
@@ -17,119 +24,124 @@ export const ThemePicker = ({
currentTheme,
onPress,
horizontal = false,
-}: ThemePickerProps) => (
-
-
-
- {currentTheme.id === theme.id ? (
-
- ) : null}
-
-
-
-
+}: ThemePickerProps) => {
+ return (
+
+
+
+ {currentTheme.id !== theme.id ? null : (
+
+ )}
-
-
+ >
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
+ {theme.name}
+
-
- {theme.name}
-
-
-);
+ );
+};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
paddingBottom: 8,
- width: '33%',
},
horizontalContainer: {
width: undefined,
marginHorizontal: 4,
+ paddingBottom: 0,
},
card: {
borderWidth: 3.6,
@@ -143,7 +155,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.2,
shadowRadius: 4,
// Elevation for Android
- elevation: 2,
+ //elevation: 2,
},
flex1: {
flex: 1,
diff --git a/src/database/__tests__/db.test.ts b/src/database/__tests__/db.test.ts
index f54c9eac26..51426058a5 100644
--- a/src/database/__tests__/db.test.ts
+++ b/src/database/__tests__/db.test.ts
@@ -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),
- ),
- },
- };
- },
- 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([
diff --git a/src/database/db.ts b/src/database/db.ts
index cd55a1527f..b2cc4faaff 100644
--- a/src/database/db.ts
+++ b/src/database/db.ts
@@ -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);
diff --git a/src/database/manager/manager.d.ts b/src/database/manager/manager.d.ts
index 4e5eda3319..195bab40bb 100644
--- a/src/database/manager/manager.d.ts
+++ b/src/database/manager/manager.d.ts
@@ -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>(
+ data: T[],
+ fn: (
+ tx: TransactionParameter,
+ ph: (arg: Extract) => Placeholder,
+ ) => SQLitePreparedQuery,
+ ): Promise;
+
/**
* Creates a subquery that defines a temporary named result set as a CTE.
*
diff --git a/src/database/manager/manager.ts b/src/database/manager/manager.ts
index 1d8667ad07..e6d9a7d5a9 100644
--- a/src/database/manager/manager.ts
+++ b/src/database/manager/manager.ts
@@ -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 {
get(): Promise;
}
-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> {
const { sql: sqlString, params } = query.toSQL();
- return db.executeSync(sqlString, params as any[]).rows[0] as Awaited<
- ReturnType
- >;
+ return this.db.$client.executeSync(sqlString, params as any[])
+ .rows[0] as Awaited>;
}
- public async allSync(
+ public allSync(
query: T,
- ): Promise>> {
+ ): Awaited> {
const { sql: sqlString, params } = query.toSQL();
- return db.executeSync(sqlString, params as any[]).rows as Awaited<
- ReturnType
- >;
+ return this.db.$client.executeSync(sqlString, params as any[])
+ .rows as Awaited>;
}
- /**
- * 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>(
data: T[],
fn: (
@@ -101,12 +96,27 @@ class DbManager implements IDbManager {
ph: (arg: Extract) => Placeholder,
) => SQLitePreparedQuery,
) {
+ if (!data.length) {
+ return;
+ }
+
const ph = (arg: Extract) => 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(
query: T,
fireOn: FireOn,
+ callback?: (data: Awaited>) => void,
) {
type ReturnValue = Awaited>;
const { sql: sqlString, params } = query.toSQL();
const paramsKey = JSON.stringify(params);
const fireOnKey = JSON.stringify(fireOn);
+ const cb = useRef(callback ?? (() => {}));
- const [data, setData] = useState(
- () => db.executeSync(sqlString, params as any[]).rows as ReturnValue,
- );
+ const [data, setData] = useState(() => {
+ 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(
fireOn,
callback: (result: { rows: ReturnValue }) => {
setData(result.rows);
+ cb.current(result.rows);
},
});
return unsub;
diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts
index 981ad26bf4..d3224b7a2e 100644
--- a/src/database/queries/ChapterQueries.ts
+++ b/src/database/queries/ChapterQueries.ts
@@ -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 => {
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 => {
// #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 =>
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 =>
@@ -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 =>
@@ -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 =>
@@ -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,
diff --git a/src/database/queries/NovelQueries.ts b/src/database/queries/NovelQueries.ts
index 52dbad1687..45a0fdd85a 100644
--- a/src/database/queries/NovelQueries.ts
+++ b/src/database/queries/NovelQueries.ts
@@ -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 => {
return dbManager.select().from(novelSchema).all();
};
-export const getNovelById = async (
- novelId: number,
-): Promise => {
- 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) {
diff --git a/src/database/queries/__tests__/ChapterQueries.test.ts b/src/database/queries/__tests__/ChapterQueries.test.ts
index 005d4c001e..216bcfe4cd 100644
--- a/src/database/queries/__tests__/ChapterQueries.test.ts
+++ b/src/database/queries/__tests__/ChapterQueries.test.ts
@@ -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', () => {
diff --git a/src/database/queries/__tests__/LibraryQueries.test.ts b/src/database/queries/__tests__/LibraryQueries.test.ts
index 3f72e3f8f2..2fa1474048 100644
--- a/src/database/queries/__tests__/LibraryQueries.test.ts
+++ b/src/database/queries/__tests__/LibraryQueries.test.ts
@@ -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);
diff --git a/src/database/queries/__tests__/NovelQueries.test.ts b/src/database/queries/__tests__/NovelQueries.test.ts
index c1c3955be2..7c7aa8efe5 100644
--- a/src/database/queries/__tests__/NovelQueries.test.ts
+++ b/src/database/queries/__tests__/NovelQueries.test.ts
@@ -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]);
diff --git a/src/database/queries/__tests__/setup.ts b/src/database/queries/__tests__/setup.ts
index 4e114c11a3..2a6e7d9c9c 100644
--- a/src/database/queries/__tests__/setup.ts
+++ b/src/database/queries/__tests__/setup.ts
@@ -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 = {
- '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');
diff --git a/src/database/queries/__tests__/testData.ts b/src/database/queries/__tests__/testData.ts
index 4df447e076..77367dd9db 100644
--- a/src/database/queries/__tests__/testData.ts
+++ b/src/database/queries/__tests__/testData.ts
@@ -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()
diff --git a/src/database/queries/__tests__/testDb.ts b/src/database/queries/__tests__/testDb.ts
index cf70e2efe3..de12977848 100644
--- a/src/database/queries/__tests__/testDb.ts
+++ b/src/database/queries/__tests__/testDb.ts
@@ -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) {
testDb.sqlite.close();
+ __resetDbManagerForTests();
}
/**
diff --git a/src/database/queries/__tests__/testDbManager.ts b/src/database/queries/__tests__/testDbManager.ts
deleted file mode 100644
index 15495201a3..0000000000
--- a/src/database/queries/__tests__/testDbManager.ts
+++ /dev/null
@@ -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 {
- toSQL(): { sql: string; params: unknown[] };
- get(): Promise;
- all(): Promise;
-}
-
-type DrizzleDb = BetterSQLite3Database;
-type TransactionParameter = Parameters<
- Parameters[0]
->[0];
-
-const isBuilderLike = (value: unknown): value is Record => {
- return (
- !!value &&
- typeof value === 'object' &&
- typeof (value as { get?: unknown }).get === 'function' &&
- typeof (value as { all?: unknown }).all === 'function'
- );
-};
-
-const wrapBuilder = (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) =>
- wrapBuilder(drizzleDb.select(...args)),
- selectDistinct: (...args: Parameters) =>
- wrapBuilder(drizzleDb.selectDistinct(...args)),
- $count: drizzleDb.$count.bind(drizzleDb),
- query: drizzleDb.query,
- run: drizzleDb.run.bind(drizzleDb),
- with: (...args: Parameters) =>
- wrapBuilder(drizzleDb.with(...args)),
- $with: (...args: Parameters) =>
- wrapBuilder(drizzleDb.$with(...args)),
- all: (...args: Parameters) =>
- Promise.resolve(drizzleDb.all(...args)),
- get: (...args: Parameters) =>
- Promise.resolve(drizzleDb.get(...args)),
- values: (...args: Parameters) =>
- Promise.resolve(drizzleDb.values(...args)),
-
- // Test-compatible implementations of op-sqlite specific methods
- getSync(
- query: T,
- ): Awaited> {
- const { sql, params } = query.toSQL();
- const stmt = sqlite.prepare(sql);
- const result = stmt.get(params as any[]) as Awaited>;
- return result;
- },
-
- async allSync(
- query: T,
- ): Promise>> {
- const { sql, params } = query.toSQL();
- const stmt = sqlite.prepare(sql);
- const results = stmt.all(params as any[]) as Awaited<
- ReturnType
- >;
- return results;
- },
-
- async batch>(
- data: T[],
- fn: (
- tx: TransactionParameter,
- ph: (arg: Extract) => Placeholder,
- ) => SQLitePreparedQuery,
- ) {
- const ph = (arg: Extract) => 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(fn: (tx: TransactionParameter) => Promise): Promise {
- const result = await fn(drizzleDb as any);
-
- return result;
- },
- async transaction(
- fn: (tx: TransactionParameter) => Promise,
- ): Promise {
- return await this.write(fn);
- },
- };
-
- return dbManager;
-}
diff --git a/src/database/queryStrings/triggers.ts b/src/database/queryStrings/triggers.ts
index f93e4caa12..3f7c3096f1 100644
--- a/src/database/queryStrings/triggers.ts
+++ b/src/database/queryStrings/triggers.ts
@@ -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;
-`;
\ No newline at end of file
+`;
diff --git a/src/database/utils/filter.ts b/src/database/utils/filter.ts
index 759dbb762c..87c5aa4a2b 100644
--- a/src/database/utils/filter.ts
+++ b/src/database/utils/filter.ts
@@ -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()]);
diff --git a/src/database/utils/parser.ts b/src/database/utils/parser.ts
index 4f27df2cf7..3a5c7f4bbd 100644
--- a/src/database/utils/parser.ts
+++ b/src/database/utils/parser.ts
@@ -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);
}
diff --git a/src/hooks/__tests__/mocks.ts b/src/hooks/__tests__/mocks.ts
index cf1223e813..457f0a56d3 100644
--- a/src/hooks/__tests__/mocks.ts
+++ b/src/hooks/__tests__/mocks.ts
@@ -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>();
+
+ return {
+ read: jest.fn((chapterId: number) => cache.get(chapterId)),
+ write: jest.fn((chapterId: number, value: string | Promise) => {
+ cache.set(chapterId, value);
+ }),
+ remove: jest.fn((chapterId: number) => cache.delete(chapterId)),
+ clear: jest.fn(() => cache.clear()),
+ };
+};
+
+export const createMockNovelStoreState = (
+ overrides: Record = {},
+) => ({
+ 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 = {},
+) => {
+ 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 & {
+ actions?: Record;
+ };
+ 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),
+}));
diff --git a/src/hooks/__tests__/mocksContract.test.ts b/src/hooks/__tests__/mocksContract.test.ts
new file mode 100644
index 0000000000..76bebe18a8
--- /dev/null
+++ b/src/hooks/__tests__/mocksContract.test.ts
@@ -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;
+
+ 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;
+
+ 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),
+ }),
+ );
+ });
+});
diff --git a/src/hooks/__tests__/useNovel.test.ts b/src/hooks/__tests__/useNovel.test.ts
index 071b366a27..71e0af6af2 100644
--- a/src/hooks/__tests__/useNovel.test.ts
+++ b/src/hooks/__tests__/useNovel.test.ts
@@ -1,492 +1,96 @@
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';
-
-// --- 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,
-});
-
-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);
+import { TRACKED_NOVEL_PREFIX } from '@hooks/persisted/useTrackedNovel';
+import {
+ keyContract,
+ novelPersistence,
+} from '@hooks/persisted/useNovel/store-helper/contracts';
+
+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.',
+ );
});
- return utils;
-}
+});
-// --- tests ---
+describe('deleteCachedNovels', () => {
+ const cachedNovels = [
+ { id: 10, pluginId: 'p1', path: '/n/1', name: 'N1', inLibrary: false },
+ { id: 11, pluginId: 'p2', path: '/n/2', name: 'N2', inLibrary: false },
+ ];
-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));
- });
+ (_getCachedNovels as jest.Mock).mockResolvedValue(cachedNovels);
+ (NativeFile.exists as jest.Mock).mockReturnValue(false);
});
- // #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,
+ 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,
);
- });
- });
-
- // #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),
+ MMKVStorage.set(
+ keyContract.settings({
+ pluginId: novel.pluginId,
+ novelPath: novel.path,
+ }),
+ JSON.stringify({ filter: [], showChapterTitles: true }),
);
- });
-
- 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);
+ MMKVStorage.set(
+ keyContract.lastRead({
+ pluginId: novel.pluginId,
+ novelPath: novel.path,
}),
+ JSON.stringify({ id: 1 }),
);
- });
- });
-
- // #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 deleteCachedNovels();
- await waitFor(() =>
- expect(result.current.novel?.inLibrary).toBe(!before),
+ for (const novel of cachedNovels) {
+ expect(MMKVStorage.contains(`${TRACKED_NOVEL_PREFIX}_${novel.id}`)).toBe(
+ false,
);
- });
- });
-
- // #endregion
-});
-
-// #region deleteCachedNovels
-
-describe('deleteCachedNovels', () => {
- const cachedNovels: NovelInfo[] = [
- { 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();
- (_getCachedNovels as jest.Mock).mockResolvedValue(cachedNovels);
- (NativeFile.exists as jest.Mock).mockReturnValue(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 () => {
@@ -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
diff --git a/src/hooks/__tests__/useNovelStore.test.ts b/src/hooks/__tests__/useNovelStore.test.ts
new file mode 100644
index 0000000000..a98c4b9dfa
--- /dev/null
+++ b/src/hooks/__tests__/useNovelStore.test.ts
@@ -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 = {},
+ 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);
+ });
+});
diff --git a/src/hooks/persisted/__mocks__/useCategories.ts b/src/hooks/persisted/__mocks__/useCategories.ts
index 831ea5e922..212b068cc3 100644
--- a/src/hooks/persisted/__mocks__/useCategories.ts
+++ b/src/hooks/persisted/__mocks__/useCategories.ts
@@ -1,4 +1,4 @@
-const mockCategories = [];
+const mockCategories: string[] = [];
const useCategories = jest.fn(() => ({
isLoading: false,
diff --git a/src/hooks/persisted/__mocks__/useNovel.ts b/src/hooks/persisted/__mocks__/useNovel.ts
index 5e73e7fb0d..5fd9ab03b2 100644
--- a/src/hooks/persisted/__mocks__/useNovel.ts
+++ b/src/hooks/persisted/__mocks__/useNovel.ts
@@ -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;
+
+const createChapterTextCache = () => {
+ const cache = new Map();
+
+ 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(),
diff --git a/src/hooks/persisted/__mocks__/useNovelSettings.ts b/src/hooks/persisted/__mocks__/useNovelSettings.ts
index b4d6d08310..78fe6f234a 100644
--- a/src/hooks/persisted/__mocks__/useNovelSettings.ts
+++ b/src/hooks/persisted/__mocks__/useNovelSettings.ts
@@ -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,
diff --git a/src/hooks/persisted/__mocks__/useTheme.ts b/src/hooks/persisted/__mocks__/useTheme.ts
index daf8881331..45f9676ad7 100644
--- a/src/hooks/persisted/__mocks__/useTheme.ts
+++ b/src/hooks/persisted/__mocks__/useTheme.ts
@@ -1,3 +1,5 @@
+import type { PropsWithChildren } from 'react';
+
const mockTheme = {
primary: '#6200ee',
onPrimary: '#ffffff',
@@ -26,5 +28,7 @@ const mockTheme = {
const useTheme = jest.fn(() => mockTheme);
+const ThemeProvider = ({ children }: PropsWithChildren) => children as any;
+
export default useTheme;
-export { mockTheme };
+export { mockTheme, ThemeProvider, useTheme };
diff --git a/src/hooks/persisted/index.ts b/src/hooks/persisted/index.ts
index 588f5799ec..00cd61ceb9 100644
--- a/src/hooks/persisted/index.ts
+++ b/src/hooks/persisted/index.ts
@@ -1,4 +1,5 @@
export { useTheme } from './useTheme';
+export { ThemeProvider } from './useTheme';
export { useUpdates, useLastUpdate } from './useUpdates';
export { default as useCategories } from './useCategories';
export { default as useHistory } from './useHistory';
@@ -12,6 +13,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';
diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts
index eee13ca6b3..dd4c3cd0b7 100644
--- a/src/hooks/persisted/useNovel.ts
+++ b/src/hooks/persisted/useNovel.ts
@@ -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(
- typeof novelOrPath === 'object' ? novelOrPath : undefined,
- );
- const [pages, setPages] = useState(() => {
- 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(
- `${LAST_READ_PREFIX}_${pluginId}_${novelPath}`,
- );
- const [novelSettings = defaultNovelSettings, _setNovelSettings] =
- useMMKVObject(
- `${NOVEL_SETTINGS_PREFIX}_${pluginId}_${novelPath}`,
- );
-
- const [chapters, _setChapters] = useState([]);
- 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) => {
- 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
diff --git a/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts b/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts
new file mode 100644
index 0000000000..8a9dec67a5
--- /dev/null
+++ b/src/hooks/persisted/useNovel/__tests__/bootstrapService.test.ts
@@ -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 = {}) => ({
+ 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;
+const mockInsertNovelAndChapters =
+ insertNovelAndChapters as jest.MockedFunction;
+const mockGetChapterCount = getChapterCount as jest.MockedFunction<
+ typeof getChapterCount
+>;
+const mockGetChapterCountSync = getChapterCountSync as jest.MockedFunction<
+ typeof getChapterCountSync
+>;
+const mockGetPageChaptersBatched =
+ getPageChaptersBatched as jest.MockedFunction;
+const mockGetNovelChaptersSync = getNovelChaptersSync as jest.MockedFunction<
+ typeof getNovelChaptersSync
+>;
+const mockFetchPage = fetchPage as jest.MockedFunction;
+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);
+ 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);
+ });
+});
diff --git a/src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts b/src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts
new file mode 100644
index 0000000000..f6ee01d900
--- /dev/null
+++ b/src/hooks/persisted/useNovel/__tests__/chapterActions.test.ts
@@ -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 = {}) => ({
+ 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 => ({
+ 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,
+ Parameters
+ >()
+ .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 }),
+ ]);
+ });
+});
diff --git a/src/hooks/persisted/useNovel/__tests__/keyContract.test.ts b/src/hooks/persisted/useNovel/__tests__/keyContract.test.ts
new file mode 100644
index 0000000000..c97ea8a339
--- /dev/null
+++ b/src/hooks/persisted/useNovel/__tests__/keyContract.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts
new file mode 100644
index 0000000000..2fbf68e6ad
--- /dev/null
+++ b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterActions.test.ts
@@ -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,
+ 'getNextChapterBatch' | 'loadUpToBatch'
+>;
+
+interface TestState {
+ novel: NovelInfo | undefined;
+ pages: string[];
+ pageIndex: number;
+ chapters: ChapterInfo[];
+ chapterTextCache: Record>;
+ fetching: boolean;
+ novelSettings: NovelSettingsWithoutSort;
+ batchInformation: BatchInfo;
+}
+
+const makeChapter = (id: number, overrides: Partial = {}) => ({
+ 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 => ({
+ 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,
+ Parameters
+ >()
+ .mockImplementation(stringKey => String(stringKey)),
+});
+
+const createHarness = (overrides: Partial = {}) => {
+ 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 | ((s: TestState) => Partial)) => {
+ const patch = typeof partial === 'function' ? partial(state) : partial;
+ state = { ...state, ...patch };
+ },
+ );
+ const get = () => state;
+ const bootstrapService: jest.Mocked = {
+ 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
+ ).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,
+ );
+ });
+});
diff --git a/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts
new file mode 100644
index 0000000000..b202c00525
--- /dev/null
+++ b/src/hooks/persisted/useNovel/__tests__/novelStore.chapterState.test.ts
@@ -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);
+ });
+});
diff --git a/src/hooks/persisted/useNovel/__tests__/persistence.test.ts b/src/hooks/persisted/useNovel/__tests__/persistence.test.ts
new file mode 100644
index 0000000000..1d27cb608e
--- /dev/null
+++ b/src/hooks/persisted/useNovel/__tests__/persistence.test.ts
@@ -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();
+ const strings = new Map();
+
+ 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');
+ });
+});
diff --git a/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts b/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts
new file mode 100644
index 0000000000..1f2ef83d7d
--- /dev/null
+++ b/src/hooks/persisted/useNovel/__tests__/useNovelSettings.test.ts
@@ -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();
+ });
+});
diff --git a/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts b/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts
new file mode 100644
index 0000000000..f8a705eed3
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store-helper/bootstrapService.ts
@@ -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>();
+
+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 = {},
+) => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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();
diff --git a/src/hooks/persisted/useNovel/store-helper/contracts.ts b/src/hooks/persisted/useNovel/store-helper/contracts.ts
new file mode 100644
index 0000000000..240037264b
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store-helper/contracts.ts
@@ -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';
diff --git a/src/hooks/persisted/useNovel/store-helper/keyContract.ts b/src/hooks/persisted/useNovel/store-helper/keyContract.ts
new file mode 100644
index 0000000000..90222f250b
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store-helper/keyContract.ts
@@ -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}`;
+ },
+};
diff --git a/src/hooks/persisted/useNovel/store-helper/persistence.ts b/src/hooks/persisted/useNovel/store-helper/persistence.ts
new file mode 100644
index 0000000000..0c1a409152
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store-helper/persistence.ts
@@ -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 => {
+ 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,
+};
diff --git a/src/hooks/persisted/useNovel/store/chapterActions.ts b/src/hooks/persisted/useNovel/store/chapterActions.ts
new file mode 100644
index 0000000000..d2acaa137f
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store/chapterActions.ts
@@ -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;
+ markChapterRead: (chapterId: number) => Promise;
+ markChaptersRead: (chapterIds: number[]) => Promise;
+ markPreviuschaptersRead: (
+ chapterId: number,
+ novelId: number,
+ ) => Promise;
+ markPreviousChaptersUnread: (
+ chapterId: number,
+ novelId: number,
+ ) => Promise;
+ markChaptersUnread: (chapterIds: number[]) => Promise;
+ updateChapterProgress: (chapterId: number, progress: number) => Promise;
+ deleteChapter: (
+ pluginId: string,
+ novelId: number,
+ chapterId: number,
+ ) => Promise;
+ deleteChapters: (
+ pluginId: string,
+ novelId: number,
+ chapters?: ChapterInfo[],
+ ) => Promise;
+ getPageChapters: (
+ novelId: number,
+ sort?: ChapterOrderKey,
+ filter?: ChapterFilterKey[],
+ page?: string,
+ ) => Promise;
+ 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,
+ 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,
+ );
+ }
+};
diff --git a/src/hooks/persisted/useNovel/store/createStore.ts b/src/hooks/persisted/useNovel/store/createStore.ts
new file mode 100644
index 0000000000..d7942bae0d
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store/createStore.ts
@@ -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;
+}
+
+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()((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;
+}
diff --git a/src/hooks/persisted/useNovel/store/novelStore.actions.ts b/src/hooks/persisted/useNovel/store/novelStore.actions.ts
new file mode 100644
index 0000000000..22bb3f0f71
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store/novelStore.actions.ts
@@ -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 | 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,
+ },
+ };
+ });
+ },
+ };
+};
diff --git a/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts b/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts
new file mode 100644
index 0000000000..10599c64ee
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store/novelStore.chapterActions.ts
@@ -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,
+ 'getNextChapterBatch' | 'loadUpToBatch'
+ >;
+ chapterActionsDependencies: ChapterActionsDependencies;
+ transformChapters: (chs: ChapterInfo[]) => ChapterInfo[];
+ defaultChapterSort: ChapterOrderKey;
+}
+
+export const createNovelStoreChapterActions = ({
+ set,
+ get,
+ bootstrapService,
+ chapterActionsDependencies,
+ transformChapters,
+ defaultChapterSort,
+}: CreateNovelStoreChapterActionsParams): NovelStoreChapterActions => {
+ let inflightNextChapterBatch: Promise | null = null;
+ let inflightLoadUpToBatch: Promise | 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 => {
+ 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,
+ });
+ },
+ };
+};
diff --git a/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts b/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts
new file mode 100644
index 0000000000..c87764d55f
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store/novelStore.chapterState.ts
@@ -0,0 +1,11 @@
+import { ChapterSliceState } from './novelStore.types';
+
+export const createInitialChapterSlice = (): ChapterSliceState => ({
+ chapters: [],
+ firstUnreadChapter: undefined,
+ chapterTextCache: {},
+ batchInformation: {
+ batch: 0,
+ total: 0,
+ },
+});
diff --git a/src/hooks/persisted/useNovel/store/novelStore.ts b/src/hooks/persisted/useNovel/store/novelStore.ts
new file mode 100644
index 0000000000..7e86663484
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store/novelStore.ts
@@ -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,
+ };
+};
diff --git a/src/hooks/persisted/useNovel/store/novelStore.types.ts b/src/hooks/persisted/useNovel/store/novelStore.types.ts
new file mode 100644
index 0000000000..349c78aec2
--- /dev/null
+++ b/src/hooks/persisted/useNovel/store/novelStore.types.ts
@@ -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;
+
+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;
+}
+
+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;
+ loadUpToBatch: (targetBatch: number) => Promise;
+ updateChapter: (index: number, update: Partial) => 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;
+ bootstrapNovelSync: () => boolean;
+ getChapters: () => Promise;
+ refreshNovel: () => Promise;
+
+ setNovel: (novel: NovelInfo | undefined) => void;
+ setPages: (pages: string[]) => void;
+ setPageIndex: (index: number) => void;
+ openPage: (index: number) => Promise;
+ setNovelSettings: (settings: NovelSettings) => void;
+ setLastRead: (chapter: ChapterInfo) => void;
+ followNovel: () => Promise;
+}
+
+export type NovelStoreActions = NovelStoreNovelActions &
+ NovelStoreChapterActions;
+
+export interface NovelStoreState extends NovelStoreData {
+ actions: NovelStoreActions;
+}
+
+export type NovelStoreApi = StoreApi;
+
+export interface NovelStoreDependencies {
+ bootstrapService: ReturnType;
+ chapterActionsDependencies: ChapterActionsDependencies;
+ transformChapters: (chs: ChapterInfo[]) => ChapterInfo[];
+ persistPageIndex?: (value: number) => void;
+ persistNovelSettings?: (value: NovelSettings) => void;
+ persistLastRead?: (value: ChapterInfo) => void;
+ switchNovelToLibrary?: (novelPath: string, pluginId: string) => Promise;
+}
+
+export type SetState = {
+ (
+ partial:
+ | NovelStoreState
+ | Partial
+ | ((
+ state: NovelStoreState,
+ ) => NovelStoreState | Partial),
+ replace?: false,
+ ): void;
+ (
+ state: NovelStoreState | ((state: NovelStoreState) => NovelStoreState),
+ replace: true,
+ ): void;
+};
+
+export type GetState = () => NovelStoreState;
diff --git a/src/hooks/persisted/useNovel/types.ts b/src/hooks/persisted/useNovel/types.ts
new file mode 100644
index 0000000000..48815857db
--- /dev/null
+++ b/src/hooks/persisted/useNovel/types.ts
@@ -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;
+}
diff --git a/src/hooks/persisted/useNovel/useChapterOperations.ts b/src/hooks/persisted/useNovel/useChapterOperations.ts
new file mode 100644
index 0000000000..63d913c72d
--- /dev/null
+++ b/src/hooks/persisted/useNovel/useChapterOperations.ts
@@ -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>;
+ 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) => {
+ 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,
+ };
+};
diff --git a/src/hooks/persisted/useNovelSettings.ts b/src/hooks/persisted/useNovelSettings.ts
index d3847f2425..0eee5d6a4b 100644
--- a/src/hooks/persisted/useNovelSettings.ts
+++ b/src/hooks/persisted/useNovelSettings.ts
@@ -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';
-
-// #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;
-}
+import {
+ defaultNovelSettings,
+ NOVEL_PAGE_INDEX_PREFIX,
+ NOVEL_SETTINGS_PREFIX,
+} from './useNovel/types';
+import { useNovelAction, useNovelValue } from '@screens/novel/NovelContext';
-// #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(
- `${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(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(
+ 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
diff --git a/src/hooks/persisted/useTheme.ts b/src/hooks/persisted/useTheme.ts
index 13e7d07078..26d24e31b7 100644
--- a/src/hooks/persisted/useTheme.ts
+++ b/src/hooks/persisted/useTheme.ts
@@ -1,4 +1,12 @@
-import { useEffect, useMemo, useState } from 'react';
+import {
+ createElement,
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ PropsWithChildren,
+} from 'react';
import { Appearance, ColorSchemeName } from 'react-native';
import {
useMMKVBoolean,
@@ -8,7 +16,6 @@ import {
import { overlay } from 'react-native-paper';
import Color from 'color';
-import { defaultTheme } from '@theme/md3/defaultTheme';
import { ThemeColors } from '@theme/types';
import { darkThemes, lightThemes } from '@theme/md3';
@@ -62,17 +69,46 @@ const findThemeById = (
isDark: boolean,
): ThemeColors => {
const themeList = isDark ? darkThemes : lightThemes;
-
+ let theme: ThemeColors | undefined;
if (themeId !== undefined) {
- const theme = themeList.find(t => t.id === themeId);
- if (theme) {
- return theme;
- }
+ const id = transformThemeId(themeId, isDark);
+ theme = themeList.find(t => t.id === id);
}
- return isDark ? defaultTheme.dark : defaultTheme.light;
+ return theme ?? themeList[0];
};
+// transforms legacy theme IDs to new IDs
+function transformThemeId(themeId: number, isDark: boolean): number {
+ if (themeId > 99) return themeId;
+ const lightIdMap: Record = {
+ '1': 100,
+ '8': 102,
+ '9': 108,
+ '10': 101,
+ '12': 103,
+ '14': 104,
+ '16': 105,
+ '18': 106,
+ '20': 107,
+ };
+ const darkIdMap: Record = {
+ '2': 100,
+ '9': 102,
+ '10': 108,
+ '11': 101,
+ '13': 103,
+ '15': 104,
+ '17': 105,
+ '19': 106,
+ '21': 107,
+ };
+ if (isDark) {
+ return darkIdMap[themeId] ?? themeId;
+ }
+ return lightIdMap[themeId] ?? themeId;
+}
+
const getBaseTheme = (
themeMode: string,
themeId: number | undefined,
@@ -88,14 +124,16 @@ const getBaseTheme = (
return findThemeById(themeId, isDark);
};
-export const useTheme = (): ThemeColors => {
+const ThemeContext = createContext(null);
+
+export const ThemeProvider = ({ children }: PropsWithChildren) => {
const [themeId] = useMMKVNumber('APP_THEME_ID');
const [themeMode = 'system'] = useMMKVString('THEME_MODE');
const [isAmoledBlack = false] = useMMKVBoolean('AMOLED_BLACK');
const [customAccent] = useMMKVString('CUSTOM_ACCENT_COLOR');
const [systemColorScheme, setSystemColorScheme] = useState(
- Appearance.getColorScheme(),
+ Appearance.getColorScheme() ?? 'unspecified',
);
useEffect(() => {
@@ -106,7 +144,7 @@ export const useTheme = (): ThemeColors => {
return () => subscription.remove();
}, []);
- const theme = useMemo(() => {
+ const theme = useMemo(() => {
const baseTheme = getBaseTheme(themeMode, themeId, systemColorScheme);
const withAmoled = applyAmoledBlack(baseTheme, isAmoledBlack);
const withAccent = applyCustomAccent(withAmoled, customAccent);
@@ -115,5 +153,16 @@ export const useTheme = (): ThemeColors => {
return finalTheme;
}, [themeId, themeMode, systemColorScheme, isAmoledBlack, customAccent]);
+ return createElement(ThemeContext.Provider, { value: theme }, children);
+};
+
+export const useTheme = (): ThemeColors => {
+ const theme = useContext(ThemeContext);
+ if (!theme) {
+ // eslint-disable-next-line no-console
+ console.error('useTheme must be used within a ');
+ return {} as ThemeColors;
+ }
+
return theme;
};
diff --git a/src/screens/novel/NovelContext.tsx b/src/screens/novel/NovelContext.tsx
index 2db08342a8..aca7b0c619 100644
--- a/src/screens/novel/NovelContext.tsx
+++ b/src/screens/novel/NovelContext.tsx
@@ -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 & {
+type Props = {
+ children: React.ReactNode;
+ route:
+ | RouteProp
+ | RouteProp;
+};
+
+type NovelLayout = {
navigationBarHeight: number;
statusBarHeight: number;
- chapterTextCache: Map>;
};
-const defaultValue = {} as NovelContextType;
+const NovelStoreContext = createContext(null);
+const NovelLayoutContext = createContext(null);
-const NovelContext = createContext(defaultValue);
+export function NovelContextProvider({ children, route }: Props) {
+ const initialNovel =
+ 'id' in route.params ? (route.params as NovelInfo) : undefined;
-export function NovelContextProvider({
- children,
-
- route,
-}: {
- children: React.JSX.Element;
-
- route:
- | RouteProp
- | RouteProp;
-}) {
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(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
);
};
@@ -88,22 +81,41 @@ export default function ThemeSelectionStep() {
[],
);
- const handleModeChange = (mode: ThemeMode) => {
+ const handleModeChange = (mode: ThemeMode, event: GestureResponderEvent) => {
setThemeMode(mode);
-
- if (mode !== 'system') {
- const themes = mode === 'dark' ? darkThemes : lightThemes;
- const currentThemeInMode = themes.find(t => t.id === theme.id);
-
- if (!currentThemeInMode) {
- setThemeId(themes[0].id);
- }
- }
+ event.currentTarget.measure((_x1, _y1, width, height, px, py) => {
+ switchTheme({
+ switchThemeFunction: () => {},
+ animationConfig: {
+ type: 'circular',
+ duration: 400,
+ startingPoint: {
+ cy: py + height / 2,
+ cx: px + width / 2,
+ },
+ },
+ });
+ });
};
- const handleThemeSelect = (selectedTheme: ThemeColors) => {
+ const handleThemeSelect = (
+ selectedTheme: ThemeColors,
+ event: GestureResponderEvent,
+ ) => {
setThemeId(selectedTheme.id);
- setThemeMode(selectedTheme.isDark ? 'dark' : 'light');
+ event.currentTarget.measure((_x1, _y1, width, height, px, py) => {
+ switchTheme({
+ switchThemeFunction: () => {},
+ animationConfig: {
+ type: 'circular',
+ duration: 400,
+ startingPoint: {
+ cy: py + height / 2,
+ cx: px + width / 2,
+ },
+ },
+ });
+ });
};
return (
@@ -117,24 +129,23 @@ export default function ThemeSelectionStep() {
theme={theme}
/>
-
{/* Theme List */}
-
- {availableThemes.map(item => (
-
+ data={availableThemes}
+ extraData={theme}
+ keyExtractor={item => 'theme-' + item.id}
+ renderItem={({ item }) => (
+
handleThemeSelect(item)}
+ onPress={e => handleThemeSelect(item, e)}
/>
- ))}
-
-
+ )}
+ />
{/* AMOLED Toggle */}
@@ -149,13 +160,6 @@ const styles = StyleSheet.create({
segmentedControlContainer: {
marginBottom: 24,
},
- themeScrollContent: {
- paddingVertical: 16,
- paddingHorizontal: 24,
- },
- themeItem: {
- marginHorizontal: 8,
- },
amoledContainer: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx b/src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx
new file mode 100644
index 0000000000..dac574bb31
--- /dev/null
+++ b/src/screens/reader/components/ChapterDrawer/__tests__/ChapterDrawer.test.tsx
@@ -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 = {}) => {
+ 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();
+
+ await waitFor(() => {
+ expect(store.state.openPage).toHaveBeenCalledWith(1);
+ });
+
+ fireEvent.press(screen.getByTestId('legend-end-reached'));
+
+ expect(store.state.getNextChapterBatch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/screens/reader/components/ChapterDrawer/index.tsx b/src/screens/reader/components/ChapterDrawer/index.tsx
index 7fd7e48b43..5969805b9a 100644
--- a/src/screens/reader/components/ChapterDrawer/index.tsx
+++ b/src/screens/reader/components/ChapterDrawer/index.tsx
@@ -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) {
diff --git a/src/screens/reader/components/ReaderAppbar.tsx b/src/screens/reader/components/ReaderAppbar.tsx
index 86a8ef2796..597f8a04e8 100644
--- a/src/screens/reader/components/ReaderAppbar.tsx
+++ b/src/screens/reader/components/ReaderAppbar.tsx
@@ -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';
diff --git a/src/screens/reader/components/ReaderFooter.tsx b/src/screens/reader/components/ReaderFooter.tsx
index 29bb1fc25b..0f810f1031 100644
--- a/src/screens/reader/components/ReaderFooter.tsx
+++ b/src/screens/reader/components/ReaderFooter.tsx
@@ -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;
@@ -37,7 +37,7 @@ const ChapterFooter = ({
borderless: true,
radius: 50,
};
- const { navigationBarHeight } = useNovelContext();
+ const { navigationBarHeight } = useNovelLayout();
const entering = () => {
'worklet';
diff --git a/src/screens/reader/hooks/__tests__/useChapter.test.ts b/src/screens/reader/hooks/__tests__/useChapter.test.ts
new file mode 100644
index 0000000000..38927285db
--- /dev/null
+++ b/src/screens/reader/hooks/__tests__/useChapter.test.ts
@@ -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 = () => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+
+ return { promise, resolve, reject };
+};
+
+const createStore = (
+ cacheSeed: Record> = {},
+) => {
+ const cache = new Map>(
+ 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) => {
+ 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();
+
+ 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');
+ });
+});
diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts
index 1ac5716e10..01c6ae5c50 100644
--- a/src/screens/reader/hooks/useChapter.ts
+++ b/src/screens/reader/hooks/useChapter.ts
@@ -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(
diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx
index b891db4dbf..59af99fe7b 100644
--- a/src/screens/settings/SettingsAdvancedScreen.tsx
+++ b/src/screens/settings/SettingsAdvancedScreen.tsx
@@ -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';
diff --git a/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx b/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx
index eaac82044a..be142f9afb 100644
--- a/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx
+++ b/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx
@@ -1,12 +1,13 @@
import React from 'react';
import { Dialog, Portal } from 'react-native-paper';
-import { ScrollView, StyleSheet, Text } from 'react-native';
+import { StyleSheet, Text } from 'react-native';
import { useTheme } from '@hooks/persisted';
import { Modal, RadioButton } from '@components';
import { getString, setLocale } from '@strings/translations';
import { useMMKVString } from 'react-native-mmkv';
+import { FlatList } from 'react-native-gesture-handler';
interface LanguagePickerModalProps {
visible: boolean;
@@ -84,22 +85,31 @@ const LanguagePickerModal: React.FC = ({
return (
-
- {getString('appearanceScreen.appLanguage')}
+
+
+ {getString('appearanceScreen.appLanguage')}
+
{getString('appearanceScreen.languagePickerModal.restartNote')}
-
- {languages.map(item => (
+ item.locale}
+ renderItem={({ item }) => (
handleLanguageSelect(item.locale)}
label={item.nativeName}
theme={theme}
+ style={styles.zeroPadding}
/>
- ))}
-
+ )}
+ />
);
@@ -111,6 +121,7 @@ const styles = StyleSheet.create({
noteText: {
lineHeight: 20,
marginBottom: 8,
- paddingHorizontal: 24,
},
+ zeroPadding: { paddingHorizontal: 0, marginHorizontal: 0, marginTop: 0 },
+ maxHeight: { maxHeight: '60%' },
});
diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx
index 65b8ca8cf2..3514f3ba61 100644
--- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx
+++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx
@@ -1,5 +1,11 @@
import React, { useMemo, useState } from 'react';
-import { ScrollView, Text, StyleSheet, View } from 'react-native';
+import {
+ ScrollView,
+ StyleSheet,
+ View,
+ Appearance,
+ GestureResponderEvent,
+} from 'react-native';
import { ThemePicker } from '@components/ThemePicker/ThemePicker';
import type { SegmentedControlOption } from '@components/SegmentedControl';
@@ -18,13 +24,18 @@ import { AppearanceSettingsScreenProps } from '@navigators/types';
import { getString } from '@strings/translations';
import { darkThemes, lightThemes } from '@theme/md3';
import { ThemeColors } from '@theme/types';
+import switchTheme from 'react-native-theme-switch-animation';
+import Color from 'color';
type ThemeMode = 'light' | 'dark' | 'system';
const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
const theme = useTheme();
const [, setThemeId] = useMMKVNumber('APP_THEME_ID');
- const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE');
+ const [themeMode = 'system', setThemeMode] = useMMKVString('THEME_MODE') as [
+ ThemeMode,
+ (mode: ThemeMode) => void,
+ ];
const [isAmoledBlack = false, setAmoledBlack] =
useMMKVBoolean('AMOLED_BLACK');
const [, setCustomAccentColor] = useMMKVString('CUSTOM_ACCENT_COLOR');
@@ -38,7 +49,13 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
setAppSettings,
} = useAppSettings();
- const currentMode = themeMode as ThemeMode;
+ const colorScheme = Appearance.getColorScheme() ?? 'light';
+ const actualThemeMode: Exclude =
+ themeMode !== 'system'
+ ? themeMode
+ : colorScheme === 'unspecified'
+ ? 'light'
+ : colorScheme;
/**
* Accent Color Modal
@@ -117,26 +134,63 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
[],
);
- const handleModeChange = (mode: ThemeMode) => {
- setThemeMode(mode);
+ // const handleModeChange = (mode: ThemeMode) => {
+ // setThemeMode(mode);
- if (mode !== 'system') {
- const themes = mode === 'dark' ? darkThemes : lightThemes;
- const currentThemeInMode = themes.find(t => t.id === theme.id);
+ // if (mode !== 'system') {
+ // const themes = mode === 'dark' ? darkThemes : lightThemes;
+ // const currentThemeInMode = themes.find(t => t.id === theme.id);
- if (!currentThemeInMode) {
- setThemeId(themes[0].id);
- }
- }
+ // if (!currentThemeInMode) {
+ // setThemeId(themes[0].id);
+ // }
+ // }
+ // };
+
+ // const handleThemeSelect = (selectedTheme: ThemeColors) => {
+ // setThemeId(selectedTheme.id);
+ // setCustomAccentColor(undefined);
+
+ // if (actualThemeMode !== 'system') {
+ // setThemeMode(selectedTheme.isDark ? 'dark' : 'light');
+ // }
+ // };
+
+ const handleModeChange = (mode: ThemeMode, event: GestureResponderEvent) => {
+ setThemeMode(mode);
+ event.currentTarget.measure((_x1, _y1, width, height, px, py) => {
+ switchTheme({
+ switchThemeFunction: () => {},
+ animationConfig: {
+ type: 'circular',
+ duration: 400,
+ startingPoint: {
+ cy: py + height / 2,
+ cx: px + width / 2,
+ },
+ },
+ });
+ });
};
- const handleThemeSelect = (selectedTheme: ThemeColors) => {
+ const handleThemeSelect = (
+ selectedTheme: ThemeColors,
+ event: GestureResponderEvent,
+ ) => {
setThemeId(selectedTheme.id);
- setCustomAccentColor(undefined);
-
- if (currentMode !== 'system') {
- setThemeMode(selectedTheme.isDark ? 'dark' : 'light');
- }
+ event.currentTarget.measure((_x1, _y1, width, height, px, py) => {
+ switchTheme({
+ switchThemeFunction: () => {},
+ animationConfig: {
+ type: 'circular',
+ duration: 400,
+ startingPoint: {
+ cy: py + height / 2,
+ cx: px + width / 2,
+ },
+ },
+ });
+ });
};
return (
@@ -159,52 +213,38 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
{/* Light Themes */}
-
+ {/*
{getString('appearanceScreen.lightTheme')}
-
-
- {lightThemes.map(item => (
- handleThemeSelect(item)}
- />
- ))}
-
-
- {/* Dark Themes */}
-
- {getString('appearanceScreen.darkTheme')}
-
-
- {darkThemes.map(item => (
- handleThemeSelect(item)}
- />
- ))}
-
-
+ */}
+
+
+ {(actualThemeMode === 'light' ? lightThemes : darkThemes).map(
+ item => (
+ handleThemeSelect(item, e)}
+ />
+ ),
+ )}
+
+
{theme.isDark ? (
{
) : null}
@@ -303,9 +343,16 @@ const styles = StyleSheet.create({
paddingVertical: 8,
},
themePickerRow: {
- paddingHorizontal: 16,
- paddingVertical: 8,
+ borderRadius: 24,
+ //marginHorizontal: 8,
+ paddingHorizontal: 4,
+ paddingTop: 8,
+ paddingBottom: 2,
flexDirection: 'row',
+ alignItems: 'center',
+ },
+ scrollViewContainer: {
+ paddingHorizontal: 8,
},
segmentedControlContainer: {
paddingHorizontal: 16,
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx
index 8eebbd8bb3..cb8daf68a7 100644
--- a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx
+++ b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx
@@ -47,17 +47,21 @@ const NavigationTab: React.FC = () => {
{useVolumeButtons && (
{
- if (text) {
+ if (!isNaN(Number(text))) {
setChapterGeneralSettings({
- volumeButtonsOffset: Number(text),
+ volumeButtonsOffset: Math.round(
+ Number(text) * screenHeight,
+ ),
});
}
}}
diff --git a/src/screens/updates/UpdatesScreen.tsx b/src/screens/updates/UpdatesScreen.tsx
index 3987ee20a8..da6d05f259 100644
--- a/src/screens/updates/UpdatesScreen.tsx
+++ b/src/screens/updates/UpdatesScreen.tsx
@@ -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;
},
[],
diff --git a/src/services/Trackers/myAnimeList.ts b/src/services/Trackers/myAnimeList.ts
index 3af314f195..77dd98e7bf 100644
--- a/src/services/Trackers/myAnimeList.ts
+++ b/src/services/Trackers/myAnimeList.ts
@@ -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(),
});
diff --git a/src/services/migrate/migrateNovel.ts b/src/services/migrate/migrateNovel.ts
index a28ff11d0e..b12513d254 100644
--- a/src/services/migrate/migrateNovel.ts
+++ b/src/services/migrate/migrateNovel.ts
@@ -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,
+ };
+
+ novelPersistence.copySettings(fromPersistenceInput, toPersistenceInput);
- const lastRead = getMMKVObject(
- `${LAST_READ_PREFIX}_${fromNovel.pluginId}_${fromNovel.path}`,
- );
+ 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);
diff --git a/src/services/updates/LibraryUpdateQueries.ts b/src/services/updates/LibraryUpdateQueries.ts
index ab841ab8ca..c336bfea5d 100644
--- a/src/services/updates/LibraryUpdateQueries.ts
+++ b/src/services/updates/LibraryUpdateQueries.ts
@@ -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,
diff --git a/src/theme/md3/catppuccin.ts b/src/theme/md3/catppuccin.ts
index d7ae348c00..4c530bcc80 100644
--- a/src/theme/md3/catppuccin.ts
+++ b/src/theme/md3/catppuccin.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const catppuccinTheme = {
light: {
- id: 20,
name: getString('appearanceScreen.theme.catppuccin'),
isDark: false,
primary: 'rgb(136, 57, 239)',
@@ -39,7 +38,6 @@ export const catppuccinTheme = {
backdrop: 'rgba(196, 200, 208, 0.4)',
},
dark: {
- id: 21,
name: getString('appearanceScreen.theme.catppuccin'),
isDark: true,
primary: 'rgb(203, 166, 247)',
diff --git a/src/theme/md3/defaultTheme.ts b/src/theme/md3/defaultTheme.ts
index 5fc18f6856..34f8657e7e 100644
--- a/src/theme/md3/defaultTheme.ts
+++ b/src/theme/md3/defaultTheme.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const defaultTheme = {
light: {
- id: 1,
name: getString('appearanceScreen.theme.default'),
isDark: false,
primary: 'rgb(0, 87, 206)',
@@ -39,7 +38,6 @@ export const defaultTheme = {
backdrop: 'rgba(46, 48, 56, 0.4)',
},
dark: {
- id: 2,
name: getString('appearanceScreen.theme.default'),
isDark: true,
primary: 'rgb(177, 197, 255)',
diff --git a/src/theme/md3/index.ts b/src/theme/md3/index.ts
index 044f09e097..b1378e34e3 100644
--- a/src/theme/md3/index.ts
+++ b/src/theme/md3/index.ts
@@ -8,6 +8,13 @@ import { takoTheme } from './tako';
import { catppuccinTheme } from './catppuccin';
import { yinyangTheme } from './yinyang';
+/**
+ * Exports for MD3 theme system
+ *
+ * IMPORTANT:
+ * IDs are auto-assigned, so new themes
+ * need to be added at the end of the list.
+ */
export const lightThemes = [
defaultTheme.light,
midnightDusk.light,
@@ -18,7 +25,7 @@ export const lightThemes = [
takoTheme.light,
catppuccinTheme.light,
yinyangTheme.light,
-];
+].map((theme, i) => ({ ...theme, id: 100 + i }));
export const darkThemes = [
defaultTheme.dark,
midnightDusk.dark,
@@ -29,4 +36,4 @@ export const darkThemes = [
takoTheme.dark,
catppuccinTheme.dark,
yinyangTheme.dark,
-];
+].map((theme, i) => ({ ...theme, id: 100 + i }));
diff --git a/src/theme/md3/lavender.ts b/src/theme/md3/lavender.ts
index d3515ad1c9..e08094c0dc 100644
--- a/src/theme/md3/lavender.ts
+++ b/src/theme/md3/lavender.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const lavenderTheme = {
light: {
- id: 14,
name: getString('appearanceScreen.theme.lavender'),
isDark: false,
primary: 'rgb(121, 68, 173)',
@@ -39,7 +38,6 @@ export const lavenderTheme = {
backdrop: 'rgba(52, 47, 55, 0.4)',
},
dark: {
- id: 15,
name: getString('appearanceScreen.theme.lavender'),
isDark: true,
primary: 'rgb(221, 184, 255)',
diff --git a/src/theme/md3/mignightDusk.ts b/src/theme/md3/mignightDusk.ts
index 8a47708c98..0af08b6c4c 100644
--- a/src/theme/md3/mignightDusk.ts
+++ b/src/theme/md3/mignightDusk.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const midnightDusk = {
light: {
- id: 10,
name: getString('appearanceScreen.theme.daybreakBloom'),
isDark: false,
primary: 'rgb(240, 36, 117)',
@@ -39,7 +38,6 @@ export const midnightDusk = {
backdrop: 'rgba(58, 45, 47, 0.4)',
},
dark: {
- id: 11,
name: getString('appearanceScreen.theme.midnightDusk'),
isDark: true,
primary: 'rgb(240, 36, 117)',
diff --git a/src/theme/md3/strawberry.ts b/src/theme/md3/strawberry.ts
index 612992179f..b617ecbf5e 100644
--- a/src/theme/md3/strawberry.ts
+++ b/src/theme/md3/strawberry.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const strawberryDaiquiriTheme = {
light: {
- id: 16,
name: getString('appearanceScreen.theme.strawberry'),
isDark: false,
primary: 'rgb(182, 30, 64)',
@@ -39,7 +38,6 @@ export const strawberryDaiquiriTheme = {
backdrop: 'rgba(59, 45, 46, 0.4)',
},
dark: {
- id: 17,
name: getString('appearanceScreen.theme.strawberry'),
isDark: true,
primary: 'rgb(255, 178, 184)',
diff --git a/src/theme/md3/tako.ts b/src/theme/md3/tako.ts
index 2502c2f2cd..588fb5d63f 100644
--- a/src/theme/md3/tako.ts
+++ b/src/theme/md3/tako.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const takoTheme = {
light: {
- id: 18,
name: getString('appearanceScreen.theme.tako'),
isDark: false,
primary: '#66577E',
@@ -39,7 +38,6 @@ export const takoTheme = {
backdrop: 'rgba(51, 47, 55, 0.4)',
},
dark: {
- id: 19,
name: getString('appearanceScreen.theme.tako'),
isDark: true,
primary: '#F3B375',
diff --git a/src/theme/md3/tealTurquoise.ts b/src/theme/md3/tealTurquoise.ts
index f9bb458bf9..30619be2cd 100644
--- a/src/theme/md3/tealTurquoise.ts
+++ b/src/theme/md3/tealTurquoise.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const tealTurquoise = {
light: {
- id: 8,
name: getString('appearanceScreen.theme.teal'),
isDark: false,
primary: 'rgb(0, 106, 106)',
@@ -39,7 +38,6 @@ export const tealTurquoise = {
backdrop: 'rgba(41, 50, 50, 0.4)',
},
dark: {
- id: 9,
name: getString('appearanceScreen.theme.turquoise'),
isDark: true,
primary: 'rgb(76, 218, 218)',
diff --git a/src/theme/md3/yinyang.ts b/src/theme/md3/yinyang.ts
index 5f4b934d25..5b8b32e16f 100644
--- a/src/theme/md3/yinyang.ts
+++ b/src/theme/md3/yinyang.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const yinyangTheme = {
light: {
- id: 9,
name: getString('appearanceScreen.theme.yinyang'),
isDark: false,
primary: '#000000',
@@ -39,7 +38,6 @@ export const yinyangTheme = {
backdrop: 'rgba(0, 0, 0, 0.4)',
},
dark: {
- id: 10,
name: getString('appearanceScreen.theme.yinyang'),
isDark: true,
primary: '#FFFFFF',
diff --git a/src/theme/md3/yotsuba.ts b/src/theme/md3/yotsuba.ts
index 0105636e99..1b981769e2 100644
--- a/src/theme/md3/yotsuba.ts
+++ b/src/theme/md3/yotsuba.ts
@@ -2,7 +2,6 @@ import { getString } from '@strings/translations';
export const yotsubaTheme = {
light: {
- id: 12,
name: getString('appearanceScreen.theme.yotsuba'),
isDark: false,
primary: 'rgb(174, 50, 0)',
@@ -39,7 +38,6 @@ export const yotsubaTheme = {
backdrop: 'rgba(59, 45, 41, 0.4)',
},
dark: {
- id: 13,
name: getString('appearanceScreen.theme.yotsuba'),
isDark: true,
primary: 'rgb(255, 181, 158)',
diff --git a/src/theme/utils/setBarColor.ts b/src/theme/utils/setBarColor.ts
index 2d14514458..f98545b172 100644
--- a/src/theme/utils/setBarColor.ts
+++ b/src/theme/utils/setBarColor.ts
@@ -16,6 +16,6 @@ export const setStatusBarColor = (color: ThemeColors | ColorInstance) => {
};
export const changeNavigationBarColor = (color: string, isDark = false) => {
- NavigationBar.setBackgroundColorAsync(color);
+ //NavigationBar.setBackgroundColorAsync(color);
NavigationBar.setButtonStyleAsync(isDark ? 'light' : 'dark');
};
diff --git a/src/utils/mmkv/zustand-adapter.ts b/src/utils/mmkv/zustand-adapter.ts
new file mode 100644
index 0000000000..8b92198396
--- /dev/null
+++ b/src/utils/mmkv/zustand-adapter.ts
@@ -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,
+ );
+ }
+ },
+};
diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json
index b3e8c9d28a..f9200e1938 100644
--- a/strings/languages/en/strings.json
+++ b/strings/languages/en/strings.json
@@ -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"
diff --git a/strings/types/index.ts b/strings/types/index.ts
index 3fc9480dc3..1301e6f2ea 100644
--- a/strings/types/index.ts
+++ b/strings/types/index.ts
@@ -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';