From 4e1a67efc8f1e18f8cf6688ac418e68976399ca5 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Wed, 22 Oct 2025 17:03:55 +0530 Subject: [PATCH] fix: Refactor and Fix EPUB Export (Closes #1611) --- package-lock.json | 45 +++-- package.json | 1 + .../novel/components/EpubIconButton.tsx | 184 ----------------- ...bLocationModal.tsx => ExportEpubModal.tsx} | 22 +-- .../components/ExportNovelAsEpubButton.tsx | 187 ++++++++++++++++++ src/screens/novel/components/NovelAppbar.tsx | 10 +- src/theme/colors.ts | 2 + strings/languages/en/strings.json | 22 ++- strings/types/index.ts | 18 +- 9 files changed, 257 insertions(+), 234 deletions(-) delete mode 100644 src/screens/novel/components/EpubIconButton.tsx rename src/screens/novel/components/{ChooseEpubLocationModal.tsx => ExportEpubModal.tsx} (81%) create mode 100644 src/screens/novel/components/ExportNovelAsEpubButton.tsx diff --git a/package-lock.json b/package-lock.json index 4a6e9178..763f306d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "react-native-drawer-layout": "^4.1.8", "react-native-edge-to-edge": "^1.6.0", "react-native-error-boundary": "^2.0.0", + "react-native-file-access": "^3.2.0", "react-native-gesture-handler": "~2.25.0", "react-native-lottie-splash-screen": "^1.1.2", "react-native-mmkv": "^3.2.0", @@ -151,6 +152,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -182,6 +184,7 @@ "integrity": "sha512-q8rjOuadH0V6Zo4XLMkJ3RMQ9MSBqwaDByyYB0izsYdaIWGNLmEblbCOf1vyFHICcg16CD7Fsi51vcQnYxmt6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", @@ -2125,6 +2128,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", "license": "MIT", + "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -3953,6 +3957,7 @@ "integrity": "sha512-DyKptlG78XPFo7tDod+we5a3R+U9qjyhaVFbOPvH4pFNu5Dehewtol/srl44K6Cszq0aEMlAJZ3juk0W4WnOJA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@react-native-community/cli-clean": "18.0.0", "@react-native-community/cli-config": "18.0.0", @@ -4730,6 +4735,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.8.tgz", "integrity": "sha512-ryKd/qNigi1pUp6mBb2pq75ese7AZ/Cl3xEmTG6PcUGMfMqAMMrmmVbgiys0h8zCGY2tSBSqnDHbGW1/ZtOoKg==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.9.1", "escape-string-regexp": "^4.0.0", @@ -5015,6 +5021,7 @@ "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5083,6 +5090,7 @@ "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", @@ -5117,6 +5125,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -5380,6 +5389,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6268,6 +6278,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -7637,6 +7648,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8304,6 +8316,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-53.0.9.tgz", "integrity": "sha512-UFG68aVOpccg3s++S3pbtI3YCQCnlu/TFvhnQ5vaD3vhOox1Uk/f2O2T95jmwA/EvKvetqGj34lys3DNXvPqgQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.13", @@ -8420,6 +8433,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.3.1.tgz", "integrity": "sha512-d+xrHYvSM9WB42wj8vP9OOFWyxed5R1evphfDb6zYBmC1dA9Hf89FpT7TNFtj2Bk3clTnpmVqQTCYbbA2P3CLg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -8611,17 +8625,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/babel-plugin-react-compiler": { - "version": "19.0.0-beta-ebf51a3-20250411", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.0.0-beta-ebf51a3-20250411.tgz", - "integrity": "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.26.0" - } - }, "node_modules/expo/node_modules/babel-preset-expo": { "version": "13.1.11", "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-13.1.11.tgz", @@ -11461,7 +11464,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/lottie-ios/-/lottie-ios-3.2.3.tgz", "integrity": "sha512-mubYMN6+1HXa8z3EJKBvNBkl4UoVM4McjESeB2PgvRMSngmJtC5yUMRdhbbrIAn5Liu3hFGao/14s5hQIgtkRQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -12922,6 +12926,7 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -13039,6 +13044,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -13223,6 +13229,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13281,6 +13288,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.79.2.tgz", "integrity": "sha512-AnGzb56JvU5YCL7cAwg10+ewDquzvmgrMddiBM0GAWLwQM/6DJfGd2ZKrMuKKehHerpDDZgG+EY64gk3x3dEkw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.79.2", @@ -13415,9 +13423,9 @@ } }, "node_modules/react-native-file-access": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/react-native-file-access/-/react-native-file-access-3.1.1.tgz", - "integrity": "sha512-4KUpBAsnWJa+AQf1tUbLdHO+1pyiZMTeq3NPf5XOGdz1O5CwIrVkrzl+gkN7ffmUa5JyoYHyXUtwScmA+z0Tlg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-native-file-access/-/react-native-file-access-3.2.0.tgz", + "integrity": "sha512-3G0Ma3FvV99Ne7lbwHyZKqLMZVrpIr6fAlIYZQxkRDSZZJ1UtJlGi0VqbJXnJFB26a7C7PEPyVBoctmKr7DzaA==", "license": "MIT", "peerDependencies": { "react": "*", @@ -13429,6 +13437,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.25.0.tgz", "integrity": "sha512-NPjJi6mislXxvjxQPU9IYwBjb1Uejp8GvAbE1Lhh+xMIMEvmgAvVIp5cz1P+xAbV6uYcRRArm278+tEInGOqWg==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -13509,6 +13518,7 @@ "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.7.1.tgz", "integrity": "sha512-cBSr6xw4g5N7Kd3VGWcf+kmaH7iBWb0DXAf2bVo3bXkzBcBbTOmYSvc0LVLHhUPW8nEq5WjT9LCIYAzgF++EXw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -13564,6 +13574,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", "integrity": "sha512-SxBK7wQfJ4UoWoJqQnmIC7ZjuNgVb9rcY5Xc67upXAFKftWg0rnkknTw6vgwnjRcvYThrjzUVti66XoZdDJGtw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -13599,6 +13610,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz", "integrity": "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -13621,6 +13633,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.10.0.tgz", "integrity": "sha512-Tw21NGuXm3PbiUGtZd0AnXirUixaAbPXDjNR0baBH7/WJDaDTTELLcQ7QRXuqAWbmr/EVCrKj1348ei1KFIr8A==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -13671,6 +13684,7 @@ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.13.5.tgz", "integrity": "sha512-MfC2B+woL4Hlj2WCzcb1USySKk+SteXnUKmKktOk/H/AQy5+LuVdkPKm8SknJ0/RxaxhZ48WBoTRGaqgR137hw==", "license": "MIT", + "peer": true, "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -15573,6 +15587,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 28cde295..d14bcb79 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "react-native-drawer-layout": "^4.1.8", "react-native-edge-to-edge": "^1.6.0", "react-native-error-boundary": "^2.0.0", + "react-native-file-access": "^3.2.0", "react-native-gesture-handler": "~2.25.0", "react-native-lottie-splash-screen": "^1.1.2", "react-native-mmkv": "^3.2.0", diff --git a/src/screens/novel/components/EpubIconButton.tsx b/src/screens/novel/components/EpubIconButton.tsx deleted file mode 100644 index d0d99898..00000000 --- a/src/screens/novel/components/EpubIconButton.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import React, { useMemo } from 'react'; -import { Portal } from 'react-native-paper'; -import ChooseEpubLocationModal from './ChooseEpubLocationModal'; -import { StatusBar, StyleProp, ViewStyle } from 'react-native'; -import { ThemeColors } from '@theme/types'; - -import EpubBuilder from '@cd-z/react-native-epub-creator'; -import { ChapterInfo, NovelInfo } from '@database/types'; - -import { useChapterReaderSettings } from '@hooks/persisted'; -import { useBoolean } from '@hooks/index'; -import { showToast } from '@utils/showToast'; -import { NOVEL_STORAGE } from '@utils/Storages'; -import NativeFile from '@specs/NativeFile'; -import { MaterialDesignIconName } from '@type/icon'; - -interface EpubIconButtonProps { - theme: ThemeColors; - novel?: NovelInfo; - chapters: ChapterInfo[]; - anchor: (props: { - icon: MaterialDesignIconName; - onPress: () => void; - style?: StyleProp; - size?: number; - }) => React.JSX.Element; -} - -const EpubIconButton: React.FC = ({ - theme, - novel, - chapters, - anchor: Anchor, -}) => { - const { - value: isVisible, - setTrue: showModal, - setFalse: hideModal, - } = useBoolean(false); - const readerSettings = useChapterReaderSettings(); - const { - epubUseAppTheme = false, - epubUseCustomCSS = false, - epubUseCustomJS = false, - } = useChapterReaderSettings(); - - const epubStyle = useMemo( - () => - !novel - ? '' - : `${ - epubUseAppTheme - ? ` - html { - scroll-behavior: smooth; - overflow-x: hidden; - padding-top: ${StatusBar.currentHeight}; - word-wrap: break-word; - } - body { - padding-left: ${readerSettings.padding}%; - padding-right: ${readerSettings.padding}%; - padding-bottom: 40px; - font-size: ${readerSettings.textSize}px; - color: ${readerSettings.textColor}; - text-align: ${readerSettings.textAlign}; - line-height: ${readerSettings.lineHeight}; - font-family: "${readerSettings.fontFamily}"; - background-color: "${readerSettings.theme}"; - } - hr { - margin-top: 20px; - margin-bottom: 20px; - } - a { - color: ${theme.primary}; - } - img { - display: block; - width: auto; - height: auto; - max-width: 100%; - }` - : '' - } - ${ - epubUseCustomCSS - ? readerSettings.customCSS - .replace( - RegExp(`#sourceId-${novel.pluginId}\\s*\\{`, 'g'), - 'body {', - ) - .replace(RegExp(`#sourceId-${novel.pluginId}[^.#A-Z]*`, 'gi'), '') - : '' - }`, - [ - novel, - epubUseAppTheme, - readerSettings.padding, - readerSettings.textSize, - readerSettings.textColor, - readerSettings.textAlign, - readerSettings.lineHeight, - readerSettings.fontFamily, - readerSettings.theme, - readerSettings.customCSS, - theme.primary, - epubUseCustomCSS, - ], - ); - - const epubJS = useMemo( - () => - !novel - ? '' - : ` - let novelName = "${novel.name}"; - let chapterName = ""; - let sourceId =${novel.pluginId}; - let chapterId =""; - let novelId =${novel.id}; - let html = document.querySelector("chapter").innerHTML; - - ${readerSettings.customJS} - `, - [novel, readerSettings], - ); - - const createEpub = async (uri: string) => { - if (!novel) { - return; - } - const epub = new EpubBuilder( - { - title: novel.name, - fileName: novel.name.replace(/\s/g, ''), - language: 'en', - cover: novel.cover, - description: novel.summary, - author: novel.author, - bookId: novel.pluginId.toString(), - stylesheet: epubStyle || undefined, - js: epubUseCustomJS ? epubJS : undefined, - }, - uri, - ); - - try { - await epub.prepare(); - for (let i = 0; i < chapters.length; i++) { - const chapter = chapters[i]; - const filePath = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}/index.html`; - if (NativeFile.exists(filePath)) { - const downloaded = NativeFile.readFile(filePath); - - await epub.addChapter({ - title: - chapter.name?.trim() ?? 'Chapter ' + (chapter.chapterNumber || i), - fileName: 'Chapter' + i, - htmlBody: `${downloaded}`, - }); - } - } - const epubFilePath = await epub.save(); - showToast('Epub file saved at: ' + epubFilePath); - } catch (error) { - showToast('Cannot create because: ' + error); - await epub.discardChanges(); - } - }; - return ( - <> - - - - - - ); -}; -export default EpubIconButton; diff --git a/src/screens/novel/components/ChooseEpubLocationModal.tsx b/src/screens/novel/components/ExportEpubModal.tsx similarity index 81% rename from src/screens/novel/components/ChooseEpubLocationModal.tsx rename to src/screens/novel/components/ExportEpubModal.tsx index 1dca5cf4..d9cba819 100644 --- a/src/screens/novel/components/ChooseEpubLocationModal.tsx +++ b/src/screens/novel/components/ExportEpubModal.tsx @@ -10,13 +10,13 @@ import { getString } from '@strings/translations'; import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; import { showToast } from '@utils/showToast'; -interface ChooseEpubLocationModalProps { +interface ExportEpubModalProps { isVisible: boolean; onSubmit?: (uri: string) => void; hideModal: () => void; } -const ChooseEpubLocationModal: React.FC = ({ +const ExportEpubModal: React.FC = ({ isVisible, onSubmit: onSubmitProp, hideModal, @@ -67,12 +67,12 @@ const ChooseEpubLocationModal: React.FC = ({ - {getString('novelScreen.convertToEpubModal.chooseLocation')} + {getString('novelScreen.exportEpubModal.title')} = ({ = ({ @@ -122,7 +120,7 @@ const ChooseEpubLocationModal: React.FC = ({ ); }; -export default ChooseEpubLocationModal; +export default ExportEpubModal; const styles = StyleSheet.create({ infoItem: { diff --git a/src/screens/novel/components/ExportNovelAsEpubButton.tsx b/src/screens/novel/components/ExportNovelAsEpubButton.tsx new file mode 100644 index 00000000..46beb66d --- /dev/null +++ b/src/screens/novel/components/ExportNovelAsEpubButton.tsx @@ -0,0 +1,187 @@ +import React, { useMemo } from 'react'; +import { Appbar, Portal } from 'react-native-paper'; +import { StatusBar } from 'react-native'; + +import EpubBuilder from '@cd-z/react-native-epub-creator'; +import NativeFile from '@specs/NativeFile'; + +import { ChapterInfo, NovelInfo } from '@database/types'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; +import { useBoolean } from '@hooks/index'; +import { showToast } from '@utils/showToast'; +import { NOVEL_STORAGE } from '@utils/Storages'; +import { getString } from '@strings/translations'; + +import ExportEpubModal from './ExportEpubModal'; + +interface ExportNovelAsEpubButtonProps { + novel?: NovelInfo; + chapters: ChapterInfo[]; +} + +const ExportNovelAsEpubButton: React.FC = ({ + novel, + chapters, +}) => { + const theme = useTheme(); + + const { + value: isModalVisible, + setTrue: showModal, + setFalse: hideModal, + } = useBoolean(false); + + const readerSettings = useChapterReaderSettings(); + const { + epubUseAppTheme = false, + epubUseCustomCSS = false, + epubUseCustomJS = false, + } = readerSettings; + + const epubStylesheet = useMemo(() => { + if (!novel) { + return ''; + } + + const appThemeStyles = epubUseAppTheme + ? ` + html { + scroll-behavior: smooth; + overflow-x: hidden; + padding-top: ${StatusBar.currentHeight}; + word-wrap: break-word; + } + body { + padding-left: ${readerSettings.padding}%; + padding-right: ${readerSettings.padding}%; + padding-bottom: 40px; + font-size: ${readerSettings.textSize}px; + color: ${readerSettings.textColor}; + text-align: ${readerSettings.textAlign}; + line-height: ${readerSettings.lineHeight}; + font-family: "${readerSettings.fontFamily}"; + background-color: "${readerSettings.theme}"; + } + hr { + margin-top: 20px; + margin-bottom: 20px; + } + a { + color: ${theme.primary}; + } + img { + display: block; + width: auto; + height: auto; + max-width: 100%; + }` + : ''; + + const customStyles = epubUseCustomCSS + ? readerSettings.customCSS + .replace(RegExp(`#sourceId-${novel.pluginId}\\s*\\{`, 'g'), 'body {') + .replace(RegExp(`#sourceId-${novel.pluginId}[^.#A-Z]*`, 'gi'), '') + : ''; + + return appThemeStyles + customStyles; + }, [novel, epubUseAppTheme, epubUseCustomCSS, readerSettings, theme.primary]); + + const epubJavaScript = useMemo(() => { + if (!novel) { + return ''; + } + + return ` + let novelName = "${novel.name}"; + let chapterName = ""; + let sourceId = ${novel.pluginId}; + let chapterId = ""; + let novelId = ${novel.id}; + let html = document.querySelector("chapter").innerHTML; + + ${readerSettings.customJS} + `; + }, [novel, readerSettings]); + + const exportNovelAsEpub = async (destinationUri: string) => { + if (!novel) { + showToast(getString('novelScreen.epub.noNovelSelected')); + return; + } + + let epub: EpubBuilder | undefined; + + try { + epub = new EpubBuilder( + { + title: novel.name, + fileName: novel.name.replace(/\s/g, ''), + language: 'en', + cover: novel.cover, + description: novel.summary, + author: novel.author, + bookId: novel.pluginId.toString(), + stylesheet: epubStylesheet || undefined, + js: epubUseCustomJS ? epubJavaScript : undefined, + }, + destinationUri, + ); + + await epub.prepare(); + + let addedChapters = 0; + for (let i = 0; i < chapters.length; i++) { + const chapter = chapters[i]; + const chapterFilePath = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}/index.html`; + + if (NativeFile.exists(chapterFilePath)) { + const chapterContent = NativeFile.readFile(chapterFilePath); + + await epub.addChapter({ + title: + chapter.name?.trim() || `Chapter ${chapter.chapterNumber || i}`, + fileName: `Chapter${i}`, + htmlBody: `${chapterContent}`, + }); + + addedChapters++; + } + } + + if (addedChapters === 0) { + showToast(getString('novelScreen.epub.noDownloadedChapters')); + await epub.discardChanges(); + return; + } + + await epub.save(); + showToast( + getString('novelScreen.epub.exportSuccess', { + chapters: addedChapters.toString(), + }), + ); + } catch (error: any) { + showToast( + getString('novelScreen.epub.exportFailed', { + error: error.message || error, + }), + ); + await epub?.discardChanges(); + } + }; + + return ( + <> + + + + + + ); +}; + +export default ExportNovelAsEpubButton; diff --git a/src/screens/novel/components/NovelAppbar.tsx b/src/screens/novel/components/NovelAppbar.tsx index ebb7e72f..441e6390 100644 --- a/src/screens/novel/components/NovelAppbar.tsx +++ b/src/screens/novel/components/NovelAppbar.tsx @@ -11,7 +11,7 @@ import Animated, { interpolateColor, useAnimatedStyle, } from 'react-native-reanimated'; -import EpubIconButton from './EpubIconButton'; +import ExportNovelAsEpubButton from './ExportNovelAsEpubButton'; import { ChapterInfo, NovelInfo } from '@database/types'; import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { MaterialDesignIconName } from '@type/icon'; @@ -165,13 +165,7 @@ const NovelAppbar = ({ - - + string = isDark => isDark ? '#FFC107' : '#FFC107'; + +export const borderColor = 'rgba(0, 0, 0, 0.12)'; diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index 7779f1dc..2b12e25b 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -361,14 +361,20 @@ "chapterChapnum": "Chapter %{num}", "chapters": "chapters", "continueReading": "Continue reading", - "convertToEpubModal": { - "chaptersWarning": "Only downloaded chapters are included in the EPUB", - "chooseLocation": "Select EPUB storage directory", - "pathToFolder": "Path to EPUB save folder", - "useCustomCSS": "Use custom CSS", - "useCustomJS": "Use custom JS", - "useCustomJSWarning": "May not be supported by all EPUB readers", - "useReaderTheme": "Use reader theme" + "exportEpubModal": { + "applyReaderTheme": "Apply reader theme to EPUB", + "customJSWarning": "Custom JavaScript may not be supported by all EPUB readers", + "downloadedChaptersOnly": "Only downloaded chapters will be included in the EPUB file", + "includeCustomCSS": "Include Custom CSS", + "includeCustomJS": "Include Custom JS", + "selectFolder": "Select destination folder for EPUB file", + "title": "Export Novel as EPUB" + }, + "epub": { + "exportFailed": "Failed to export EPUB: %{error}", + "exportSuccess": "Successfully exported %{chapters} chapters as EPUB", + "noDownloadedChapters": "No downloaded chapters found. Please download chapters before exporting.", + "noNovelSelected": "No novel selected for export" }, "coverSaved": "Cover saved", "coverNotSaved": "Cover not saved", diff --git a/strings/types/index.ts b/strings/types/index.ts index 922eea4b..7f592fb0 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -296,13 +296,17 @@ export interface StringMap { 'novelScreen.chapterChapnum': 'string'; 'novelScreen.chapters': 'string'; 'novelScreen.continueReading': 'string'; - 'novelScreen.convertToEpubModal.chaptersWarning': 'string'; - 'novelScreen.convertToEpubModal.chooseLocation': 'string'; - 'novelScreen.convertToEpubModal.pathToFolder': 'string'; - 'novelScreen.convertToEpubModal.useCustomCSS': 'string'; - 'novelScreen.convertToEpubModal.useCustomJS': 'string'; - 'novelScreen.convertToEpubModal.useCustomJSWarning': 'string'; - 'novelScreen.convertToEpubModal.useReaderTheme': 'string'; + 'novelScreen.exportEpubModal.applyReaderTheme': 'string'; + 'novelScreen.exportEpubModal.customJSWarning': 'string'; + 'novelScreen.exportEpubModal.downloadedChaptersOnly': 'string'; + 'novelScreen.exportEpubModal.includeCustomCSS': 'string'; + 'novelScreen.exportEpubModal.includeCustomJS': 'string'; + 'novelScreen.exportEpubModal.selectFolder': 'string'; + 'novelScreen.exportEpubModal.title': 'string'; + 'novelScreen.epub.exportFailed': 'string'; + 'novelScreen.epub.exportSuccess': 'string'; + 'novelScreen.epub.noDownloadedChapters': 'string'; + 'novelScreen.epub.noNovelSelected': 'string'; 'novelScreen.coverSaved': 'string'; 'novelScreen.coverNotSaved': 'string'; 'novelScreen.deleteChapterError': 'string';