From f6f3f7cc5b9208a09c180a7c6874e2314eab1275 Mon Sep 17 00:00:00 2001 From: Shehryar Date: Tue, 12 Mar 2024 14:23:38 +0400 Subject: [PATCH] Better state manager & cancel download --- App.tsx | 69 +++++++-------- babel.config.js | 1 + package-lock.json | 103 +++++++++++++++++++-- package.json | 7 +- src/contexts/DatabaseContext.tsx | 123 -------------------------- src/lib/database.ts | 44 +++++++++ src/lib/sources/novelfull.ts | 39 +++++--- src/lib/utils.ts | 4 + src/screens/NovelScreen.tsx | 73 +++++++-------- src/screens/library/LibraryScreen.tsx | 14 +-- src/screens/sources/SourceScreen.tsx | 2 - src/screens/sources/SourcesScreen.tsx | 7 +- src/stores/databaseStore.ts | 79 +++++++++++++++++ test.json | 29 ++++++ tsconfig.json | 7 +- 15 files changed, 375 insertions(+), 226 deletions(-) delete mode 100644 src/contexts/DatabaseContext.tsx create mode 100644 src/lib/database.ts create mode 100644 src/stores/databaseStore.ts create mode 100644 test.json diff --git a/App.tsx b/App.tsx index 39db391..31bcc6d 100644 --- a/App.tsx +++ b/App.tsx @@ -9,7 +9,6 @@ import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import HomeScreen from "./src/screens/HomeScreen"; import SettingsScreen from "./src/screens/SettingsScreen"; import SourcesNavigator, { SourcesStackParamList } from './src/screens/sources/SourcesNavigator'; -import { DatabaseProvider } from './src/contexts/DatabaseContext'; export type RootTabParamList = { Home: undefined; @@ -22,41 +21,39 @@ const Tab = createMaterialBottomTabNavigator(); export default function App() { return ( - - - - - - ( - - ), - }} /> - ( - - ), - }} /> - ( - - ), - }} /> - ( - - ), - }} /> - - - - - + + + + + ( + + ), + }} /> + ( + + ), + }} /> + ( + + ), + }} /> + ( + + ), + }} /> + + + + ); } diff --git a/babel.config.js b/babel.config.js index 30526a8..7f8c7d0 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ["module:@react-native/babel-preset"], + plugins: ["transform-remove-console"] }; diff --git a/package-lock.json b/package-lock.json index be93aff..6fe733d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cheerio": "^1.0.0-rc.12", "clone": "^2.1.2", "jszip": "^3.10.1", + "ramda": "^0.29.1", "react": "18.2.0", "react-native": "0.73.5", "react-native-fs": "^2.20.0", @@ -22,7 +23,8 @@ "react-native-paper": "^5.12.3", "react-native-safe-area-context": "^4.9.0", "react-native-screens": "^3.29.0", - "react-native-vector-icons": "^10.0.3" + "react-native-vector-icons": "^10.0.3", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -33,14 +35,17 @@ "@react-native/metro-config": "0.73.5", "@react-native/typescript-config": "0.73.1", "@types/clone": "^2.1.4", + "@types/ramda": "^0.29.11", "@types/react": "^18.2.6", "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "^18.0.0", "babel-jest": "^29.6.3", + "babel-plugin-transform-remove-console": "^6.9.4", "eslint": "^8.19.0", "jest": "^29.6.3", "prettier": "2.8.8", "react-test-renderer": "18.2.0", + "ts-essentials": "^9.4.1", "typescript": "5.0.4" }, "engines": { @@ -4600,13 +4605,22 @@ "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "dev": true + "devOptional": true + }, + "node_modules/@types/ramda": { + "version": "0.29.11", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.11.tgz", + "integrity": "sha512-jm1+PmNOpE7aPS+mMcuB4a72VkCXUJqPSaQRu2YqR8MbsFfaowYXgKxc7bluYdDpRHNXT5Z+xu+Lgr3/ml6wSA==", + "dev": true, + "dependencies": { + "types-ramda": "^0.29.9" + } }, "node_modules/@types/react": { "version": "18.2.64", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.64.tgz", "integrity": "sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4645,7 +4659,7 @@ "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true + "devOptional": true }, "node_modules/@types/semver": { "version": "7.5.8", @@ -5546,6 +5560,12 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/babel-plugin-transform-remove-console": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", + "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", + "dev": true + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -6362,7 +6382,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/dayjs": { "version": "1.11.10", @@ -12393,6 +12413,15 @@ } ] }, + "node_modules/ramda": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz", + "integrity": "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -13777,6 +13806,26 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/ts-essentials": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.4.1.tgz", + "integrity": "sha512-oke0rI2EN9pzHsesdmrOrnqv1eQODmJpd/noJjwj2ZPC3Z4N2wbjrOEqnsEgmvlO2+4fBb0a794DCna2elEVIQ==", + "dev": true, + "peerDependencies": { + "typescript": ">=4.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dev": true + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -13908,6 +13957,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/types-ramda": { + "version": "0.29.9", + "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.29.9.tgz", + "integrity": "sha512-B+VbLtW68J4ncG/rccKaYDhlirKlVH/Izh2JZUfaPJv+3Tl2jbbgYsB1pvole1vXKSgaPlAe/wgEdOnMdAu52A==", + "dev": true, + "dependencies": { + "ts-toolbelt": "^9.6.0" + } + }, "node_modules/typescript": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", @@ -14039,6 +14097,14 @@ "react": ">=16.8" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/utf8": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", @@ -14373,6 +14439,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz", + "integrity": "sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index f369d2f..387e62d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "cheerio": "^1.0.0-rc.12", "clone": "^2.1.2", "jszip": "^3.10.1", + "ramda": "^0.29.1", "react": "18.2.0", "react-native": "0.73.5", "react-native-fs": "^2.20.0", @@ -25,7 +26,8 @@ "react-native-paper": "^5.12.3", "react-native-safe-area-context": "^4.9.0", "react-native-screens": "^3.29.0", - "react-native-vector-icons": "^10.0.3" + "react-native-vector-icons": "^10.0.3", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -36,14 +38,17 @@ "@react-native/metro-config": "0.73.5", "@react-native/typescript-config": "0.73.1", "@types/clone": "^2.1.4", + "@types/ramda": "^0.29.11", "@types/react": "^18.2.6", "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "^18.0.0", "babel-jest": "^29.6.3", + "babel-plugin-transform-remove-console": "^6.9.4", "eslint": "^8.19.0", "jest": "^29.6.3", "prettier": "2.8.8", "react-test-renderer": "18.2.0", + "ts-essentials": "^9.4.1", "typescript": "5.0.4" }, "engines": { diff --git a/src/contexts/DatabaseContext.tsx b/src/contexts/DatabaseContext.tsx deleted file mode 100644 index b24a8d0..0000000 --- a/src/contexts/DatabaseContext.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Dispatch, ReactNode, SetStateAction, createContext, useEffect, useState } from "react"; -import { DatabaseT, NovelT, SOURCES } from "../lib/types"; -import { deleteNovelFromInternal, loadDatabaseFromInternal, saveDatabaseToInternal } from "../lib/fs"; -import { AutoQueue } from "../lib/queue"; -import clone from "clone"; - -export type DownloadStatusT = { - [novelURL: string]: { - totalChapters: number; - downloadedChapters: number; - complete: boolean; - } -} - -export type UpdateDownloadStatusT = ( - novelURL: string, - totalChapters: number, - downloadedChapters: number, - complete: boolean -) => void; - -type DatabaseContextT = { - database: DatabaseT | undefined; - setDatabase: Dispatch>; - downloadStatus: DownloadStatusT; - setDownloadStatus: Dispatch>; - updateDownloadStatus: UpdateDownloadStatusT; - - saveNovel: (novel: NovelT) => boolean; - deleteNovel: (novel: NovelT) => boolean; -} - -export const DatabaseContext = createContext({} as DatabaseContextT); - -export const DatabaseProvider = ({ children }: { children: ReactNode }) => { - const [database, setDatabase] = useState(undefined); - const [downloadStatus, setDownloadStatus] = useState({}); - - const [databaseSaveQueue, setDatabaseSaveQueue] = useState(new AutoQueue()); - - useEffect(() => { - loadDatabase(); - }, []); - - useEffect(() => { - if (database) { - saveDatabase(database); - } - }, [database]); - - const loadDatabase = async () => { - const db = await loadDatabaseFromInternal(); - if (db) { - console.log("Loaded database from file"); - setDatabase(db); - } else { - console.log("Database not found, creating new database"); - const sources: any = clone(SOURCES); - Object.keys(sources).forEach(key => { - delete sources[key].logo; - }); - const newDatabase: DatabaseT = { - sources: sources, - novels: {}, - }; - setDatabase(newDatabase); - } - } - - const saveNovel = (novel: NovelT) => { - if (!database) return false; - if (!database.sources[novel.source]) return false; - setDatabase(prevDB => { - if (!prevDB) return prevDB; - novel.inLibrary = true; - prevDB.novels[novel.url] = novel; - return { ...prevDB }; - }); - return true; - } - - const deleteNovel = (novel: NovelT) => { - if (!database) return false; - if (!database.sources[novel.source]) return false; - setDatabase(prevDB => { - if (!prevDB) return prevDB; - delete prevDB.novels[novel.url]; - return { ...prevDB }; - }); - deleteNovelFromInternal(novel.url); - return true; - } - - const saveDatabase = (newDatabase: DatabaseT) => { - databaseSaveQueue.enqueue(() => saveDatabaseToInternal(newDatabase)).then(res => { - if (res) console.log("Database saved successfully"); - else console.error("Failed to save database"); - }); - } - - const updateDownloadStatus: UpdateDownloadStatusT = ( - novelURL: string, - totalChapters: number, - downloadedChapters: number, - complete: boolean - ) => { - setDownloadStatus(prevStatus => { - const newStatus = { ...prevStatus }; - newStatus[novelURL] = { totalChapters, downloadedChapters, complete }; - return newStatus; - }); - } - - return ( - - {children} - - ) -} diff --git a/src/lib/database.ts b/src/lib/database.ts new file mode 100644 index 0000000..c636f76 --- /dev/null +++ b/src/lib/database.ts @@ -0,0 +1,44 @@ +import clone from "clone"; +import { loadDatabaseFromInternal, saveDatabaseToInternal } from "./fs"; +import { DatabaseT, SOURCES, SourceNamesT } from "./types"; +import { AutoQueue } from "./queue"; +import { DeepWritable } from "ts-essentials"; + +const databaseSaveQueue = new AutoQueue(); + +export const loadDatabase = async () => { + const sources: DeepWritable = clone(SOURCES); + + const db = await loadDatabaseFromInternal(); + if (db) { + console.log("Loaded database from file"); + // Add missing sources if any + const sourceKeys = Object.keys(sources) as SourceNamesT[]; + sourceKeys.forEach((sourceKey) => { + if (!db.sources[sourceKey]) { + console.log(`Source ${sourceKey} not found in database, adding it`); + delete sources[sourceKey].logo; + db.sources[sourceKey] = sources[sourceKey]; + } + }); + return db; + } else { + console.log("Database not found, creating new database"); + const sourceKeys = Object.keys(sources) as SourceNamesT[]; + sourceKeys.forEach((sourceKey) => { + delete sources[sourceKey].logo; + }); + const newDatabase: DatabaseT = { + sources: sources, + novels: {}, + }; + return newDatabase; + } +} + +export const saveDatabase = (newDatabase: DatabaseT) => { + databaseSaveQueue.enqueue(() => saveDatabaseToInternal(newDatabase)).then(res => { + if (res) console.log("Database saved successfully"); + else console.error("Failed to save database"); + }); +} diff --git a/src/lib/sources/novelfull.ts b/src/lib/sources/novelfull.ts index 5c4d45e..219c324 100644 --- a/src/lib/sources/novelfull.ts +++ b/src/lib/sources/novelfull.ts @@ -2,10 +2,10 @@ const axios = require('axios/dist/browser/axios.cjs'); import { load } from "cheerio"; import { ChapterDataT, ChapterT, NovelT, SOURCES } from "../types"; import { generateEPUB, getPropagandaHTML } from "../epub/epub"; -import { NOVEL_CHAPTERS_URI, readChaptersFromInternal, readFileFromInternal, saveChaptersToInternal, saveFileToInternal, saveNovelEpubToDownloads } from "../fs"; +import { readChaptersFromInternal, saveChaptersToInternal, saveNovelEpubToDownloads } from "../fs"; import { AutoQueue } from "../queue"; -import { DownloadStatusT, UpdateDownloadStatusT } from "../../contexts/DatabaseContext"; -import { Dispatch, SetStateAction } from "react"; +import useDatabaseStore from "../../stores/databaseStore"; +import { timeout } from "../utils"; export const searchNovelFullNovels = async (query: string): Promise => { try { @@ -39,7 +39,7 @@ export const searchNovelFullNovels = async (query: string): Promise => } } -export const loadNovelFullNovel = async (url: string, novelData?: NovelT): Promise => { +export const fetchNovelFullNovel = async (url: string, novelData?: NovelT): Promise => { try { const res = await axios.get(url); const $ = load(res.data); @@ -105,13 +105,22 @@ export const loadNovelFullNovel = async (url: string, novelData?: NovelT): Promi } } -export const downloadNovelFullNovel = async (novel: NovelT, updateDownloadStatus: UpdateDownloadStatusT) => { +export const downloadNovelFullNovel = async (novel: NovelT) => { + const setNovelStatus = useDatabaseStore.getState().setNovelStatus; + const setNovel = useDatabaseStore.getState().setNovel; + const setCancelNovelDownload = useDatabaseStore.getState().setCancelNovelDownload; + + let cancel = false; + const unsubscribeState = useDatabaseStore.subscribe(state => { + cancel = !!state.cancelNovelDownload[novel.url] + }); + let totalChapters = novel.totalChapters || 0; let downloadedChapters = novel.downloadedChapters || 0; try { console.log(`Downloading novel: ${novel.title}`); - updateDownloadStatus(novel.url, totalChapters, downloadedChapters, false); + setNovelStatus(novel.url, totalChapters, downloadedChapters, false); let res = await axios.get(novel.url); let $ = load(res.data); @@ -132,14 +141,18 @@ export const downloadNovelFullNovel = async (novel: NovelT, updateDownloadStatus else throw new Error("Failed to get chapter title or url"); }); + if (cancel) throw new Error("Download cancelled"); + if (i + 1 > totalPages) break; res = await axios.get(`${novel.url}?page=${i}`); $ = load(res.data); } totalChapters = chapterLinks.length; - updateDownloadStatus(novel.url, totalChapters, downloadedChapters, false); + setNovelStatus(novel.url, totalChapters, downloadedChapters, false); console.log(`Total chapters: ${totalChapters}`); + if (cancel) throw new Error("Download cancelled"); + let chapters: ChapterDataT[] = []; // Load downloaded chapters from cache if any if (novel.downloadedChapters) { @@ -151,6 +164,7 @@ export const downloadNovelFullNovel = async (novel: NovelT, updateDownloadStatus downloadedChapters = 0; const chaptersSaveQueue = new AutoQueue(); for (let i = 0; i < chapterLinks.length; i++) { + if (cancel) throw new Error("Download cancelled"); const chapter = chapterLinks[i]; if (chapters.length > i && chapters[i].url === chapter.url && chapters[i].title === chapter.title) { downloadedChapters++; @@ -167,11 +181,12 @@ export const downloadNovelFullNovel = async (novel: NovelT, updateDownloadStatus // Cache downloaded chapters to file chaptersSaveQueue.enqueue(() => saveChaptersToInternal(novel.url, JSON.stringify(chapters))); downloadedChapters++; - updateDownloadStatus(novel.url, totalChapters, downloadedChapters, false); + setNovelStatus(novel.url, totalChapters, downloadedChapters, false); } - updateDownloadStatus(novel.url, totalChapters, downloadedChapters, false); + setNovelStatus(novel.url, totalChapters, downloadedChapters, false); // Generate and save EPUB + if (cancel) throw new Error("Download cancelled"); console.log("Generating EPUB"); const epubBlob = await generateEPUB(novel, chapters); await saveNovelEpubToDownloads(novel.title, epubBlob); @@ -180,10 +195,12 @@ export const downloadNovelFullNovel = async (novel: NovelT, updateDownloadStatus console.error(error); } + unsubscribeState(); novel.totalChapters = totalChapters; novel.downloadedChapters = downloadedChapters; - updateDownloadStatus(novel.url, totalChapters, downloadedChapters, true); - return novel; + setCancelNovelDownload(novel.url, false); + setNovelStatus(novel.url, totalChapters, downloadedChapters, true); + setNovel(novel); } const downloadNovelFullChapter = async (title: string, url: string) => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ae3165a..699f83d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -36,3 +36,7 @@ export const getFileSafeString = (str: string): string => { export const roundNumber = (num: number, factor: number) => { return Math.round((num + Number.EPSILON) * (10 ** factor)) / (10 ** factor) } + +export const timeout = (ms: number) => { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/screens/NovelScreen.tsx b/src/screens/NovelScreen.tsx index 4129f05..77b3cfd 100644 --- a/src/screens/NovelScreen.tsx +++ b/src/screens/NovelScreen.tsx @@ -4,45 +4,47 @@ import { Image, StyleSheet, Text, View } from "react-native"; import { SourcesStackParamList } from "./sources/SourcesNavigator"; import { ScrollView, TouchableOpacity } from "react-native-gesture-handler"; import { useContext, useEffect, useState } from "react"; -import { downloadNovelFullNovel, loadNovelFullNovel } from "../lib/sources/novelfull"; -import { DatabaseContext } from "../contexts/DatabaseContext"; +import { downloadNovelFullNovel, fetchNovelFullNovel } from "../lib/sources/novelfull"; import { NovelT } from "../lib/types"; import { LibraryStackParamList } from "./library/LibraryNavigator"; import { assertUnreachable, roundNumber } from "../lib/utils"; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { stripDatabaseInfoFromNovel } from "../lib/sources/sources"; +import useDatabaseStore from "../stores/databaseStore"; type Props = CompositeScreenProps< StackScreenProps, StackScreenProps >; export default function NovelScreen({ route, navigation }: Props) { - const db = useContext(DatabaseContext); + const dbNovel = useDatabaseStore(state => state.database?.novels[route.params.novel.url]); + const dbNovelStatus = useDatabaseStore(state => state.novelStatus[route.params.novel.url]); + const dbSetNovel = useDatabaseStore(state => state.setNovel); + const dbDeleteNovel = useDatabaseStore(state => state.deleteNovel); + const dbCancelNovelDownload = useDatabaseStore(state => state.cancelNovelDownload[route.params.novel.url]); + const dbSetCancelNovelDownload = useDatabaseStore(state => state.setCancelNovelDownload); const [isLoadingNovel, setIsLoadingNovel] = useState(false); - const [inDatabase, setInDatabase] = useState(false); const [novel, setNovel] = useState(route.params.novel); const [showMoreDesc, setShowMoreDesc] = useState(false); useEffect(() => { - const dbNovel = db.database?.novels[novel.url]; if (dbNovel) { console.log("Loaded novel from database"); setNovel(dbNovel); - setInDatabase(true); } else { console.log("Novel not found in database, loading from source"); - loadNovel(); + fetchNovelFromSource(); } - }, [db.database]); + }, [dbNovel]); const loadSourceNovel = async (novel: NovelT): Promise => { // TODO: Handle sources here switch (novel.source) { case "NovelFull": - return await loadNovelFullNovel(novel.url, novel); + return await fetchNovelFullNovel(novel.url, novel); } return assertUnreachable(novel.source); } @@ -52,44 +54,35 @@ export default function NovelScreen({ route, navigation }: Props) { // TODO: Handle sources here switch (novel.source) { case "NovelFull": - const newNovel = await downloadNovelFullNovel(novel, db.updateDownloadStatus); - setNovel(newNovel); - db.saveNovel(newNovel); + await downloadNovelFullNovel(novel); return; } return assertUnreachable(novel.source); } - const loadNovel = async () => { + const fetchNovelFromSource = async () => { if (isLoadingNovel) return; setIsLoadingNovel(true); - - let loadedNovel = await loadSourceNovel(novel); - - if (loadedNovel) { - setNovel(loadedNovel); - } + const loadedNovel = await loadSourceNovel(novel); + if (loadedNovel) setNovel(stripDatabaseInfoFromNovel(loadedNovel)); setIsLoadingNovel(false); } const handleSaveNovel = () => { if (isLoadingNovel) return; - const isSaved = db.saveNovel(novel); - if (!isSaved) console.error("Failed to save novel to database"); - else setInDatabase(true); + novel.inLibrary = true; + dbSetNovel(novel); } const handleDeleteNovel = () => { - if (!db.database || !inDatabase || isLoadingNovel) return; - const isDeleted = db.deleteNovel(novel); + if (!novel.inLibrary) return; + dbDeleteNovel(novel); setNovel(prevNovel => stripDatabaseInfoFromNovel(prevNovel)); - if (!isDeleted) console.error("Failed to delete novel from database"); - else setInDatabase(false); // if (navigation.canGoBack()) navigation.goBack(); } const handleCancelDownload = () => { - // FIXME: Handle cancel download + dbSetCancelNovelDownload(novel.url, true); } if (!novel) return No Novel Selected!; @@ -97,24 +90,32 @@ export default function NovelScreen({ route, navigation }: Props) { - {(db.downloadStatus[novel.url] && !db.downloadStatus[novel.url].complete) && + {(dbNovelStatus && !dbNovelStatus.complete) && - {`${roundNumber((db.downloadStatus[novel.url].downloadedChapters - / db.downloadStatus[novel.url].totalChapters) + {`${roundNumber((dbNovelStatus.downloadedChapters + / dbNovelStatus.totalChapters) * 100, 2)}%`} } - {(novel.inLibrary && (!db.downloadStatus[novel.url] || db.downloadStatus[novel.url].complete)) && + {(dbNovelStatus && !dbNovelStatus.complete && !dbCancelNovelDownload) && + handleCancelDownload()} + disabled={isLoadingNovel} + > + + + } + {(novel.inLibrary && (!dbNovelStatus || dbNovelStatus.complete)) && handleDownloadNovel()} disabled={isLoadingNovel} @@ -122,12 +123,12 @@ export default function NovelScreen({ route, navigation }: Props) { } - {(!db.downloadStatus[novel.url] || db.downloadStatus[novel.url].complete) && + {(!dbNovelStatus || dbNovelStatus.complete) && inDatabase ? handleDeleteNovel() : handleSaveNovel()} + onPress={() => novel.inLibrary ? handleDeleteNovel() : handleSaveNovel()} disabled={isLoadingNovel} > - + } diff --git a/src/screens/library/LibraryScreen.tsx b/src/screens/library/LibraryScreen.tsx index 506be07..0272cf1 100644 --- a/src/screens/library/LibraryScreen.tsx +++ b/src/screens/library/LibraryScreen.tsx @@ -1,28 +1,28 @@ import { Image, StyleSheet, Text, View } from "react-native"; -import { DatabaseContext } from "../../contexts/DatabaseContext"; import { useCallback, useContext, useEffect, useState } from "react"; import { FlatList, TextInput, TouchableOpacity } from "react-native-gesture-handler"; import { StackScreenProps } from "@react-navigation/stack"; import { LibraryStackParamList } from "./LibraryNavigator"; import { useFocusEffect } from "@react-navigation/native"; +import useDatabaseStore from "../../stores/databaseStore"; type Props = StackScreenProps; export default function LibraryScreen({ navigation }: Props) { - const db = useContext(DatabaseContext); + const dbNovels = useDatabaseStore(state => state.database?.novels); const [search, setSearch] = useState(""); - const [filteredNovels, setFilteredNovels] = useState(db.database?.novels ? Object.values(db.database.novels) : []); + const [filteredNovels, setFilteredNovels] = useState(Object.values(dbNovels || {})); useFocusEffect(useCallback(() => { setSearch(""); - setFilteredNovels(Object.values(db.database?.novels || {})); - }, [db.database])); + setFilteredNovels(Object.values(dbNovels || {})); + }, [dbNovels])); const handleSearch = () => { if (search === "") { - setFilteredNovels(Object.values(db.database?.novels || {})); + setFilteredNovels(Object.values(dbNovels || {})); } else { - setFilteredNovels(Object.values(db.database?.novels || {}) + setFilteredNovels(Object.values(dbNovels || {}) .filter(novel => novel.title.toLowerCase().includes(search.toLowerCase())) ); } diff --git a/src/screens/sources/SourceScreen.tsx b/src/screens/sources/SourceScreen.tsx index cd6bd61..5a82070 100644 --- a/src/screens/sources/SourceScreen.tsx +++ b/src/screens/sources/SourceScreen.tsx @@ -19,8 +19,6 @@ export default function SourceScreen({ route, navigation }: Props) { switch (source.name) { case "NovelFull": return await searchNovelFullNovels(search); - case "BoxNovel": - return []; // FIXME: Add BoxNovel support } return assertUnreachable(source.name); } diff --git a/src/screens/sources/SourcesScreen.tsx b/src/screens/sources/SourcesScreen.tsx index 6185d57..de80b9e 100644 --- a/src/screens/sources/SourcesScreen.tsx +++ b/src/screens/sources/SourcesScreen.tsx @@ -4,12 +4,13 @@ import { SourcesStackParamList } from "./SourcesNavigator"; import { ScrollView, TouchableOpacity } from "react-native-gesture-handler"; import ImageAutoHeight from "../../components/ImageAutoHeight"; import { useContext, useEffect, useState } from "react"; -import { DatabaseContext } from "../../contexts/DatabaseContext"; import { SOURCES } from "../../lib/types"; +import useDatabaseStore from "../../stores/databaseStore"; type Props = StackScreenProps; export default function SourcesScreen({ navigation }: Props) { - const db = useContext(DatabaseContext) + const dbSources = useDatabaseStore(state => state.database?.sources); + const [imageStyle, setImageStyle] = useState({ width: 0, height: "auto" }); const onLayout = (event: any) => { @@ -20,7 +21,7 @@ export default function SourcesScreen({ navigation }: Props) { return ( - {Object.values(db.database?.sources || {}).map((source, i) => ( + {Object.values(dbSources || {}).map((source, i) => ( void; + deleteNovel: (novel: NovelT) => void; + setNovelStatus: (novelURL: string, totalChapters: number, downloadedChapters: number, complete?: boolean) => void; + deleteNovelStatus: (novelURL: string) => void; + setCancelNovelDownload: (novelURL: string, cancel: boolean) => void; +} + +const useDatabaseStore = create((set) => ({ + database: undefined, + novelStatus: {}, + cancelNovelDownload: {}, + setNovel: (novel: NovelT) => set(state => { + if (!state.database) return { database: state.database }; + const newDatabase = { + ...state.database, + novels: { + ...state.database.novels, + [novel.url]: novel + } + }; + saveDatabase(newDatabase); + return { database: newDatabase } + }), + deleteNovel: (novel: NovelT) => set(state => { + if (!state.database) return { database: state.database }; + const novels = { ...state.database?.novels }; + delete novels[novel.url]; + const newDatabase = { + ...state.database, + novels: novels + } + saveDatabase(newDatabase); + return { database: newDatabase } + }), + setNovelStatus: (novelURL, totalChapters, downloadedChapters, complete = false) => set(state => ({ + novelStatus: { + ...state.novelStatus, + [novelURL]: { + totalChapters, + downloadedChapters, + complete + } + } + })), + deleteNovelStatus: (novelURL) => set(state => { + const newNovelStatus = { ...state.novelStatus }; + delete newNovelStatus[novelURL]; + return { novelStatus: newNovelStatus }; + }), + setCancelNovelDownload: (novelURL, cancel) => set(state => ( + { cancelNovelDownload: { ...state.cancelNovelDownload, [novelURL]: cancel } } + )), +})); + +loadDatabase().then(database => { + useDatabaseStore.setState({ database }); + saveDatabase(database); +}); + +export default useDatabaseStore; diff --git a/test.json b/test.json new file mode 100644 index 0000000..0ae6db1 --- /dev/null +++ b/test.json @@ -0,0 +1,29 @@ +{ + "alternateTitles": [ + "Psychic Detective Yakumo", + "Shinrei Tantei Yakumo - Akai Hitomi wa Shitteiru", + "心霊探偵八雲" + ], + "authors": [ + "Kaminaga Manabu" + ], + "coverURL": "https://novelfull.com/uploads/thumbs/shinrei-tantei-yakumo-fa80d9de01-2239c49aee6b961904acf173b7e4602a.jpg", + "description": "Haruka, consulted by a friend who ran into a ghost at school, goes to the Movie Research Circle to see a man with mysterious powers. However, the person who meets her there is an affected young man with terrible bed hair and sleepy eyes. What does Haruka consult him about? A confinement and murder case at a women’s university, a false suicide case… The great detective who can see the spirits of the dead, Saitou Yakumo, takes these cases on in this astoundingly high-speed spiritual mystery!", + "genres": [ + "Drama", + "Horror", + "Mystery", + "Romance", + "Shoujo", + "Supernatural" + ], + "inLibrary": true, + "latestChapter": "Volume 9 Epilogue", + "rating": "8.5/10 from 971 ratings", + "source": "NovelFull", + "status": "Completed", + "thumbnailURL": "https://novelfull.com/uploads/thumbs/shinrei-tantei-yakumo-fa80d9de01-654c4ae8898fbd1defba3716220d72f8.jpg", + "title": "Shinrei Tantei Yakumo", + "totalChapters": 42, + "url": "https://novelfull.com/shinrei-tantei-yakumo.html" +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 304ab4e..9002f11 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "@react-native/typescript-config/tsconfig.json" -} + "extends": "@react-native/typescript-config/tsconfig.json", + "compilerOptions": { + "strictNullChecks": true + } +} \ No newline at end of file